spark-server/docs/architecture-v3.md
DESKTOP-L8OUL9F\gfl0034 c0ed0d83de fix: 命令 8 接受 last_msg_id 作为已读水位
客户端已读请求常只有 conversation_id 和 last_msg_id。任一消息 id 大于 0 即写入 last_read_msg_id 并清 Redis 未读,不再因此返回 40001。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 19:26:48 +08:00

912 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 生产版 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}
```
也接受客户端现用的 `last_msg_id`(可同时带 `peer_id`,服务端不使用该字段):
```json
{"conversation_id": 1001, "peer_id": 9, "last_msg_id": 2002}
```
`msg_id` 与 `last_msg_id` 任一大于 0 即是成员已读到的消息 id;两者都大于 0 时用 `msg_id`。成功应答仍是命令 8,Body 为 V3 信封,`data.msg_id` 为写入后的 `last_read_msg_id`(只增不减;更小的 id 不回退)。缺 `conversation_id`,或两个消息 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 补齐。**