可靠性设计: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)
去重键 TTL5min覆盖客户端重发的最大时间窗;TTL 过长浪费内存
重发处理间隔100ms扫描周期,批量取出到期项
重发队列上限单 uid 200 条超过则丢弃最旧的并计入告警

排查速查

现象可能原因定位手段
客户端一直转圈server ack 未返回:落库慢或唯一键冲突异常查 server ack 延迟与落库错误率
消息重复出现在接收方接收端水位未推进就交付,或端 ACK 丢失导致重发查 seq 分布与客户端去重逻辑
一条消息在重发表里长期不消失接收方 ACK 通路故障(上行 ACK 被限流)查端 ACK 上报量与限流日志
重发表内存暴涨接收方大面积长时间离线且仍标记重发转离线存储,停止对离线设备重发
消息「已发送」但对方没有落库成功但下行全部失败,路由失效查投递失败率与路由清理次数

小结:可靠性靠三段确认串起来——上行有 server ack、存储有事务与副本、下行有端 ACK;重发用 Redis 有序集合做延迟队列,配指数退避与 3 次上限,超过就交给上线补拉兜底;去重在两端各设一道闸门,服务端靠去重键加唯一键做到「重复上行只产生一条消息」,接收端靠 seq 水位丢弃重复。整套设计接受至少一次投递,用幂等把重复抹平,最终得到的效果等价于恰好一次。

笔记加载中…