消息投递链路:上行、路由、下行与 ACK
一条消息从发送方键盘到接收方屏幕,中间要穿过两个不同的连接、一次持久化、一次路由查找和三类确认。链路上任何一跳失败,用户看到的都是同一个现象——「对方没收到」,但排查方向完全不同。把链路拆成可独立观测的段,是 IM 投递设计的起点。
链路全景
客户端A 网关A 逻辑层(投递) 存储层 网关B 客户端B
| | | | | |
|--①上行 M1-->| | | | |
| |--②转发--------->| | | |
| | |--③分配 msg_id/seq------------>| |
| | |--④落库-------->| | |
| | |<--ok-----------| | |
| |<--⑤server ack---| | | |
|<--server ack| | | | |
| | |--⑥查路由 route:uid:{B}------->| |
| | |--⑦下行 M1-------------------------------> |
| | |<--⑧端 ACK(delivered)--------------------- |
链路上有四个边界,每个边界都要独立定义成功与失败的语义:
| 段 | 边界 | 成功语义 | 失败后谁负责重发 |
|---|---|---|---|
| ①→② 上行 | 客户端 → 网关 | 网关已解析并转交逻辑层 | 客户端(带同一 client_msg_id) |
| ③→⑤ 定序与落库 | 逻辑层 → 存储 | 序号已分配且消息已持久化 | 客户端(未收到 server ack) |
| ⑥→⑦ 路由下行 | 逻辑层 → 接收方网关 | 网关已写入目标连接 | 服务端(重发表驱动) |
| ⑧ 端 ACK | 接收方客户端 → 服务端 | 客户端已收妥可展示 | 服务端(重发表驱动,带次数上限) |
上行报文结构
上行(uplink)的第一个设计要点是客户端自带唯一标识:所有重发都复用同一个 client_msg_id,服务端据此去重,上行链路因此可以放心做成「超时重发」。
// Uplink 上行报文(客户端 → 服务端)
type Uplink struct {
Type int32 `json:"type"` // 1=单聊 2=群聊 10=心跳 11=ACK 12=补拉
ClientMsgID string `json:"client_msg_id"` // 客户端生成的 UUID,重发时保持不变
ConvID string `json:"conv_id"` // 会话 ID,单聊为排序后的 uid 对
ToUID int64 `json:"to_uid,omitempty"`
Body []byte `json:"body"` // 加密后的载荷
ClientSeq int64 `json:"client_seq"` // 客户端本地序号,仅用于排查
SentAt int64 `json:"sent_at"` // 客户端毫秒时间戳,仅用于展示
}
时间戳只用于展示,不参与排序:客户端的钟不可信,服务端落库时间才是权威(定序靠会话序号而不是时间,理由见消息 ID 与序列号一章)。网关对上行做三件事:解析与校验(帧完整性、长度上限)、鉴权上下文绑定(连接上已绑定的 uid 为准,不信任报文里的 uid)、限流(单连接每秒上行条数与字节数)。
下行报文结构
下行(downlink)的要点是把服务端分配的权威字段回带,客户端不需要自己推算任何东西:
// Downlink 下行报文(服务端 → 客户端)
type Downlink struct {
Type int32 `json:"type"`
MsgID string `json:"msg_id"` // 雪花 ID 字符串化,避免前端数字精度丢失
ConvID string `json:"conv_id"`
Seq int64 `json:"seq"` // 会话内单调递增,客户端按它排序
FromUID int64 `json:"from_uid"`
Body []byte `json:"body"`
ServerAt int64 `json:"server_at"` // 服务端落库毫秒时间戳
}
msg_id 必须序列化为字符串:雪花 ID 是 64 位整数,超过 JavaScript 安全整数范围(2^53-1),用数字下发会在 Web 端悄悄丢精度,出现「同一消息两个 ID」的诡异 bug。
三类 ACK 与职责划分
「ACK」在 IM 里是三个不同层次的东西,混为一谈是排障时最常见的错误:
| ACK 类型 | 产生者 | 确认的内容 | 丢失后果 |
|---|---|---|---|
| server ack | 逻辑层 | 序号已分配、消息已持久化 | 客户端重发上行(服务端按 client_msg_id 去重) |
| 端 ACK(delivered) | 接收方客户端 | 已收到并可展示 | 服务端按重发表重发下行 |
| 已读回执(read receipt) | 接收方客户端 | 用户已查看 | 只影响未读数,不影响消息可达 |
规则可以概括成两句话:上行由客户端负责到底,下行由服务端负责到底。客户端只有拿到 server ack 才能把本地那条「发送中」的消息标记为已发送;服务端只有拿到端 ACK 才能把消息从重发表里摘掉。ACK 的累计确认(cumulative ack)也很实用:客户端上报「已收到会话 C 的 seq ≤ 1000 的全部消息」,一条 ACK 顶一千条,能显著削减小消息的确认流量。
超时预算
链路上每一跳都要有明确的时间预算,否则一处慢就会把整条链路的资源占住:
| 阶段 | 目标耗时 | 超时阈值 | 超时后动作 |
|---|---|---|---|
| ①上行接入 | 网络往返 | 5s(客户端计时) | 客户端重发,复用 client_msg_id |
| ②网关转逻辑层 | < 1ms | 300ms | 返回可重试错误 |
| ③④定序 + 落库 | < 10ms | 200ms | 不返回 server ack,客户端重发 |
| ⑥路由查找 | < 1ms | 50ms | 熔断降级为离线,不阻塞链路 |
| ⑦下行推送 | < 1ms | 200ms | 标记重投,进入重发表 |
| ⑧等待端 ACK | — | 10s | 重发,最多 3 次 |
注意②与③的差别:网关到逻辑层的超时不等于失败,可能逻辑层已完成落库只是响应丢了。这正是需要 client_msg_id 去重的原因——同一条上行可能被处理两次,但只会产生一条消息。
失败分支与兜底
| 失败点 | 现象 | 兜底动作 |
|---|---|---|
| 上行重发多次仍失败 | 客户端一直在转圈 | 本地标记失败并提示,保留草稿供手动重发 |
| 落库成功、server ack 丢失 | 客户端重发 | 按 (from_uid, client_msg_id) 唯一键命中,返回已存在的 msg_id 与 seq |
| 路由查不到接收方 | 目标离线 | 转离线存储,上线后按会话位点拉取 |
| 目标网关已宕机 | 下行写入失败 | 清理路由字段,重新查一次路由(可能已重连到新网关) |
| 端 ACK 始终不来 | 重发 3 次仍无响应 | 停止重发并告警,转由下一次上线拉取补齐 |
| 逻辑层分片短暂不可用 | 整段链路积压 | 网关侧本地排队并施加背压,超阈值则拒绝新上行 |
投递链路的核心监控指标是端到端延迟(上行入网关到收到端 ACK 的 P50/P99)与server ack 延迟。两者差值突然变大,通常说明接收方侧有问题(重发、路由失效、连接质量差),而不是发送侧。
小结:投递链路按「上行 → 定序落库 → 查找路由 → 下行 → 端 ACK」分四段,每段独立定义成功语义与重发责任方;上行靠客户端持有的 client_msg_id 保证重发安全,下行靠服务端重发表保证送达;三类 ACK 分别确认「服务端收妥」「客户端收妥」「用户已读」,绝不能混用。把每跳的超时预算写进代码并在监控里区分 server ack 与端 ACK 延迟,链路问题才能被快速定位到具体一跳。