# 生产版 Go 单体 IM 系统架构设计 V3.0 > 版本:V3.0 > 基于:V2.0 + 对照 BFF 现网补上的可靠性、入口与会话列表切分 > 架构形态:Go Modular Monolith(模块化单体) > 适用阶段:单节点 / 单地域生产环境 > 核心原则:**先保证正确性,再根据压测结果引入复杂度** 不上 Kafka、K8s、微服务、Keyed Worker。机器一台、公网 IP 一个、公网端口只有 443。 --- # 0. 硬约束(继承 V2,V3 再钉三条) | # | 约束 | 含义 | | --- | --- | --- | | C1 | 消息存储 = MySQL + MsgId | 正文只在 MySQL。Snowflake `MsgId` 是主键、会话内排序和增量游标。没有 ConversationSeq。 | | C2 | EventLoop 与 MySQL 拆开;扇出走 Redis list | 学 BFF `deliversv`。入队用可靠队列,不是裸 `BLPOP`。 | | C3 | gnet 不自研加密协议,走 TLS | TLS 负责保密。应用层只有薄 Frame + 可选压缩。 | | C4 | 公网只开 443;Nginx 按 SNI 分流 | HTTP / IM 两个域名。gnet 只绑回环。 | | C5 | `SEND_ACK` = 已落库 **且** 已入扇出队列 | `COMMIT → 入队 → ACK`。Redis 失败不 ACK。 | | C6 | 透传必须带真实 IP | Nginx `proxy_protocol`;gnet 在 TLS 之前读 PROXY 头。 | | C7 | 会话列表 / 未读在 Redis | 学 BFF ZSET。MySQL 不承担群消息 N 行 inbox 更新。 | 相对 V2 明确撤回: * 撤回 `COMMIT → SEND_ACK → RPUSH`(中间崩溃会对端无感知) * 撤回 MySQL `user_inbox` 作为热路径会话列表 * 撤回小群给每个成员写 `NEW_MESSAGE` inbox_event * 撤回裸 `BLPOP` 作为唯一消费方式 * 撤回「gnet 用 RemoteAddr 当客户端 IP」(那是 127.0.0.1) 不抄 BFF:自定义 ECDH/AES、Push 超时拆连接、`SequenceID > 200` 才限流、CheckToken 绑 Redis conn、`context.TODO()` 热路径、`netstat` 数在线。 --- # 1. 总览 ```text Internet │ :443 TLS │ Nginx stream + SNI + proxy_protocol ┌─────────────┴─────────────┐ │ api.example.com │ im.example.com ▼ ▼ 127.0.0.1:8443 127.0.0.1:9000 Nginx 终结 TLS gnet:PROXY → TLS │ │ 127.0.0.1:8080 EventLoop Gin │ │ TaskPool └──────────┬───────────────┘ ▼ Application │ ┌────────────┼────────────┐ ▼ ▼ ▼ MySQL Redis Session 消息正文 列表/未读 本机连接 成员/已读 预览/限流 控制邮箱 deliver 队列 │ Deliver Worker 列表 + Push + 失败邮箱 ``` > **实时靠连接,正文靠 MySQL,列表靠 Redis,扇出靠可靠队列,对外只露 443。** `SEND_ACK` 表示:消息已在 MySQL,且 deliver 任务已在 Redis 队列里。不表示对方已收到。对方最终可见靠:在线 Push,或上线后 `SYNC_INBOX` + `SYNC_MESSAGES`。 --- # 2. 部署与入口 ## 2.1 监听 ```text 公网 :443 Gin 127.0.0.1:8080 本机 HTTPS 127.0.0.1:8443 Nginx 终结 api 证书 gnet 127.0.0.1:9000 PROXY 头 + 终结 im 证书 ``` ```text HTTPS API https://api.example.com IM 长连接 im.example.com:443 ``` ## 2.2 Nginx:SNI + PROXY protocol IM 是自定义 TCP,不是 HTTP。stream 用 `ssl_preread` 读 SNI,对 IM **透传 TLS**,并在转发给 gnet 时加上 PROXY protocol,否则 gnet 只看得到 127.0.0.1。 ```nginx stream { map $ssl_preread_server_name $backend { api.example.com 127.0.0.1:8443; im.example.com 127.0.0.1:9000; default ""; } map $ssl_preread_server_name $proxy_proto { im.example.com 1; default 0; } server { listen 443 reuseport; ssl_preread on; proxy_connect_timeout 5s; proxy_timeout 3600s; proxy_protocol on; # 仅对需要的 upstream 生效见下 proxy_pass $backend; } } ``` 未知 SNI **不要**落到 HTTP。`default` 为空则拒绝连接。IM 证书必须匹配 `im.example.com`。 HTTP: ```nginx http { server { listen 127.0.0.1:8443 ssl; server_name api.example.com; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; } } } ``` 若 stream 全局 `proxy_protocol on` 会影响 8443,则拆成两个 `server` / 两个 `map`:只对 `im.example.com` 打开 `proxy_protocol`。以本机实测为准,原则是:**只有 gnet 收到 PROXY 头,Gin 仍走 HTTP 头里的真实 IP。** ## 2.3 gnet:先 PROXY,再 TLS ```text accept → 读 PROXY v1/v2(超时要短,非法头断开) → 取出 src IP/port 写入 ConnInfo.RealIP → TLS handshake(证书 = im.example.com) → Frame ``` 限流、封禁、审计、日志一律用 `RealIP`,禁止用 `127.0.0.1`。 ## 2.4 超时必须对齐 | 项 | 值 | 理由 | | --- | --- | --- | | 客户端心跳 | 30s | 与 BFF `TCPKeepAlive` 同量级 | | gnet `TCPKeepAlive` | 30s | 维持 Nginx 流 | | 连接 `IdleTimeout` | 120s | 连续错过心跳才断 | | Nginx `proxy_timeout` | 3600s | 长连接;靠 keepalive 探活 | | 本机探活 | Gin `/healthz`;gnet 管理口或 TCP 本机 | 公网安全组只放 443 | 三条对不齐时,客户端会表现为「IM 随机断线」,实际是 Nginx 静默掐流。BFF 没有这一跳,所以 V3 必须把对齐写进验收。 ## 2.5 gnet Options(搬 connectorsv) ```text Multicore true ReusePort true SocketRecvBuffer 16MB SocketSendBuffer 16MB TCPKeepAlive 30s ``` --- # 3. 为什么仍是单体 ```text 443 → gnet → TaskPool → MySQL → 入队 → ACK Redis list → Deliver Worker → 列表 / Push ``` HTTP 与 IM 共用 Application。拆微服务只会在这一跳加 RPC。 --- # 4. 目录 ```text im-server/ ├── cmd/server/main.go ├── internal/ │ ├── bootstrap/ │ ├── transport/ │ │ ├── http/ │ │ └── tcp/ │ │ ├── server.go # PROXY + TLS │ │ ├── connection.go │ │ ├── command_router.go │ │ ├── taskpool.go │ │ ├── protocol/ # 薄 Frame + 压缩 │ │ └── session/ │ ├── module/ │ │ ├── auth/ │ │ ├── user/ │ │ ├── friend/ │ │ ├── group/ │ │ ├── conversation/ │ │ ├── message/ │ │ ├── inbox/ # Redis ZSET 列表 + 失败邮箱 │ │ ├── sync/ │ │ ├── session/ │ │ ├── upload/ │ │ └── deliver/ │ └── infrastructure/ │ ├── mysql/ │ ├── redis/ │ ├── id/ # Snowflake │ ├── logger/ │ ├── metrics/ │ ├── tracing/ │ └── push/ ├── deploy/nginx/ ├── api/proto/ ├── configs/ └── migrations/ ``` 模块内:`service.go` + `entity.go` + `repository.go`。不先堆 DDD。 --- # 5. 分层 ```text Nginx 443 → Transport(Gin / gnet) → TaskPool // inbound,含 MySQL → Application → MySQL | Redis | ObjectStore | PushNotifier ``` 禁止:EventLoop 跑 MySQL / 扇出;`MessageService` 碰 `gnet.Conn`;发送路径同步推完所有设备。 --- # 6. Transport ## 6.1 EventLoop 硬规则 ```text 允许:PROXY、TLS、拆包、心跳、限流判定、Submit TaskPool、AsyncWrite 禁止:MySQL、Redis 同步调用、对象存储、群成员展开、Deliver 扇出 ``` 两套池: ```text TaskPool 发送方延迟(AUTH / SEND / SYNC / READ / RECALL) Deliver Worker 扇出延迟(列表、Push、失败邮箱) ``` `SEND_ACK` 只等 TaskPool 里的 MySQL + 入队。 ## 6.2 TaskPool ```go type TaskPool interface { Submit(ctx context.Context, task func()) error } ``` 有界 channel 即可。满则 `ErrBusy`,客户端用同一 `client_msg_id` 重试。心跳 / PROXY / PUSH_ACK 留在 EventLoop。 热路径必须带可取消的 `context.Context`,禁止 `context.TODO()`。 --- # 7. 协议:TLS + 薄 Frame 不学 BFF 握手加密、双版本头、把 Response 当 Request。 ```text TCP → Nginx TLS → PROXY → gnet Frame → JSON envelope ``` ```text Magic u16 | Version u8 | Command u16 | Flags u16 RequestID u64 | SequenceID u32 | BodyLength u32 JSON Body {"code","msg","request_id","data"} ``` Body **不是 Protobuf**。`Flags` 含 `Compress`。Body 超过 512B 再压缩(阈值可配)。 `SequenceID` 只匹配请求/响应和 PUSH_ACK。 `MaxFrameSize = 64KB`。超大 `BodyLength` 断开。媒体不走 Frame。 ## 7.1 Command TCP: ```text AUTH HEARTBEAT GENERATE_MSG_ID SEND_MESSAGE SEND_ACK MESSAGE PUSH_ACK READ_MESSAGE RECALL_MESSAGE SYNC_INBOX SYNC_INBOX_FINISH SYNC_MESSAGES SYNC_MESSAGES_FINISH KICK ``` HTTP:资料、好友、群管理、会话列表分页、历史、上传凭证、设置。 HTTP 写操作走同一 Application,并入 deliver 队列。 --- # 8. Session ```go type Connection interface { ID() string UserID() int64 DeviceID() uint64 RealIP() string Send(packet *Packet) error Close() error } type SessionManager interface { Bind(session Session) error Unbind(sessionID string) error GetUserSessions(userID int64) []Session SendToUserExceptDevice(ctx context.Context, userID int64, exceptDeviceID uint64, packet *Packet) error } ``` ```text 同 device_id 踢旧连接,下发 KICK 多 device 允许 发送设备 不回推 MESSAGE 其他设备 必须推 cursor (user_id, device_id) ``` 出站有界队列,满则丢实时、计 `outbound_drop`。PUSH_ACK 超时只降级到失败邮箱 / PushNotifier,**不拆连接**。 连接态:`CONNECTING → CONNECTED → AUTHENTICATED → ACTIVE → CLOSING → CLOSED`。AUTH 一次,身份挂 Session。 不写 `im:user:{uid}:node`。 --- # 9. 消息:MySQL + MsgId ## 9.1 ID ```text MsgId Snowflake,主键、排序、同步游标 ClientMsgID 客户端幂等 TCP SequenceID 请求匹配 InboxSeq 仅失败邮箱 / 控制事件 ``` Snowflake worker id 写进配置并固定到这台机器。时钟回拨:拒绝发号或等待,禁止生成回退的 MsgId。重启后 worker id 不变。 ## 9.2 GENERATE_MSG_ID 学 BFF `0x0219`。客户端可先拿 MsgId 做乐观 UI,再 `SEND_MESSAGE` 带同一 id。 若请求自带 MsgId:校验其时间戳在 ±5 分钟内;已存在则走幂等返回;非法则服务端重发一个。 ## 9.3 发送路径(C5) ```text TaskPool: 1. RealIP 限流 2. 权限(见 9.4),失败不写库 3. MsgId = 请求带来的或 Snowflake 4. BEGIN INSERT t_messages UPDATE t_conversations.last_msg_id / last_msg_at COMMIT 5. DeliverQueue.Push // 可靠入队,失败则重试有限次 6. 仍失败 → 不 SEND_ACK,客户端用 client_msg_id 重试 7. SEND_ACK(msg_id, client_msg_id, server_time) ``` 幂等:`UNIQUE(sender_id, conversation_id, client_msg_id)`。冲突查出原行返回原 MsgId;若原消息尚未在队列里(极端),补一次 `Push` 再 ACK。 ## 9.4 发送前校验(事务前完成) ```text P2P 好友关系、未拉黑、对方未禁你 群 是成员、未全员禁言 / 未被单独禁言 通用 类型合法、content ≤ 64KB、媒体只允许引用 ``` 失败不入队。系统号走单独开关,可 `NotPush`。 --- # 10. 表 ## 10.1 messages ```sql CREATE TABLE t_messages ( id BIGINT PRIMARY KEY COMMENT 'Snowflake MsgId,主键、排序键、同步游标', conversation_id BIGINT NOT NULL COMMENT '所属会话 id', sender_id BIGINT NOT NULL COMMENT '发送者 user_id', client_msg_id VARCHAR(64) NOT NULL COMMENT '客户端消息 id,与 sender+会话 组成幂等键', message_type SMALLINT NOT NULL COMMENT '1文本 2图片 3语音 4视频 5表情 6GIF 7文件 8位置 9系统', content BLOB NOT NULL COMMENT '序列化 JSON 字节,禁止存原始 string;媒体只存对象存储 key', status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 2=已撤回 3=审核隐藏', created_at DATETIME(3) NOT NULL COMMENT '服务端入库时间', UNIQUE KEY uk_sender_conv_client_msg (sender_id, conversation_id, client_msg_id), KEY idx_conversation_id (conversation_id, id) ) COMMENT='消息正文。PK=MsgId;content 为序列化 BLOB'; ``` `message_type` 与 `content` 内 JSON 的 `t` 必须一致。正文用 `json.Marshal` 得到的 `[]byte` 写入 BLOB,不要把 Go `string` 当列类型。图片/语音/GIF/文件只存对象存储 key。完整 COMMENT 见 `migrations/0001_init.sql`。 ```sql SELECT * FROM t_messages WHERE conversation_id = ? AND id > ? ORDER BY id ASC LIMIT 200; ``` ## 10.2 conversations / P2P 唯一 / members ```sql CREATE TABLE t_conversations ( id BIGINT PRIMARY KEY, type TINYINT NOT NULL, -- 1=P2P 2=GROUP last_msg_id BIGINT NOT NULL DEFAULT 0, last_msg_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL ); -- 两人永远同一条会话,学 BFF 小 uid : 大 uid CREATE TABLE t_p2p_conversations ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, user_small BIGINT NOT NULL, user_big BIGINT NOT NULL, conversation_id BIGINT NOT NULL, UNIQUE KEY uk_p2p_users (user_small, user_big), UNIQUE KEY uk_conversation (conversation_id) ); CREATE TABLE t_conversation_members ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, conversation_id BIGINT NOT NULL, user_id BIGINT NOT NULL, last_read_msg_id BIGINT NOT NULL DEFAULT 0, joined_at DATETIME(3) NOT NULL, muted TINYINT NOT NULL DEFAULT 0, UNIQUE KEY uk_conversation_members (conversation_id, user_id), KEY idx_user_id (user_id) ); ``` `last_read_msg_id` 在 MySQL,作为已读真相(重启 Redis 可从这里重建未读)。 会话列表不扫这张表的热路径。列 COMMENT 见 `migrations/0001_init.sql`。 不做 `message_receipts`。 ## 10.3 mailbox_events(失败邮箱 + 控制事件) ```sql CREATE TABLE t_mailbox_events ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, device_id BIGINT NOT NULL, -- 0 = 该用户所有设备 seq BIGINT NOT NULL, event_type SMALLINT NOT NULL, -- RECALL / KICK / MEMBER / FRIEND / MISSED_MESSAGE conversation_id BIGINT NULL, payload VARBINARY(4096), -- conversation_id + msg_id,无正文 created_at DATETIME(3) NOT NULL, UNIQUE KEY uk_user_device_seq (user_id, device_id, seq), KEY idx_user_device (user_id, device_id, seq) ); CREATE TABLE t_device_sync_cursors ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, device_id BIGINT NOT NULL, mailbox_seq BIGINT NOT NULL DEFAULT 0, dialog_score BIGINT NOT NULL DEFAULT 0, -- Redis ZSET score 游标 updated_at DATETIME(3) NOT NULL, UNIQUE KEY uk_user_device (user_id, device_id) ); ``` **不**给每条成功 Push 的新消息写 mailbox。对齐 BFF ICH:邮箱只服务没推上的设备,以及撤回 / 踢人 / 好友这类控制事件。列 COMMENT 见 `migrations/0001_init.sql`。 --- # 11. 会话列表:Redis ZSET(C7) 对齐 BFF `unread:dialog:{uid}` / `read:dialog:{uid}`。 ```text im:dialog:unread:{uid} ZSET score=last_msg_at_ms member=conversation_id im:dialog:read:{uid} ZSET 同上 im:dialog:unreadcnt:{uid} HASH field=conversation_id value=count im:dialog:preview:{cid} STRING 最后一条短预览(id/type/text/sender) im:dialog:muted:{uid} SET im:dialog:pinned:{uid} ZSET ``` Deliver Worker 对每个收件人(pipeline): ```text ZADD unread 或 read(免打扰进 read,score 仍更新) HINCRBY unreadcnt (免打扰 / 发送者自己不加;按 MsgId 幂等) SET preview ``` 列表接口 / `SYNC_INBOX` 的会话差量: ```text ZRANGEBYSCORE ... (last_dialog_score +inf ``` 不要每条群消息 `UPDATE` N 行 MySQL inbox。MySQL 只在:建会话、改成员、持久化 `last_read_msg_id`、控制事件落 mailbox。 预览与未读以 Redis 为准;Redis 丢失后: ```text 会话列表可降级扫 conversation_members + conversations.last_msg_at(冷路径) 未读用 last_read_msg_id 与 last_msg_id 重算 消息正文不受影响 ``` --- # 12. 扇出:可靠 Redis 队列 ## 12.1 结构 ```text deliver:queue LIST 待处理 deliver:processing LIST 已取出未完成 deliver:done:{msgId} STRING 短 TTL,幂等 ``` ```go type DeliverJob struct { MsgID int64 Cmd uint16 ConversationID int64 FromUserID int64 FromDeviceID uint64 ExcludeDevice uint64 NotPush bool NotMailbox bool // 对应 BFF NotSaveIch Payload []byte } type DeliverQueue interface { Push(ctx context.Context, job *DeliverJob) error Take(ctx context.Context, timeout time.Duration) (*DeliverJob, error) // RPOPLPUSH Ack(ctx context.Context, job *DeliverJob) error // LREM processing RequeueStale(ctx context.Context) error } ``` 生产:`RPUSH deliver:queue`。失败则发送路径不 ACK。 消费:`RPOPLPUSH queue → processing`,处理完 `LREM processing`。进程崩溃:启动时把 `processing` 搬回 `queue`。这修补了 BFF 裸 `BLPOP` 丢任务的问题。 ## 12.2 Worker ```text job = Take if SETNX deliver:done:{msgId} 失败 → Ack 丢掉,防止 unread +2 1. 收件人(P2P / 群成员缓存) 2. 按 ExcludeDevice / muted / NotPush 过滤 3. pipeline 更新 ZSET 列表、预览、未读 4. Session.Send 在线设备 5. 写出失败或无连接: NotMailbox=false → mailbox_events(该 device) NotPush=false 且未 mute → PushNotifier Ack(job) ``` 群成员列表要有短缓存,不要每条消息打全表。 ## 12.3 旗帜(搬 BFF) ```text ExcludeDeviceId 发送设备 NotPush 系统消息、已读回执等不打系统推送 NotMailbox 不需要失败邮箱(例如纯在线信令) ``` 规则在 Worker,不在 gnet。 ## 12.4 Redis 丢队列 `SEND_ACK` 之后才允许 Redis 丢数据的窗口只剩「队列里还没消费」。处理中列表用于扛 Worker 崩溃。若 Redis 整实例丢失: ```text 正文仍在 MySQL 列表可重建 实时 Push 可能漏 上线 SYNC_MESSAGES 按 last_msg_id 仍能补已知会话 新会话感知会弱一档(直到有下一次列表重建) ``` 这是 C7 的代价,写进不变量,不把 Redis 当消息真相。 --- # 13. 两级同步 ```text AUTH → SYNC_INBOX(device_id, mailbox_seq, dialog_score) · ZSET 差量:会话列表 / 预览 / 未读 · mailbox 差量:MISSED_MESSAGE、RECALL、KICK、好友、成员 → 对 dirty conversation SYNC_MESSAGES(conversation_id, last_msg_id) ``` 限流按 `device_id` 分开(学 BFF 历史 200 次窗口): ```text SYNC_INBOX SYNC_MESSAGES HTTP GetHistory SEND_MESSAGE AUTH ``` 从第一条业务命令开始限流,不要 `SequenceID > 200`。 --- # 14. ACK ```text SEND_ACK 已落库且已入队 PUSH_ACK 传输层,不落业务库 READ 更新 last_read_msg_id,清 Redis 未读,入队通知其他设备 ``` 不做业务 DELIVERY_ACK。 已读:MySQL `last_read_msg_id` + Redis `HDEL unreadcnt` + 从 unread ZSET 挪到 read ZSET。 --- # 15. 模块接口 ```go type MessageService interface { GenerateMsgID(ctx context.Context, req *GenerateMsgIDRequest) (int64, error) SendMessage(ctx context.Context, req *SendMessageRequest) (*SendMessageResponse, error) RecallMessage(ctx context.Context, req *RecallMessageRequest) error MarkRead(ctx context.Context, req *MarkReadRequest) error GetHistory(ctx context.Context, req *GetHistoryRequest) (*GetHistoryResponse, error) } type SyncService interface { SyncInbox(ctx context.Context, req *SyncInboxRequest) (*SyncInboxResponse, error) SyncMessages(ctx context.Context, req *SyncMessagesRequest) (*SyncMessagesResponse, error) } type InboxStore interface { /* Redis ZSET + preview */ } type MailboxRepository interface { /* 失败/控制事件 */ } type DeliverQueue interface{} type PushNotifier interface { Notify(ctx context.Context, n *PushNotification) error } ``` `PushNotifier` 可先 Noop。媒体:HTTP 凭证 → 对象存储 → 消息只带引用。 --- # 16. Redis vs MySQL | MySQL | Redis | | --- | --- | | users / 好友 / 群 / 成员 | 在线、Session | | messages | deliver:queue / processing | | conversations / p2p 唯一 | dialog ZSET、未读、预览 | | last_read_msg_id | 限流 | | mailbox_events | Snowflake 无关 | 业务只依赖接口:`IDGenerator`、`InboxStore`、`DeliverQueue`、`PresenceStore`、`RateLimiter`。 --- # 17. 错误 / 日志 / 指标 ```go var ( ErrUnauthorized, ErrForbidden, ErrNotFound ErrInvalidArgument, ErrDuplicate ErrRateLimited, ErrBusy, ErrInternal ) ``` 日志:`trace_id user_id device_id real_ip connection_id command conversation_id msg_id`。 指标: ```text tcp_* eventloop_react_seconds taskpool_depth taskpool_rejected_total message_send_* persist_latency enqueue_latency send_ack_latency deliver_queue_depth deliver_processing_depth deliver_job_latency deliver_idempotent_skip_total outbound_drop_total push_ack_timeout_total inbox_sync_total message_sync_total realip_missing_total # PROXY 头异常 ``` --- # 18. 启动与关闭 ```text Config → Logger → MySQL → Redis → ID → RequeueStale processing → Session / TaskPool / Deliver Worker → gnet 127.0.0.1:9000(PROXY+TLS) → Gin 127.0.0.1:8080 → Ready ``` SIGTERM:探活失败 → 停 Accept → 抽干 TaskPool → Worker 再 Take 一轮后退出 → 未 Ack 的 processing 留着下次 `RequeueStale` → 关连接 / Redis / MySQL。 --- # 19. 配置 ```yaml server: http: addr: "127.0.0.1:8080" tcp: addr: "127.0.0.1:9000" proxy_protocol: true tls: cert_file: "/etc/im/im.example.com.crt" key_file: "/etc/im/im.example.com.key" max_frame_size: 65536 idle_timeout: 120s keepalive: 30s recv_buffer: 16777216 send_buffer: 16777216 multicore: true compress_min_size: 512 id: snowflake_worker_id: 1 mysql: dsn: "..." redis: addr: "127.0.0.1:6379" deliver_queue_key: "deliver:queue" deliver_processing_key: "deliver:processing" taskpool: size: 256 queue: 1024 deliver: workers: 8 take_timeout: 5s done_ttl: 10m rate_limit: message_per_second: 20 auth_per_second: 5 sync_inbox_per_minute: 60 sync_messages_per_minute: 120 history_per_minute: 200 message: max_content_size: 65536 sync_limit: 200 msgid_expire_ms: 300000 ``` --- # 20. 不变量 1. `SEND_ACK` = MySQL 已有消息 **且** 已 `Push` 进 deliver 队列 2. `(sender_id, conversation_id, client_msg_id)` 唯一 3. 会话内顺序是 `MsgId` 4. P2P `(user_small, user_big)` 唯一对应一个 `conversation_id` 5. 消息正文只在 MySQL;列表 / 未读 / 预览在 Redis 6. mailbox 只用于推送失败和控制事件 7. EventLoop 不做 MySQL / 扇出 8. 业务不碰 `gnet.Conn`;限流用 PROXY 真实 IP 9. gnet 只监听回环;公网只开 443;未知 SNI 断开 10. 保密靠 TLS,不靠应用层加密 11. 同一 `MsgId` 的 deliver 任务只生效一次 12. 发送设备不回推 MESSAGE;其他设备必须投递 13. Redis 丢列表不丢正文;丢队列不推翻已 ACK 的前提是入队已成功 --- # 21. 核心链路 ```text Client TLS :443 Nginx SNI im.example.com + PROXY gnet 读真实 IP → TLS → Frame TaskPool 权限 → INSERT t_messages → RPUSH 可靠队列 → SEND_ACK Deliver Worker 幂等 → ZSET 列表 → 在线 MESSAGE 失败 → mailbox + 可选系统推送 ``` 离线: ```text AUTH → SYNC_INBOX(ZSET 差量 + mailbox) → SYNC_MESSAGES(id > last_msg_id) ``` HTTP: ```text SNI api.example.com → Nginx 终结 TLS → Gin → 同一 Application ``` --- # 22. 开发顺序 1. 基础设施 + Snowflake worker id 2. Nginx 443 SNI + PROXY;Gin / gnet 本机打通;验收真实 IP 3. Frame、压缩、Session、AUTH、心跳超时对齐 4. P2P 唯一会话、消息落库、幂等、`GENERATE_MSG_ID` 5. 可靠队列:入队后再 ACK 6. Worker:ZSET 列表、预览、未读幂等、在线 Push、失败 mailbox 7. 两级 SYNC + 分项限流 8. 已读、撤回、免打扰、置顶 9. Upload 10. 好友 / 拉黑 / 群成员 / 禁言校验 + 群扇出 11. HTTP 写路径复用 Application 12. PushNotifier(先 Noop) 13. 压测验收 列表、邮箱、Upload 必须和消息主路径一起闭环。 --- # 23. 验收 ```text 公网只有 443;:8080 / :9000 外部不可达 SNI api. / im. 分流正确;未知 SNI 断开 IM 抓包为 TLS;gnet 日志里是客户端真实 IP,不是 127.0.0.1 心跳 30s、空闲 120s、Nginx 1h:长连接不被静默掐断 EventLoop P99 不随 MySQL P99 线性恶化 COMMIT 后 Redis 失败:客户端收不到 SEND_ACK,重试不双发 ACK 后杀进程:processing 重启后续上,unread 不 +2 同 client_msg_id / 同 MsgId 返回同一条 P2P 两人无论谁先发,conversation_id 相同 千人群:SEND_ACK P99 稳定,MySQL 无 N 行 inbox 更新 离线用户上线:列表 ZSET 能看见会话,再按 MsgId 拉正文 撤回 / KICK 经 mailbox 到达;成功在线 Push 不写 mailbox 发送方第二台设备能收到 慢客户端 outbound 满不拖垮进程 清空 Redis:历史消息仍在;列表可重建;不出现 MsgId 回绕 ``` --- # 24. 何时再加复杂度 | 现象 | 才引入 | | --- | --- | | 单机 443 / CPU / 连接打满 | 多机 + 上游 4 层 | | deliver 持续堆积 | 加 worker;再按 conversation 分 list | | 大群 / 搜索 / 审计削峰 | MQ | | 模块独立故障域 | 拆服务 | --- # 25. 一句话 > **一台机器、一个 443;Nginx 按 SNI 拆 HTTP 与 IM,并用 PROXY 把头上的真 IP 传给 gnet;gnet 在本机做 TLS 和薄 Frame;EventLoop 不碰 MySQL;消息用 MySQL + Snowflake MsgId;先可靠入队再 ACK;会话列表走 Redis ZSET;邮箱只收失败推送和控制事件;最终靠两级 SYNC 补齐。**