
消息队列里 at-least-once 常被当成 bug。到了 IM,它却是唯一靠谱的默认,靠三道防线,把"可能收到多次"变成用户无感。
目录
- 一、at-least-once 被低估了
- 二、第一道防线:发送端用 msgID 兜住弱网
- 三、第二道防线:服务端把离线消息先存着
- 四、第三道防线:接收端按 msgID 多端去重
- 五、三层怎么接力:一条时间线看全
- 六、为什么别追 exactly-once
一、at-least-once 被低估了
at-least-once(至少一次投递)在消息队列语境里常被当成需要额外处理的麻烦:“会重复,得自己写消费端幂等”。但在 IM 里,它不是 bug,是唯一正确的默认。
你可能遇到过这种事:发一条消息,先是"发送中",过一会儿变成"已送达",对方也只收到一条。你没操心"会不会发重了",因为客户端替你兜住了。这背后就是 at-least-once 的思维,允许重发,但保证你最终只看到一条。
很多人第一次接触"消费端幂等",是在消息队列里:MQ 引入了"消费者幂等"这个没人愿意写的代价,它的语境是"单个消费者从队列里取消息"。本文不是那个问题。我们谈的是 IM:一个用户同时开着手机、电脑、平板,关了又开,地铁里没信号,电梯里掉线。失败模式从"消费者挂了"变成了"多端 + 离线 + 弱网"。把 MQ 那套照搬过来,会漏掉 IM 真正难的地方。
IM 的可靠投递靠三道防线兜着:
- 发送端用唯一 msgID 做超时补传;
- 服务端做离线暂存(store-and-forward);
- 接收端按 msgID 多端幂等去重。
为什么是三道,不是两道?因为单独两道都留洞。
只有发送端重试,对方离线时消息直接丢;加了服务端暂存,对方上线能收到,但 at-least-once 意味着可能收到多次,用户看到重复。再加接收端去重,才把"多端 + 离线 + 弱网"三种失败模式全盖住。任何一层都拦不住所有情况,但三层叠起来,“消息最终到达、且用户只看到一条"就成立了。

下面每道防线和一段可运行的 Go 代码一起看,代码都在 evidence/code/ 下,能直接 go run。
二、第一道防线:发送端用 msgID 兜住弱网
先说发送端。你发一条"在吗”,网络一抖,服务端可能根本没收到,或者收到了但回你的 ACK 在路上丢了。这时候怎么办?最朴素的办法是:等 ACK,超时没收到就重发。
at-least-once(至少一次投递)。发送端保证消息"最终至少送达一次",代价是可能发多次。对应的机制是:每条消息带一个全局唯一 ID(msgID),发完等 ACK,超时没收到就重发,但重发时 msgID 不变。
这里有个细节值得停一下:msgID 必须在重发前就持有,所以常见做法是客户端在发送前就生成,uid + 时间戳 + 客户端自增序号,或者直接上 UUIDv7 这种带时间有序的 ID。服务端统一分配 msgID 也是一条可行路线,区别主要在 ID 的有序性和冲突处理上。为什么客户端生成更顺手?因为重发发生在发送端本地,网络抖了一下,客户端得立刻用同一个 ID 重发,它不能先去问服务端"我这条的 ID 是多少"。ID 在第一次发送前就定下来了,重发只是把同一封信再寄一次。
我写了个最小可跑的发送端(Go 1.26.4,仅标准库,代码在 evidence/code/sender-retry)。片段省略了 ackCh 的定义,它模拟"第 1 次 ACK 丢失"的场景,看发送端怎么反应:
const msgID = "m-0f3a9c" // 发送端生成的全局唯一 ID,重试不变
timeout := 50 * time.Millisecond
for attempt := 1; attempt <= 3; attempt++ {
msg := Message{MsgID: msgID, Body: body, Try: attempt}
fmt.Printf("[发送端] 第 %d 次发送 msgID=%s body=%q\n", attempt, msg.MsgID, msg.Body)
select {
case <-ackCh:
fmt.Printf("[发送端] 收到 ACK,投递成功(共尝试 %d 次,msgID 始终=%s)\n", attempt, msgID)
return
case <-time.After(timeout):
fmt.Printf("[发送端] 超时未收到 ACK,准备用同一 msgID 重发\n")
}
}
实测输出(Go 1.26.4 darwin/arm64):
[发送端] 第 1 次发送 msgID=m-0f3a9c body="在吗?周末爬山"
[网络] 第 1 次 ACK 丢失
[发送端] 超时未收到 ACK,准备用同一 msgID 重发
[发送端] 第 2 次发送 msgID=m-0f3a9c body="在吗?周末爬山"
[发送端] 收到 ACK,投递成功(共尝试 2 次,msgID 始终=m-0f3a9c)
注意第 1 次和第 2 次的 msgID 都是 m-0f3a9c。重试不是发了一条新消息,是同一封信的重寄。这一点很关键:接收端拿到重发的消息,靠 msgID 就能认出"这是刚才那封",而不是当成两条。

