补上会员价格按平台筛选,并修正广场机器人测试的 Feed 调用,使 go test 能通过。 Co-authored-by: Cursor <cursoragent@cursor.com>
906 lines
28 KiB
Markdown
906 lines
28 KiB
Markdown
# 生产版 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 未读,入队通知其他设备
|
||
```
|
||
|
||
请求(命令 8,业务 JSON,无信封):
|
||
|
||
```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. 模块接口
|
||
|
||
```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 补齐。**
|