spark-server/docs/architecture-v3.md
DESKTOP-L8OUL9F\gfl0034 d293de34f5 实现 TCP READ(命令 8),已读水位前进时清未读并通知其他设备。
补上会员价格按平台筛选,并修正广场机器人测试的 Feed 调用,使 go test 能通过。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 18:22:08 +08:00

28 KiB
Raw Blame History

生产版 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. 总览

                         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 监听

公网          :443
Gin           127.0.0.1:8080
本机 HTTPS    127.0.0.1:8443     Nginx 终结 api 证书
gnet          127.0.0.1:9000     PROXY 头 + 终结 im 证书
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。

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:

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

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)

Multicore          true
ReusePort          true
SocketRecvBuffer   16MB
SocketSendBuffer   16MB
TCPKeepAlive       30s

3. 为什么仍是单体

443 → gnet → TaskPool → MySQL → 入队 → ACK
                Redis list → Deliver Worker → 列表 / Push

HTTP 与 IM 共用 Application。拆微服务只会在这一跳加 RPC。


4. 目录

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. 分层

Nginx 443
  → Transport(Gin / gnet)
  → TaskPool                         // inbound,含 MySQL
  → Application
  → MySQL | Redis | ObjectStore | PushNotifier

禁止:EventLoop 跑 MySQL / 扇出;MessageService 碰 gnet.Conn;发送路径同步推完所有设备。


6. Transport

6.1 EventLoop 硬规则

允许:PROXY、TLS、拆包、心跳、限流判定、Submit TaskPool、AsyncWrite
禁止:MySQL、Redis 同步调用、对象存储、群成员展开、Deliver 扇出

两套池:

TaskPool         发送方延迟(AUTH / SEND / SYNC / READ / RECALL)
Deliver Worker   扇出延迟(列表、Push、失败邮箱)

SEND_ACK 只等 TaskPool 里的 MySQL + 入队。

6.2 TaskPool

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。

TCP → Nginx TLS → PROXY → gnet Frame → JSON envelope
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:

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

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
}
同 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

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)

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 发送前校验(事务前完成)

P2P   好友关系、未拉黑、对方未禁你
群    是成员、未全员禁言 / 未被单独禁言
通用  类型合法、content ≤ 64KB、媒体只允许引用

失败不入队。系统号走单独开关,可 NotPush。


10. 表

10.1 messages

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。

SELECT * FROM t_messages
 WHERE conversation_id = ? AND id > ?
 ORDER BY id ASC LIMIT 200;

10.2 conversations / P2P 唯一 / members

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(失败邮箱 + 控制事件)

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}。

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):

ZADD unread 或 read(免打扰进 read,score 仍更新)
HINCRBY unreadcnt  (免打扰 / 发送者自己不加;按 MsgId 幂等)
SET preview

列表接口 / SYNC_INBOX 的会话差量:

ZRANGEBYSCORE ... (last_dialog_score +inf

不要每条群消息 UPDATE N 行 MySQL inbox。MySQL 只在:建会话、改成员、持久化 last_read_msg_id、控制事件落 mailbox。

预览与未读以 Redis 为准;Redis 丢失后:

会话列表可降级扫 conversation_members + conversations.last_msg_at(冷路径)
未读用 last_read_msg_id 与 last_msg_id 重算
消息正文不受影响

12. 扇出:可靠 Redis 队列

12.1 结构

deliver:queue         LIST    待处理
deliver:processing    LIST    已取出未完成
deliver:done:{msgId}  STRING  短 TTL,幂等
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

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)

ExcludeDeviceId    发送设备
NotPush            系统消息、已读回执等不打系统推送
NotMailbox         不需要失败邮箱(例如纯在线信令)

规则在 Worker,不在 gnet。

12.4 Redis 丢队列

SEND_ACK 之后才允许 Redis 丢数据的窗口只剩「队列里还没消费」。处理中列表用于扛 Worker 崩溃。若 Redis 整实例丢失:

正文仍在 MySQL
列表可重建
实时 Push 可能漏
上线 SYNC_MESSAGES 按 last_msg_id 仍能补已知会话
新会话感知会弱一档(直到有下一次列表重建)

这是 C7 的代价,写进不变量,不把 Redis 当消息真相。


13. 两级同步

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 次窗口):

SYNC_INBOX
SYNC_MESSAGES
HTTP GetHistory
SEND_MESSAGE
AUTH

从第一条业务命令开始限流,不要 SequenceID > 200。


14. ACK

SEND_ACK    已落库且已入队
PUSH_ACK    传输层,不落业务库
READ        更新 last_read_msg_id,清 Redis 未读,入队通知其他设备

请求(命令 8,业务 JSON,无信封):

{"conversation_id": 1001, "msg_id": 2002}

msg_id 是成员已读到的消息 id。成功应答仍是命令 8,Body 为 V3 信封,data.msg_id 为写入后的 last_read_msg_id(只增不减;更小的 id 不回退)。缺 conversation_id 或 msg_id(≤0)返回 40001。消息不存在为 40401;消息不属于该会话为 40001;不是成员为 40301。

水位前进时入队:ToUserIDs 仅为当前用户,ExcludeDevice 为当前设备,NotPush 与 NotMailbox 均为真。其他在线设备收到命令 8。

不做业务 DELIVERY_ACK。

已读:MySQL last_read_msg_id + Redis HDEL unreadcnt + 从 unread ZSET 挪到 read ZSET。


15. 模块接口

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. 错误 / 日志 / 指标

var (
    ErrUnauthorized, ErrForbidden, ErrNotFound
    ErrInvalidArgument, ErrDuplicate
    ErrRateLimited, ErrBusy, ErrInternal
)

日志:trace_id user_id device_id real_ip connection_id command conversation_id msg_id。

指标:

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. 启动与关闭

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. 配置

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. 核心链路

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 + 可选系统推送

离线:

AUTH → SYNC_INBOX(ZSET 差量 + mailbox) → SYNC_MESSAGES(id > last_msg_id)

HTTP:

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. 验收

公网只有 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 补齐。