消息投递链路:上行、路由、下行与 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
②网关转逻辑层< 1ms300ms返回可重试错误
③④定序 + 落库< 10ms200ms不返回 server ack,客户端重发
⑥路由查找< 1ms50ms熔断降级为离线,不阻塞链路
⑦下行推送< 1ms200ms标记重投,进入重发表
⑧等待端 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 延迟,链路问题才能被快速定位到具体一跳。

笔记加载中…