at-least-once 不是 bug:IM 消息可靠投递的三道防线

at-least-once 在消息队列里常被当 bug,到了 IM 却是唯一靠谱的默认。靠发送端 msgID 补传、服务端 store-and-forward、接收端多端去重三道防线,把'可能收到多次'变成用户无感。

封面

消息队列里 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 意味着可能收到多次,用户看到重复。再加接收端去重,才把"多端 + 离线 + 弱网"三种失败模式全盖住。任何一层都拦不住所有情况,但三层叠起来,“消息最终到达、且用户只看到一条"就成立了。

exactly-once 与 at-least-once 语义对比:单消费者单分区 vs 多端+离线

下面每道防线和一段可运行的 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 就能认出"这是刚才那封",而不是当成两条。

第一道防线:发送端超时重发时序(弱网丢 ACK → 同 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: [晚上八点组局火锅]

第二道防线:服务端 store-and-forward(落库→推在线→离线暂存→上线拉取)

phone 当场收到,pc 和 tablet 上线后从库里拿到同一条。消息没因为设备离线而丢。

在线设备为什么能"当场收到"?因为它走的是长连接推送通道(WebSocket 或自研长连),服务端主动推。但长连也会断,地铁进隧道,连接掉了,这段时间来的消息服务端不知道你在不在线,只能先落库。所以"在线推送"和"离线暂存"不是二选一,是一条消息先落库、能推就推、推不了就等上线拉,两条路都从落库这一步分出去。落库是这条防线的地基,没有它,长连一断消息就没了着落。

但这里有个简化得说清楚:demo 让离线设备上线时拉了全量历史,真实系统不会这么干。真实做法是每台设备记一个"已读游标" last_seq,上线只拉 seq > last_seq 的消息,已经看过的不再推。游标是 IM 多端同步的核心:你在手机上看到了 seq=100,电脑上线时从 101 开始拉,不会把 100 再推一遍。这和防线 3 的去重是两套机制,一个管"拉哪些",一个管"收到重复的怎么处理",互补但不替代。

seq / last_seq 增量游标:已读的不再推

还有个 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,无重复)

第三道防线:接收端多端幂等去重(每设备独立 seen,重推丢弃)

三台设备各出现一次,消息要上我所有设备;重推的旧消息被各自丢弃,不能在我某台设备上冒出第二条。这就是 IM 幂等去重和 MQ 单消费者去重不一样的地方:它防的不是"一个消费者重复处理",而是"一个用户的多端重复看到"。

device_id 这层展开看会更清楚:手机上的 seenm-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 多端复杂度里另一块难啃的骨头。

exactly-once 误区:把 MQ 的"单消费者单分区"语义套进 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,用户几乎察觉不到。

金句:at-least-once 不是 bug,是唯一正确的默认

你们线上 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


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →