可靠性设计:ACK、重发、去重与幂等
「消息不丢」在 IM 里不是一句口号,而是三段确认拼出来的结果:上行要有 server ack,落库要有持久化,下行要有端 ACK。三段里任何一段用「发了就算成功」的乐观语义,都会出现同一个现象——发送方显示成功、接收方永远收不到。可靠性设计的目标也不是「恰好一次(exactly-once)」,而是至少一次(at-least-once)投递 + 幂等(idempotent)处理 = 业务上的效果等价于恰好一次。
三段可靠性
| 段 | 手段 | 防的问题 | 缺失后果 |
|---|---|---|---|
| 上行 | server ack + 客户端超时重发 | 上行在网关或逻辑层丢失 | 客户端以为发出去了,服务端没有这条消息 |
| 存储 | 定序与落库同事务、多副本 | 分配了 seq 但消息没落库 | 客户端永久等待一个不存在的 seq |
| 下行 | 端 ACK + 服务端重发表 | 下行推送丢失、接收方未入库 | 服务端以为投递完成,接收方没有 |
三段的共同点是都需要一个可重发的稳定标识:上行重发复用 client_msg_id,下行重发复用 msg_id。没有稳定标识的重发等于重复投递,那是把可靠性问题变成了重复问题。
ACK 状态机
┌──────── 客户端上行超时重发(携带同一 client_msg_id)
v
[LOCAL] --上行--> [SENT] --server ack--> [STORED] --端 ACK--> [DELIVERED] --已读--> [READ]
本地草稿 等 server ack 已持久化、可下发 接收方已收妥并入库
| |
| 超时 5s,最多 3 次 | 端 ACK 超时 10s,最多 3 次
v v
[FAILED] 停止重发,等待上线后按位点补拉
状态迁移的条件与幂等性:
| 迁移 | 触发 | 是否幂等 | 重复触发时的动作 |
|---|---|---|---|
| LOCAL → SENT | 用户点击发送 | 否(用户动作) | 客户端按 client_msg_id 去重,禁止同一条消息产生两个上行 |
| SENT → STORED | 收到 server ack | 是 | 旧值覆盖新值无副作用,直接忽略 |
| SENT → SENT | 超时重发 | 是 | 服务端按 (from_uid, client_msg_id) 唯一键挡住 |
| STORED → DELIVERED | 收到端 ACK | 是 | DELIVERED 是终态,再收一次直接丢弃 |
| DELIVERED → READ | 收到已读回执 | 是 | 只更新未读数与回执状态 |
客户端状态机只允许向前迁移:一个迟到的 server ack 不能把已经 DELIVERED 的消息退回 SENT。这条规则能挡掉绝大多数「状态来回跳」的诡异 bug。
重发:延迟队列 + 退避
重发任务用 Redis 有序集合(sorted set)承载,score 直接存下次重发时间戳,取出即按时间排序,无需额外队列:
unack:uid:{uid} ZSET member=msg_id score=下次重发时间戳(毫秒)
retry:cnt:{msgID} String 已重发次数,TTL 24h,避免计数无限增长
// ScanDue 取出该用户到期待重发的 msg_id(score <= now),最多 limit 条。
func (q *RetryQueue) ScanDue(ctx context.Context, uid, now, limit int64) ([]string, error) {
key := fmt.Sprintf("unack:uid:%d", uid)
return q.rdb.ZRangeByScore(ctx, key, &redis.ZRangeBy{
Min: "-inf",
Max: strconv.FormatInt(now, 10),
Offset: 0,
Count: limit,
}).Result()
}
// backoff 返回第 n 次重发的延迟:1s、4s、16s;超过 3 次不再重发。
func backoff(n int) time.Duration {
if n <= 0 {
n = 1
}
return time.Second * time.Duration(1<<(2*(n-1)))
}
重发必须带退避(backoff),固定间隔重发在接收方网络差时会形成「重发风暴」:每秒重发一次与每秒十次对送达率几乎没有区别,却把带宽和 CPU 全部吃掉。重发次数必须封顶(建议 3 次),超过就停止并把消息留在服务端,等接收方上线时按会话位点拉取补齐——拉取是重发的兜底,重发是拉取的加速;对明确离线的接收方则不重发,直接转离线存储,避免重发表被离线设备撑爆。
去重:两端各一道闸门
重复的来源有四个:客户端重发、服务端重发、服务端内部重试、跨机房的链路重投。指望「不发生重复」是不现实的,正确做法是两端各设一道闸门。
服务端挡上行重复,用一个短 TTL 的去重键,顺便把首次结果缓存下来供重放:
// FirstTime 判断是否为首次上行。重复时返回 false 与首次分配的 msg_id,直接回放结果。
func (s *Dedup) FirstTime(ctx context.Context, uid int64, clientMsgID string) (bool, int64, error) {
key := fmt.Sprintf("dedup:up:%d:%s", uid, clientMsgID)
// 首次占位成功:value 先写 -1,落库拿到真实 msg_id 后再覆盖
ok, err := s.rdb.SetNX(ctx, key, "-1", 5*time.Minute).Result()
if err != nil || !ok {
raw, _ := s.rdb.Get(ctx, key).Int64()
return false, raw, err
}
return true, 0, nil
}
数据库唯一键是第二道闸门(Redis 可能丢数据),写入时直接依赖约束而不是「先查再插」:
INSERT INTO im_message (msg_id, conv_id, seq, from_uid, msg_type, body, client_msg_id, server_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW(3))
ON DUPLICATE KEY UPDATE msg_id = msg_id;
-- 影响行数 1:新插入;0:已存在(无需更新即命中唯一键)
「先 SELECT 判断存在再 INSERT」在并发下必然出问题:两个重发请求同时查不到、同时插入,一个报主键冲突。唯一键把判断和写入变成一次原子操作,这才是幂等的正解。注意不同客户端驱动对影响行数的返回语义有差异,上线前要用真实驱动验证一次。
接收端挡下行重复,规则更简单:seq 小于等于水位的一律丢弃。因为同会话内 seq 单调递增,任何比水位小的消息必然是重复投递或乱序残留:
客户端去重:
1. 收到 seq <= local_watermark → 直接丢弃(重复投递)
2. 收到 seq == local_watermark+1 → 交付并推进水位,再看缓冲区能否连续交付
3. 收到 seq > local_watermark+1 → 视为乱序,存入重排缓冲并触发补拉
参数建议
| 参数 | 建议值 | 说明 |
|---|---|---|
| server ack 超时 | 5s | 客户端计时,含两跳网络与一次落库 |
| 端 ACK 超时 | 10s | 服务端计时,给接收方留出解码与落库时间 |
| 重发上限 | 3 次 | 收益递减明显,超过就靠上线补拉 |
| 退避序列 | 1s / 4s / 16s | 指数退避(exponential backoff) |
| 去重键 TTL | 5min | 覆盖客户端重发的最大时间窗;TTL 过长浪费内存 |
| 重发处理间隔 | 100ms | 扫描周期,批量取出到期项 |
| 重发队列上限 | 单 uid 200 条 | 超过则丢弃最旧的并计入告警 |
排查速查
| 现象 | 可能原因 | 定位手段 |
|---|---|---|
| 客户端一直转圈 | server ack 未返回:落库慢或唯一键冲突异常 | 查 server ack 延迟与落库错误率 |
| 消息重复出现在接收方 | 接收端水位未推进就交付,或端 ACK 丢失导致重发 | 查 seq 分布与客户端去重逻辑 |
| 一条消息在重发表里长期不消失 | 接收方 ACK 通路故障(上行 ACK 被限流) | 查端 ACK 上报量与限流日志 |
| 重发表内存暴涨 | 接收方大面积长时间离线且仍标记重发 | 转离线存储,停止对离线设备重发 |
| 消息「已发送」但对方没有 | 落库成功但下行全部失败,路由失效 | 查投递失败率与路由清理次数 |
小结:可靠性靠三段确认串起来——上行有 server ack、存储有事务与副本、下行有端 ACK;重发用 Redis 有序集合做延迟队列,配指数退避与 3 次上限,超过就交给上线补拉兜底;去重在两端各设一道闸门,服务端靠去重键加唯一键做到「重复上行只产生一条消息」,接收端靠 seq 水位丢弃重复。整套设计接受至少一次投递,用幂等把重复抹平,最终得到的效果等价于恰好一次。