顺便澄清一个常见误区:有人觉得"TCP 不是保证可靠传输吗,为什么还要应用层 ACK?“TCP 的可靠只到对端内核收到字节,不保证应用真的处理了这条消息,也不管你有没有回已读。IM 的 ACK 是应用层的"我收到了、我处理了”,和 TCP 那层不是一回事。弱网下 TCP 连接本身可能重连,重连后上层的消息状态得靠 msgID 自己理清。
这个 msgID 还顺手解决了"消息状态"的展示问题。你发消息时看到的灰圈、单勾、双勾,本质就是状态机在走:灰色"发送中"= 本地还没拿到 ACK;单勾=服务端已收;双勾=已送达对方设备(已读另靠回执)。前两个状态靠的就是这一层的 ACK,msgID 是把这些状态钉在同一条消息上的关键。
生产里重试不会用固定超时硬等。固定 50 毫秒在真实移动网络下要么太短(地铁里 RTT 可能几百毫秒)要么太长(WiFi 下白白等)。
常见做法是超时随重试次数指数退避,并设上限和总次数:退避防弱网里雪崩式重发,上限防一条消息永远卡在重试里。超过上限还没 ACK,这条消息就该交给第二道防线:服务端暂存,等对方上线再投。
这层防的是"弱网丢包",ACK 丢了,消息不至于石沉大海。但它有个前提:接收方迟早会回 ACK。可 IM 的真实情况是,对方手机锁屏、切了后台、进了电梯,他根本不在线。消息发到服务端,不能再干等。

三、第二道防线:服务端把离线消息先存着
第一道防线假设接收方在线。真实 IM 里对方常常不在:手机锁屏、电脑合盖、人在地下车库。这时候消息到服务端,得先存起来,等他上线再给。
store-and-forward(存储转发)。服务端收到消息先落库,在线设备立刻推,离线设备暂存,等它上线再从库里拉历史消息。
落库存什么?至少消息体、发送者、发送时间、还有一条单调递增的序号 seq。seq 是后面"增量拉取"的锚点。存储上通常是两张表:一张消息表(按 seq 排序,全量历史),一张设备投递状态表(记录每个 uid+device_id 拉到了哪个 seq)。demo 在 evidence/code/server-store-forward,一台 phone 在线、pc 和 tablet 离线,服务端收到一条"晚上八点组局火锅":
(注意:真实落库还要按 (uid, msgID) 做幂等写入——防线 1 的重发会用同一 msgID 再来一次,不幂等就会写进两条,把后面靠 last_seq 的增量拉取也带乱。)
func (s *Server) Receive(msg string) {
// 落库前按 (uid, msgID) 唯一约束 / Redis SETNX 做幂等写入,防止防线1 重发污染消息表
s.store = append(s.store, msg) // 1. 落库(真实场景带 seq/发送者/时间)
for _, d := range s.devices {
if d.Online {
d.Inbox = append(d.Inbox, msg) // 在线:立即推
} else {
// 离线:暂存,等上线拉取
}
}
}
func (s *Server) OnConnect(devID string) {
d := s.devices[devID]
d.Online = true
d.Inbox = append(d.Inbox, s.store...) // 上线拉历史
}
实测输出(Go 1.26.4 darwin/arm64):
[服务端] 消息落库 seq=1: "晚上八点组局火锅"
[服务端] 推送给在线设备 phone
[服务端] 设备 pc 离线,暂存,等待上线拉取
[服务端] 设备 tablet 离线,暂存,等待上线拉取
--- PC 上线 ---
[服务端] 设备 pc 上线,拉取历史消息 1 条
--- tablet 上线 ---
[服务端] 设备 tablet 上线,拉取历史消息 1 条
各设备收件箱:
phone: [晚上八点组局火锅]
pc: [晚上八点组局火锅]
tablet: [晚上八点组局火锅]

