IMBoy 消息确认机制:消息真的送到了吗?
你发一条消息,屏幕上转个小圈,然后变成”已发送”。这背后,App 和服务器做了不少事,才能让你相对放心地相信:消息大概率到了。
但”大概率到”和”一定到”是两回事。今天聊聊 IMBoy 是怎么处理这件事的——而且说实话,它用的办法可能跟你想象的不一样。
先说结论:IMBoy 用的是”推 + 拉”两层
很多人以为,IM 保证消息不丢,靠的是”没收到就重发,重发到收到为止”。IMBoy 不是这么干的。
IMBoy 实际上有两层:
- 推送层(尽力而为):你发消息,服务器立刻通过长连接把消息”推”给对方。能推到就推到,推不到也不死磕。
- 同步层(保证不丢):对方每次打开 App 或联网,会主动问服务器”我落下了哪些消息?“,按顺序补齐。
打个比方:推送像快递员送货上门——人在就签收,不在就扑空;同步像你自己去快递柜取件——只要单号在,迟早能取到。
消息不丢的真正保证,是第二层(同步),不是第一层(推送)。 推送只是为了让消息”尽快”显示出来,提升体验。ACK(确认)机制,就用在推送这一层。
记住这个定位,下面就好理解了。
ACK 是什么:一张签收回执
ACK 就是”签收回执”。服务器推一条消息给客户端,客户端处理完了,回一个 ACK 告诉服务器:“这条我收到了。”
为什么要回这一下?因为长连接”连着”不等于”消息真的被 App 处理了”。中间隔着好几层:网络传输、App 接收、解码、显示到界面。任何一层卡住,连接看着是好的,消息其实没到用户眼前。ACK 就是让服务器确认”真的到了”,而不是靠猜。
IMBoy 里有两种 ACK,方向相反,别搞混:
- SERVER_ACK:你发出去的消息,服务器收到了吗?收到了,你那边的”发送中”小圈就能消失。
- CLIENT_ACK:服务器推给你的消息,你(客户端)收到了吗?收到了,服务器就不再重发这条。
本文讲的是 CLIENT_ACK 这一条——它管的是”推送层”的确认。
推送到底投递几次?说实话,比你想象的少
这是很多人(包括一些讲 IM 的文章)容易搞错的地方。你可能听说过”消息要重试好几次,比如 2 秒、5 秒、7 秒、11 秒,四次不行才放弃”。IMBoy 的单聊(C2C)不是这样的。
打开 IMBoy 的重试配置(elib_retry_config.erl),实际数字是:
| 消息类型 | 重试间隔 | 一共试几次 |
|---|---|---|
| 单聊 C2C | 立即、3 秒后 | 2 次 |
| 群聊 C2G | 立即 | 1 次 |
| 客户端请求 C2S | 立即、5s、7s、11s | 4 次 |
配置文件里写得很清楚:C2C 是”立即投递 + 3s 后重试一次,共 2 次尝试”;C2G 是”仅立即投递一次”。
为什么单聊只试 2 次、群聊只试 1 次,不像传闻里那样重试个四五次?
因为前面说过:推送本来就是”尽力而为”,不是用来保证消息不丢的。 真正保证不丢的是同步层。所以推送没必要死磕——推两次还没签收,大概率是对方网络不好或 App 不在前台,再推也是浪费流量和电量。这时候把”补送”的活儿交给同步层,等对方方便了自己来取,更省、更稳。
那些”四次重试 25 秒”的说法,是把另一种消息(C2S,客户端主动请求)的重试节奏,错套到单聊头上了。
收到 ACK 后,服务器做什么
客户端回一个 CLIENT_ACK,服务器要做三件事:取消还没触发的重发 → 标记这条已确认 → 清掉临时记录。
IMBoy 把单聊、群聊、系统消息、客户端请求这四种消息的确认,收到同一个入口处理(msg_ack_logic:client_ack),里面按消息类型分别走各自的清理逻辑。对外只暴露一个接口,调用方不用关心类型差异。
这里有个关键细节:处理 ACK 是幂等的。什么意思?假如服务器 3 秒到了重发了一次,客户端对原消息和重发的消息各回了一个 ACK,那第二个 ACK 进来时,对应的重发定时器早就取消没了。这时候服务器不会报错,而是静默忽略——“已经处理过了,再来一次也无所谓”。这种”重复也安全”的设计,是可靠投递的地基。
怎么防止”收到两条一样的消息”
重发会带来一个新麻烦:万一 ACK 走得慢,服务器已经重发了,结果原消息和重发的消息客户端都收到了,用户看到两条一样的——这比丢消息还糟。
IMBoy 的办法很简单:每次准备投递前,先查一下”这台设备是不是已经确认过了”。 确认过的,直接跳过,不再推。
无论是第一次立即投递,还是 3 秒后的重发,投递前都会查这个标记。这样即使定时器已经触发,已确认的设备也不会被重复打扰。
还有个细节:服务器给重发定时器设的”备忘录”保存时间,会比定时器本身多 5 秒。为什么?因为定时器到期、服务器重发之后,客户端的 ACK 可能还在路上。备忘录多留 5 秒,就给这个往返留了余量,免得 ACK 回来时找不到对应的记录。这种”给异步操作留 buffer”的细节,是线上跑过才有的经验。
一个用户多台设备,怎么算”收到”
现在一个人往往手机、电脑、平板同时登录同一个账号。同一条消息要推给这个账号下所有在线设备。
这里的关键是:每台设备的确认是独立的。 手机签收了,不代表电脑也签收了。所以 IMBoy 在标记”已确认”时,用的是”用户 + 设备 + 消息”三元组,而不是只看用户。手机回了 ACK,只标记手机这台收到了;电脑那条还是待确认,得等电脑自己回。
这也是为什么客户端回 ACK 时必须带上自己的设备 ID——不是多余的字段,是多端各自闭环的依据。
真正的兜底:推送没送到,靠同步补
回到开头那个问题:如果推送两次(C2C)都没签收,消息就这么”丢”了吗?
没有。 因为还有同步层兜底。
每条消息入库时都有一个顺序编号(IMBoy 里叫 conv_seq)。客户端会记住自己”已经拿到了第几号”,每次和服务器通信时,用它去问:“我到 N 号了,N 号后面还有哪些?“服务器就把漏掉的消息按顺序补发。
只要这条消息进了数据库(它在推送之前就已经入库了),客户端总能在某次同步时取到——哪怕当时的推送因为网络原因全失败了。
这就是为什么推送可以”尽力而为”而不慌:推送负责”快”,同步负责”全”。 推送没及时送到,体验上慢一点;但同步保证它最终不会丢。两层配合,才是 IMBoy 消息可靠性的全貌。
配套:让通道本身别先掉线
ACK 能回得来,前提是连接还活着。IMBoy 还有几条配套机制托底:
- Token 快过期时先提醒刷新,不直接掐线:给客户端 8 秒窗口无感刷新,避免一过期就掉线打断收消息。
- 双层心跳保活:一层管网络层的连接,一层管应用层”对方 App 还在响应吗”,超时就判定连接已死,把没确认的消息交给同步层。
- 断线后指数退避重连:第 1 次立刻重连,之后 1 秒、2 秒、5 秒……逐步拉长,避免所有用户同时重连把服务器挤爆。重连成功第一件事就是同步拉取漏掉的消息。
结语
很多人讲 IM 可靠性,喜欢渲染”四次重试、必达闭环”那一套。但 IMBoy 的实际做法更朴素:承认推送不是万能的,把它定位成”尽力而为的加速”,把”不丢消息”的责任交给顺序同步。 ACK 机制服务的是推送这一层——确认签收、防止重复、提供投递指标——它让推送尽量靠谱,但不替同步层扛”必达”的责任。
这样的分层,单看每一层都不复杂,但搭在一起既快又稳。比起一个臃肿的”重试到死”队列,它更省电、更省流量,也更能扛住弱网。
通讯的可靠性,不是某一个”确认”动作撑起来的,而是推送、确认、同步、保活这几条线各司其职、互相兜底的结果。
欢迎关注 IMBoy 开源项目 https://gitee.com/imboy-pub