27 KiB
生产版 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_MESSAGEinbox_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 messages
UPDATE 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 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 messages
WHERE conversation_id = ? AND id > ?
ORDER BY id ASC LIMIT 200;
10.2 conversations / P2P 唯一 / members
CREATE TABLE 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 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 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 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 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 未读,入队通知其他设备
不做业务 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. 不变量
SEND_ACK= MySQL 已有消息 且 已Push进 deliver 队列(sender_id, conversation_id, client_msg_id)唯一- 会话内顺序是
MsgId - P2P
(user_small, user_big)唯一对应一个conversation_id - 消息正文只在 MySQL;列表 / 未读 / 预览在 Redis
- mailbox 只用于推送失败和控制事件
- EventLoop 不做 MySQL / 扇出
- 业务不碰
gnet.Conn;限流用 PROXY 真实 IP - gnet 只监听回环;公网只开 443;未知 SNI 断开
- 保密靠 TLS,不靠应用层加密
- 同一
MsgId的 deliver 任务只生效一次 - 发送设备不回推 MESSAGE;其他设备必须投递
- Redis 丢列表不丢正文;丢队列不推翻已 ACK 的前提是入队已成功
21. 核心链路
Client TLS :443
Nginx SNI im.example.com + PROXY
gnet 读真实 IP → TLS → Frame
TaskPool
权限 → INSERT 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. 开发顺序
- 基础设施 + Snowflake worker id
- Nginx 443 SNI + PROXY;Gin / gnet 本机打通;验收真实 IP
- Frame、压缩、Session、AUTH、心跳超时对齐
- P2P 唯一会话、消息落库、幂等、
GENERATE_MSG_ID - 可靠队列:入队后再 ACK
- Worker:ZSET 列表、预览、未读幂等、在线 Push、失败 mailbox
- 两级 SYNC + 分项限流
- 已读、撤回、免打扰、置顶
- Upload
- 好友 / 拉黑 / 群成员 / 禁言校验 + 群扇出
- HTTP 写路径复用 Application
- PushNotifier(先 Noop)
- 压测验收
列表、邮箱、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 补齐。