phone 当场收到,pc 和 tablet 上线后从库里拿到同一条。消息没因为设备离线而丢。
在线设备为什么能"当场收到"?因为它走的是长连接推送通道(WebSocket 或自研长连),服务端主动推。但长连也会断,地铁进隧道,连接掉了,这段时间来的消息服务端不知道你在不在线,只能先落库。所以"在线推送"和"离线暂存"不是二选一,是一条消息先落库、能推就推、推不了就等上线拉,两条路都从落库这一步分出去。落库是这条防线的地基,没有它,长连一断消息就没了着落。
但这里有个简化得说清楚:demo 让离线设备上线时拉了全量历史,真实系统不会这么干。真实做法是每台设备记一个"已读游标" last_seq,上线只拉 seq > last_seq 的消息,已经看过的不再推。游标是 IM 多端同步的核心:你在手机上看到了 seq=100,电脑上线时从 101 开始拉,不会把 100 再推一遍。这和防线 3 的去重是两套机制,一个管"拉哪些",一个管"收到重复的怎么处理",互补但不替代。

还有个 ACK 的小陷阱:设备收到推送会回 ACK,服务端据此标记"这台设备已投递"。但 at-least-once 下这个 ACK 也可能丢,服务端以为没投到,又推一次。于是设备可能收到两条一样的。这就接出了第三道防线。
四、第三道防线:接收端按 msgID 多端去重
前两层保证"到"和"不丢"。第三层保证"不重",而且是在"多端"这个 IM 特有的麻烦上。
幂等(Idempotent)。同一条消息处理多次和处理一次效果相同。去重。靠 msgID 认出重复并丢弃。在 IM 里这俩要绑在"多端"上理解:一条消息要出现在我手机、电脑、平板上,但每台设备上只能出现一次;设备重连后服务端重推的历史消息,也不能在我某台设备上冒出第二条。
这和 MQ 的消费端幂等不一样。MQ 里常见用 Redis SetNX 给"单个消费者"去重,一条消息从队列出来,别被同一个消费者重复处理。IM 不是这样:你有三台设备,每台都是独立的消费者,各自有消费状态。难点不是"一个人别重复处理同一条",是"一个人的多台设备各自别重复,且重连时的历史消息也别重复"。去重的维度从 (consumer_group, msgID) 变成了 (uid, device_id, msgID)。多出来的 device_id 就是 IM 和 MQ 的本质差别。
demo 在 evidence/code/receiver-dedup,每个设备维护自己的 seen 集合(生产环境用 Redis SETNX 或 DB 唯一约束 + 已读游标),先看同一消息推三端,再看服务端重推:
func (d *DeviceInbox) Deliver(msgID, body string) {
if d.seen[msgID] {
fmt.Printf("[%s] msgID=%s 已消费过,丢弃(去重)\n", d.ID, msgID)
return
}
d.seen[msgID] = true
d.consumed++
}
实测输出(Go 1.26.4 darwin/arm64):
=== 场景1:同一消息推给一个用户的三台设备 ===
[phone] 消费 msgID=m-7b2d11 body="晚上八点组局火锅"(本设备第 1 条)
[pc] 消费 msgID=m-7b2d11 body="晚上八点组局火锅"(本设备第 1 条)
[tablet] 消费 msgID=m-7b2d11 body="晚上八点组局火锅"(本设备第 1 条)
=== 场景2:at-least-once 下服务端重推同一条历史消息 ===
[phone] msgID=m-7b2d11 已消费过,丢弃(去重)
[pc] msgID=m-7b2d11 已消费过,丢弃(去重)
各设备最终消费条数:phone=1 pc=1 tablet=1(均为 1,无重复)

三台设备各出现一次,消息要上我所有设备;重推的旧消息被各自丢弃,不能在我某台设备上冒出第二条。这就是 IM 幂等去重和 MQ 单消费者去重不一样的地方:它防的不是"一个消费者重复处理",而是"一个用户的多端重复看到"。
把 device_id 这层展开看会更清楚:手机上的 seen 有 m-7b2d11,不代表电脑的 seen 也有,它们是各自独立的集合。所以电脑第一次收到这条,要显示、并把自己的 seen 写进去;手机重推时,手机的 seen 已经有过,直接丢弃。正是这种"每设备消费状态独立",才让"消息上我所有设备、每台只一次"同时成立。MQ 的单消费者去重没有 device_id 这一维,因为它根本不存在"一个用户多台设备"的语义。
生产里 seen 集合不会只放内存,进程一重启就没了。常用两招:Redis SETNX,天然"存在则设失败",正好表达"这条我已处理过";或者 DB 上给 (uid, device_id, msgID) 建唯一约束,插入冲突即判重复。图片、文件这类大消息,去重按 msgID 走,资源本身靠 msgID 引用同一份,不会重复下载。
seen 也不能无限涨。at-least-once 的重推通常集中在短时间内(弱网抖动、刚上线那几秒),所以去重窗口一般只保留近期,按会话或按时间清理,或只记最近 N 条 seq 的位图。真要长期防重,最终还是落到 DB 唯一约束那层,内存窗口只是挡掉高频重推的提速手段。
顺带一提:去重管的是"同一条消息别显示两次",它不解决"已读状态多端同步",你在手机上读了,电脑上得显示已读,那是另一个靠 last_seq 和回执广播解决的问题,不在本文范围,但它是 IM 多端复杂度里另一块难啃的骨头。

