资讯动态

IM离线消息可靠补发机制:消息状态机、ACK确认与幂等去重实践

发布时间:2026/9/20 10:48:08 来源:尧图企业网站定制
1. 离线消息补发的真正痛点从“发送成功”到“接收方真正收到”之间隔着一整套系统一年多前我在负责一个企业即时通讯客户端的消息链路改造。当时线上最让我头疼的一类问题是消息明明显示发送成功接收方那边却死活看不到。尤其是一些长期离线的用户重新登录之后要么消息丢了几条要么重复收到大量旧消息聊天记录乱成一团。一开始我以为是推送通道不稳定后来一步步排查才发现真正的难点不在“推送”本身而在离线消息的“补发”机制上。企业即时通讯和普通聊天软件最大的区别是不能接受“尽力而为”的投递模式。普通App里一条消息发丢了用户顶多抱怨一句但在企业场景里一条离线消息可能是一份审批通知、一个排班变更、一条紧急事件的处理指令。发丢了就是工作事故。所以在设计目标上我们不只要求消息“发出去了”还要求能拿到“接收方已确认送达”的证据。离线消息、可靠补发、送达确认这几件事从此成了整个系统最核心的建设目标。为了说清楚这套机制我需要把问题拆开来看当发送方和接收方不在同一条网络链路上时系统如何保证在不丢、不重、不乱序的前提下把消息准确送到接收方手中这里涉及消息状态管理、离线存储结构、确认与重试策略、幂等去重、顺序控制等多个环节。这篇文章会把我们当时的方案、踩过的坑、调参的过程完整复盘一遍。如果你正在做IM系统、消息推送平台或者是任何涉及离线数据同步场景的后端开发应该能从里面找到不少可以直接抄作业的东西。2. 消息生命周期的重新定义没有状态机就没有“可确认送达”2.1 旧方案的警告只有“发送状态”的系统在离线场景下必然丢消息我们最早的实现非常直观发送方客户端通过WebSocket直接把消息发给服务端服务端再转发给接收方。如果接收方不在线服务端就把消息写进一张“离线消息表”等对方上线时把整张表捞出来推送。这个方案看起来没有任何问题但实际跑起来之后漏洞百出。最典型的一个场景是接收方客户端上线服务端把离线消息推送过去客户端在写入本地数据库的过程中突然遇到系统休眠或者网络闪断写入失败。消息其实已经在服务端标记为“已推送成功”而客户端本地其实并没真正落库。这条消息就这样神不知鬼不觉地丢了。更麻烦的是另一个场景客户端写库成功但反馈给服务端的确认包在网络中丢失了。服务端以为推送失败于是重新推送一遍客户端就会把同一消息写入两次。用户看到的就是一条消息被重复接收。这种“丢”和“重”同时存在的问题根源在于我们的消息只有一个“发送成功”的状态。它无法区分“服务端已接收”“接收端已投递”“接收端已读”这些完全不同的阶段。没有状态区分网络抖动一次消息就不可恢复。2.2 状态机的四个阶段CREATED、STORED、DELIVERED、READ重新设计时我们把消息生命周期定义成了下面四个状态每一次状态切换都必须落库而不是只更新内存里的字段CREATED已创建发送方客户端点击发送消息落本地库并上传服务端。此时消息状态为CREATED。STORED已存储服务端把消息写入消息主表和接收方的离线队列确认落库后向发送方返回ACK。发送方收到ACK后把UI状态从“发送中”改为“已发送”。这个阶段对应“消息发出去了”。DELIVERED已投递接收方客户端成功把消息写入本地库后向服务端返回DELIVERED ACK服务端更新消息状态。这个阶段才是“可确认送达”。READ已读接收方打开会话客户端把已读游标回传服务端更新会话的已读记录。这个状态机看起来简单但它是整个可靠补发机制的地基。没有这个基础后面所有关于重试、补发、去重的方案都无从谈起。状态机的价值在于消息每一步走到哪里都是可查询、可追踪的。排查线上问题时直接根据状态字段就能定位消息到底是卡在服务端没存上、接收端没收到还是收到了没确认。2.3 状态存储的选型与一个关键权衡状态存储最开始放在MySQL里每条消息一行状态字段用int。等消息量级上来之后发现两个问题一是状态更新频繁MySQL的随机写压力很大高峰期经常出现慢查询二是消息要按会话、按时间分页查询索引一不小心就设计错。后来我们把“消息内容存储”和“状态索引”拆开了消息主表继续留在MySQL负责持久化和内容查询状态索引移到了Redis里用msg_status:{msg_id}作为keyvalue用hash存状态值、更新时间、重试次数。这样状态更新的路径变短了单机Redis能抗住的更新TPS比同规格MySQL高一个量级。查询消息详情时先查状态再根据状态决定是否回主表捞内容。用这套拆法我们的状态查询性能提升了近10倍。这里有一个点想提醒大家不要试图把“最新状态”和“完整历史”存在同一个存储里。状态是高频读写的热数据历史是不可变归档的冷数据两者混在一起性能和存储都会两头不讨好。3. 离线消息的存储与拉取链路不丢、不重、不漏的三重保障3.1 离线队列的存储方案按接收方维度建列表离线消息在服务端的存储方案业界其实没有太多悬念为每个接收方维护一个“待发送消息列表”这个列表本质上就是离线队列。我们用Redis的List结构存储key为offline_queue:{user_id}value是序列化后的消息JSON。有两个设计细节值得单独拿出来讲第一写入离线队列和写入消息主表必须在同一个事务里完成。我们当时用Lua脚本把两个Redis写操作和一个MySQL插入操作包在一起执行保证不会出现“队列里有消息但主表没记录”或反过来“主表有记录但队列里丢了”的情况。这一步是“不丢”的底线。第二队列里存的是消息ID而不是完整消息内容。客户端拿到消息ID后如果需要内容再通过详情接口按ID拉取。这样做的原因是队列本身变得非常轻量而且为后续的撤回、编辑功能预留了空间——消息内容变化时不需要动队列只需要动主表。3.2 上线拉取分页批次拉取与游标确认机制客户端上线时要拉取离线消息。这里我们做了一个关键取舍不是服务端一次性把队列里所有消息全部推过来而是让客户端分批次主动拉取且每拉完一批确认一次。这个决定源于一次线上故障的教训。早期版本用的是“一把梭”式推送服务端把整个离线队列全部序列化后一次性推给客户端。结果遇到一个离线了一个月的用户队列里积压了八千多条消息推送包20多MB客户端直接内存溢出崩溃。之后我们就明白了离线补发必须考虑单批数据量上限不能用推的方式得改成拉的方式。最终的上线拉取流程是这样设计的客户端上线后向服务端发送PullOffline(offset, batch_size)请求。服务端从offline_queue:{user_id}列表中按偏移量取出batch_size条消息ID并携带这批数据在队列中的结束位置。客户端逐条把消息写入本地库写入完成后发送ConfirmOffline(batch_end_offset)。服务端收到确认后更新该用户的游标字段offline_cursor:{user_id}表示这个位置之前的所有离线消息都已投递成功。客户端继续发起下一轮拉取直到游标追上队列末尾。这套流程的稳健性在于即使客户端在拉取过程中崩溃下次上线时也会从游标位置继续拉取已经确认过的消息不会再重复拉没有确认的也不会漏掉。3.3 游标与队列的清理策略淘汰补发机会但绝不能淘汰消息本身离线队列不能无限膨胀。我们设置了一个阈值单用户离线队列最多保留最近一万条消息ID。超过这个量把最老的消息ID从队列中删除。但是这里必须强调一个原则队列里删掉的只是“待补发的资格”消息主表里的原始记录依然永久保留。用户在聊天记录页面仍然能翻到所有历史消息只是超过一万条之后更早的消息不再自动补发而是需要用户手动下拉加载历史。在企业IM场景里这个策略涉及合规风险。因为有些行业对消息留存有明确的审计要求。我们的做法是主表数据保留至少一年队列清理只影响“自动补发”这个环节不影响数据的可追溯性。4. ACK确认、超时重试与幂等去重可靠补发的三大核心机制4.1 分层的ACK确认存储ACK与投递ACK各司其职“可确认送达”最终要落地为一条确凿的ACK链路。我们把ACK拆成了两层各自承担不同的职责存储ACK服务端把消息写入主表和离线队列后向发送方返回MsgStoredAck。发送方只有在收到这个ACK后才把UI改到“已发送”。这一步确认的是“消息到了服务端不会丢了”。投递ACK接收方客户端成功把消息写入本地库后向服务端返回MsgDeliveredAck。服务端收到后更新消息状态为DELIVERED并通过状态变更通知把“对方已收到”的结果推送给发送方。这一步确认的是“消息到了接收方手里”。为什么非要拆两层ACK因为发送方到服务端、服务端到接收方是两段相互独立的网络路径。任何一段发生故障如果不分段确认你就无法判断消息到底丢在哪一段。分段确认之后问题可以在第一段彻底解决不需要在第二段做补偿。4.2 超时重试的参数选型不拍脑袋用真实时延数据说话超时重试涉及两个重要参数超时阈值和重试次数。我们的参数不是拍脑袋定的是从一组实测数据里推出来的。在办公网络环境下客户端到服务端的P99网络往返时延是80ms左右服务端处理一条消息大约需要20ms所以一个ACK回包通常在100ms内能回来。我们设置了1秒的超时时间相当于留了10倍余量。超过1秒没收到ACK就触发重传连续重传5次仍失败把消息状态置为FAILED并走告警流程。企业IM场景下很多消息是群发的。前端上一条消息可能发给几十甚至几百个接收方。每条消息要在每一条独立的接收方链路上分别判断ACK和超时。只要有一个接收方没有确认这条消息的“送达率”就不算100%。因此我们的重试任务是按“消息ID接收方ID”这个组合维度来管理的不能只按消息ID来管。4.3 幂等去重重试机制是药同时也是重复消息的源头重试是可靠性的核心但重试不做去重就会制造大量重复消息。我们服务端和客户端各做了一层去重双保险。服务端的消息主表以msg_id为主键天然防止同一条消息被重复插入。客户端的处理逻辑则更复杂一些每收到一条推送消息先查一个已处理消息ID集合实际用的是本地布隆过滤器如果ID存在就丢弃不存在才写库写库成功之后把ID加进集合再回ACK。这里有一个后来踩过的大坑客户端虽然会丢弃重复消息但第一次处理时如果ACK回执在网络中丢了服务端会重推同一条消息。客户端命中去重集合会直接丢弃但如果不回ACK服务端就会一直重试下去。那次线上事故里一条消息被服务端重试了几十次客户端也丢弃了几十次直到我们把规则改成“凡是命中去重集合的消息一律回复ACK而不是静默丢弃”问题才彻底解决。5. 消息顺序与多端同步补发不能打乱会话的时间线5.1 会话内顺序控制单调递增序号 本地排序离线补发最容易让人忽视的问题就是消息顺序。一个会话里用户上午10点发了消息A10点05分发了消息B。如果A因为网络问题延迟到10点10分才被接收方拉到而B在10点05分已经正常到达并写库那本地库里的顺序就变成B在前、A在后整个聊天时间线乱了。我们的解决方案是“会话级单调递增序号”机制。服务端为每个会话维护一个seq字段每写入一条消息就递增一次。客户端本地库里的消息统一按seq排序展示而不是按服务器时间或到达时间排序。这样即使离线消息到达的顺序和发送顺序不一致写库后读取时还是会按seq恢复正确的顺序。这个方案的关键在于服务端写入消息时必须保证同一个会话内的seq递增是串行化的。我们使用了Redis的INCR加会话锁来保证并发场景下不会出现相同或倒序的序号。5.2 多端设备的独立游标PC已经读完手机还要再拉企业IM用户在PC端和移动端同时登录是很常见的使用习惯。如果只维护一个全量消费游标会出问题PC端已经消费完游标之前的消息但手机端是刚上线的如果不重新拉一遍手机端的聊天记录就会缺一截。我们的方案是把离线消费游标拆成按设备维度管理每个设备保存自己的device_cursor:{user_id}:{device_id}。服务端推送时并不删除离线队列中的消息每个设备各自记录消费位置。只有当某个用户的所有在线设备都追平到队列末尾时才允许清理已消费的消息。这个过程我们称为“全端确认”也是“可确认送达”在多设备场景下的完整版本。5.3 离线补发与实时转发的通道关系补充一个容易被误解的点离线补发不是一套独立于实时转发的系统两者是同一个分发通道的两条路径。接收方在线时消息通过长连接直接下发同时异步写入离线队列接收方不在线时消息只写入离线队列等对方上线再补推。这个设计的好处是即使实时通道出现故障补发通道仍然能把消息送出去相当于一个天然的降级冗余。我们内部把它叫作“实时通道负责体验离线通道负责兜底”。6. 压测与线上踩坑真实环境里补发链路暴露出的问题6.1 两千人同时重连的“重试风暴”上线新方案后我们做了一次全量压测模拟2000个客户端同时离线再同时上线的极端场景。第一次压测结果很惨离线消息接口超时率达到62%Redis CPU被打满。排查发现问题出在拉取流程的确认阶段。2000个客户端同时上线每个客户端都会先拉一批消息随后发送确认请求确认操作对应的Redis写请求量瞬间把单节点性能压垮了。我们的优化方案是给确认操作做并发聚合把相同用户相邻的批量确认请求合并成一个Pipeline请求再利用Redis集群分片把写压力分散到不同分片节点。优化后的压测超时率降到了1.8%。6.2 消息ID的全局唯一问题另一个坑是消息ID的生成。最初用的是数据库自增ID拆库之后自增ID在多个分片里会重复直接导致去重机制误判——两台不同服务器上生成的两条不同消息却拥有同一个ID客户端把其中一条当成重复消息丢弃造成实际丢消息。这个坑花了不少时间才定位到。当时现象是系统偶尔丢消息丢得毫无规律。查了很久最后发现是ID冲突导致客户端去重集合误判。解决方案是换成全局唯一的雪花ID根据时间戳、机器ID、进程内自增序号生成64位唯一ID。换完之后去重逻辑彻底正确了。这里想提醒所有做分布式消息系统的朋友全局消息ID的唯一性必须在第一天就解决不然后面数据量越大越难收拾。6.3 长时间离线用户的体验保护还有一个容易被忽略的场景用户出差一个月期间收到了几千条群消息和离线消息。上线时一次性推送这么多消息手机本地数据库瞬间膨胀出现IO阻塞和App卡死。我们加了保护策略拉取接口的batch_size限制为200条每拉取一批必须确认后才能拉下一批。客户端写库时每写500条主动flush一次防止单次事务锁库时间过长。这样即使离线积累了数千条消息也在几十秒内分几十批静默补齐。产品侧同步提示用户“正在同步历史消息”避免用户误以为新消息一直没到。7. 一些实操经验这些细节文档里通常不会写最后分享几个开发过程中沉淀下来的实操经验希望对遇到类似问题的朋友有帮助业务概念上要严格区分“已送达”和“已读”。对用户来说这两个词差别不大但技术实现上是完全不同的两套机制已送达是单条消息的投递状态已读是会话级的时间点游标。混在一起后面的统计报表、消息撤回、未读角标都会非常混乱。离线补发的日志必须完整。每一批拉取的起始偏移、结束偏移、耗时、设备信息都要记录。出问题时这些日志是判断“服务端没推、推了客户端没收到、还是收到了没确认”的唯一依据。没有日志排查这类问题基本靠猜。ACK重试必须设上限。无限重试在轻微抖动时看似稳妥但一旦遇到大规模网络故障所有消息同时触发重试会形成“重试风暴”反过来加重网络拥塞。我们设置了重试上限为5次超过直接告警并进入人工补偿流程。灰度发布时要先升级接收端再升级发送端。这能避免新协议的确认包被旧客户端错误解析。这套方案上线运行半年离线消息的丢消息率从改造前的万分之三左右降到了十万分之一以下。在我看来这个收敛的本质是把消息从“发出去”这个单薄的动作拆成了一个有状态、可确认、可重试的完整链路。如果你也在做IM或类似的离线数据同步系统希望这篇文章能帮你在方案设计上避开一些我们走过的弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价