五、三层怎么接力:一条时间线看全
一个场景把三层串起来:你发"晚上八点组局火锅",我的手机在充电、电脑锁屏。
弱网触发防线 1:地铁里第一条 ACK 丢了,客户端用同一 msgID 重发,服务端最终收到。离线触发防线 2:服务端落库暂存,两小时后我上线靠 last_seq 拉到。多端重推触发防线 3:电脑醒来,服务端又推一次,但 seen 已认出重复并丢弃。
任何一层单拿出来都有可感破绽:
- 缺防线 1,地铁里发出的消息可能永远卡在"已发送",对方收不到;
- 缺防线 2,我离线时消息直接丢,上线也救不回来;
- 缺防线 3,我醒来看到三条"晚上八点组局火锅",得自己判断哪条是真的。
三层各管一种失败模式:弱网、离线、多端。少一层,就露一个破绽;三层叠起来,用户只感知到一句:消息到了,且只有一条。

六、为什么别追 exactly-once
说到这,有人会问:那直接用 exactly-once(Kafka 事务、SQS FIFO)不就好了?
这是 IM 里最常见的误解,得说清楚。exactly-once 在消息中间件里指的是"单消费者对单分区的消息精确处理一次"(这是为便于对比的简化模型,真实是靠幂等+事务实现的 effectively-once),它解决的是"一个消费者别重复处理同一条"。但 IM 要的不是这个,是一条消息推给一个用户的 N 台设备,且设备可能离线。即使你用 Kafka 事务保证了"服务端处理一次",到了"推给手机、电脑、平板"这一步,仍然是 at-least-once:网络丢一个 ACK,某台设备就得多收一次,然后靠那台设备的去重兜住。
(上面这段是逻辑推演,不是我跑出来的实测。但推理链只有两步,前提明确:exactly-once 的语义边界是"单消费者单分区",而 IM 的失败模式是"多端 + 离线"。把前者套到后者上,语义根本没覆盖到真正的失败点。)
实践上 exactly-once 也贵。我的判断是:Kafka 事务、SQS FIFO 这类"精确一次"方案在吞吐和复杂度上的代价,远高于 at-least-once,因为"精确一次"要么靠事务把多步操作绑成原子(牺牲吞吐),要么靠去重表把重复挡在存储层(增加复杂度)。我的看法是:在 IM 这种本质就是 at-least-once 的场景里,花大代价追 exactly-once 是打错靶,把三层保障做对,比换语义实在得多。你纠结语义的名字,不如纠结 msgID 有没有贯穿三层、去重的维度有没有带上 device_id。
有人会说"Kafka 也能多个消费者啊"。但那是不同的消费者组各消费一遍,不是同一用户的多台设备;而且消费者组之间不解决"设备离线",离线那段还是得自己落库。把 MQ 的 exactly-once 搬进 IM,等于用一套不覆盖"多端 + 离线"的语义去堵 IM 真正的洞,堵不住的。
所以别再听见 at-least-once 就皱眉。在 IM 里,它不是 bug,是唯一正确的默认。真正的 bug 是把"可能收到多次"留给用户去忍;而这三道防线,让重复停在工程层:msgID 贯穿三层、去重带上 device_id,用户几乎察觉不到。

你们线上 IM 的去重维度是怎么设计的?按 (uid, device_id, msgID),还是只按 msgID?欢迎在评论区聊聊你踩过的坑。
附录:实验代码和原始数据
本文 3 组实验的代码和实测输出已开源:
GitHub:zhiyulab-evidence/im-reliable-delivery
sender-retry/:发送端带唯一 msgID 的超时补传(E1,对应第一道防线)server-store-forward/:服务端 store-and-forward 离线暂存(E2,对应第二道防线)receiver-dedup/:接收端按 (uid, device_id, msgID) 多端幂等去重(E3,对应第三道防线)
每个子目录都有独立 README,说明如何 go run 复现。二进制编译产物不入库,跑实验前自己 go build。
原文发布于 止语Lab