1. 先搞清楚:离线消息补发到底要解决什么问题
做企业即时通讯的同学应该都有过类似的经历:线上有人反馈“我给同事发了条消息,对方明明在线,却一直没收到”,后台一查,消息记录里这条消息的状态是“已发送”,发送端的聊天窗口也明明白白显示着“已发出”,但接收端就是没有。这种问题排查起来极其痛苦,因为它不像崩溃、报错那样有明确的异常信息,而是系统在“看似正常”的状态下悄悄丢了一条消息。
为什么会出现这种情况?核心在于很多即时通讯系统在设计的时候,把“发送成功”定义成了“服务端收到消息”,甚至直接定义成“客户端把消息发出去了”。这个定义在纯在线会话里勉强能用,一旦牵扯到网络抖动、客户端被杀、设备切换、离线再上线,这套逻辑就彻底撑不住了。尤其是企业IM里面,用户动不动就锁屏、切后台、断网重连,消息要是没个可靠的补发机制,用户的信任感很快就没了。
这篇文章我想重点梳理一下离线消息从“发出去”到“可确认送达”这个完整链路里,有哪些绕不开的设计决策和实现细节。我会结合企业即时通讯的真实业务场景,把消息状态机、ACK机制、离线存储、补发时机这些关键点逐个拆开讲,最后给出一套可以直接参考落地的实现方案,并整理一些我在实际开发中踩过的坑。
2. 可靠补发的三大核心机制
2.1 消息状态机和“可确认送达”的语义
要谈可靠补发,第一步是重新定义消息的状态。不要再用“已发送”“未发送”这种非黑即白的二元状态,至少要拆成以下四种:
- 消息已接收:服务端收到了发送方提交的消息,完成持久化
- 消息已投递:服务端成功把消息下发给了接收端,接收端返回了确认
- 消息已读:接收端不仅收到了,而且用户真的打开会话看到了
- 消息已确认送达:服务端明确知道接收端拿到了这条消息,且消息ID对得上
“已确认送达”跟“消息已投递”是两回事。投递是服务端到接收端的一次网络传输完成,确认送达是整个链路都走通了,接收端明确知道自己收到了哪条消息,并且后续可以按这个消息ID做去重和排序。我见过不少系统把这两个混在一起,结果就是服务端觉得自己发出去了,接收端因为网络问题丢包了,两边各说各话。
这里有个很实用的做法,就是给每条消息设计一个完整的生命周期字段,用一个状态机来驱动。比如消息从客户端发起的时候是PENDING,服务端落库后变STORED,推送给接收端后变成SENT,收到接收端ACK后变成DELIVERED,接收端上报已读后变成READ。后续不管哪一环断了,都可以根据当前状态决定要不要补发、补发到哪一步。
2.2 消息持久化与离线存储的设计思路
离线消息补发的前提是消息不丢,而消息不丢的前提是持久化做得足够稳。企业IM里,消息一旦发出去,不管接收方在不在线,这条消息都必须有一个可靠的落地位置。常见做法是双写:一份写消息内容表,一份写会话维度的时间线索引,接收方离线的时候,消息进入这个会话的待投递队列。
存储层选型方面,我见过三种主流方案。第一种是用关系型数据库直接存,适合消息量不大的企业内部系统,胜在好维护、能精确查询;第二种是用Redis的Stream或者List做待投递队列,配合定期刷盘到MySQL或者对象存储,兼顾了性能和可靠性;第三种是直接上专业的消息队列中间件,比如RocketMQ或者Kafka,把每条消息当作一个业务事件,接收端上线后按需消费。
三种方案没有绝对的好坏,取决于企业IM的规模和团队运维能力。如果是几百人用的内部工具,MySQL一张表就够了;如果是几万人在线的企业级产品,建议至少用Redis做待投递缓存,再配合异步落库。有一个细节需要注意:离线消息的存储一定要按接收方ID分片,千万不能把所有离线消息塞进同一个大队列,否则某一个大用户的离线消息会拖垮整个集群的性能。
2.3 补发时机和触发策略:拉取还是推送
离线消息的补发时机,本质上是在回答一个问题:接收方什么时候算“可以收了”?目前业界主流有两种思路。
第一种是纯拉取模式,接收端上线后主动向服务端拉取离线消息,服务端返回一页消息列表。这种模式实现简单,控制逻辑都在客户端,服务端不需要维护复杂的在线状态,适合做移动端和企业内部工具。缺点是实时性差,接收方不知道什么时候该拉,只能靠重连、进入会话、定时轮询这些时机来触发。
第二种是推送加拉取结合模式,服务端维护一份在线状态表,接收方在线的时候直接通过长连接推送,接收方不在线或者推送失败的时候,消息进入离线队列,等接收方重新建立连接后,服务端第一时间把离线队列里的消息补推过去。这种模式实时性好,也是目前主流企业IM采用的方式。
我在实际项目中更推荐第二种,但要注意一个细节:服务端判断“在线”不能只看连接有没有建立,还得看连接是否处于可用状态。因为移动端经常出现Wi-Fi切流量、锁屏休眠导致长连接假死的情况,服务端以为连着,其实客户端已经收不到消息了。所以在线状态的心跳间隔要设置得比NAT超时时间短,而且要在消息推送后等待接收端的ACK,而不是推送完就当作成功。
3. 从“发出去”到“可确认送达”:一个完整方案的设计与实操
3.1 整体架构与消息格式设计
我拿一个实际的内部项目举例,这个项目是给一家中型企业做的内部即时通讯工具,技术栈是Go后端加Redis加MySQL,客户端有Web端和移动端。这套架构不复杂,但能把离线消息补发这件事说清楚。
先定义消息结构。消息ID必须全局唯一,不能依赖数据库自增主键,因为自增ID在分库分表之后会产生重复,而且不好做客户端去重。我最终用了雪花算法生成64位的消息ID,高32位是时间戳,中间是机器编号,低位是自增序列,保证高并发下全局唯一且趋势递增。消息体里除了ID,还包含会话ID、发送方ID、接收方ID、消息类型、消息内容、发送时间、消息状态这几个核心字段。
这里分享一个经验:客户端本地生成一个客户端消息ID,服务端收到后再分配一个服务端消息ID,两者建立映射关系。这样做的好处是,客户端在弱网环境下重试发送时,可以带上客户端消息ID,服务端据此判断是不是同一条消息,避免重复落库。服务端消息ID则用于投递链路中的唯一标识和去重。
3.2 消息发送与在线投递的完整流程
一条消息从发送方到接收方的完整链路,大致分为以下六步:
- 发送方客户端将消息封装好,带上客户端消息ID,通过长连接POST到服务端的消息发送接口
- 服务端校验发送方权限和会话合法性,生成服务端消息ID,把消息写入MySQL消息表,状态置为STORED
- 服务端将消息推送到接收方所在的长连接通道,如果接收方在线且通道健康,等待接收方ACK
- 接收方收到消息后,校验消息ID是否已处理过,确认是首次收到则写入本地消息库,然后返回ACK
- 服务端收到ACK后,将消息状态更新为DELIVERED,发送方客户端更新消息状态为“已送达”
- 如果服务端在一定时间内没有收到ACK,触发重推机制,重推次数超过阈值后转入离线消息队列,等待接收方上线后补发
这段流程看着简单,实际坑很多。第4步的本地去重就很关键,因为第6步的重推有可能导致同一条消息被接收端收到两次,如果接收端不做去重,用户就会看到一模一样的消息出现在聊天窗口里。所以接收方一定要以服务端消息ID为key,维护一张最近消息的去重表,处理完的消息把ID记录下来,重复消息直接丢弃或返回已处理ACK。
还有一个容易忽略的点:接收方返回的ACK要带上消息ID和接收方自己生成的一个确认序号,服务端通过这个确认序号可以判断ACK是不是重复的。网络重试会带来重复ACK,这在TCP层很常见,到了业务层必须自己做幂等。
3.3 离线消息存储与补发的关键实现
接收方离线的情况下,消息不能被丢下不管。在第6步里,当推送失败或者重推超时,服务端会把消息写入Redis的离线消息队列。设计上我用了一个按照用户维度拆分的ZSet,key是offline:{userId},score是服务端消息ID,value是序列化后的消息内容。
为什么用ZSet不用List?因为ZSet天然支持按照消息ID排序,接收方上线补发的时候,可以按照消息ID从小到大逐条补发,保证消息顺序和发送顺序一致。List只能按插入顺序,一旦出现并发写入多条离线消息,顺序可能和消息ID的先后对不上。
接收方上线以后,流程是这样的:
- 客户端建立长连接,服务端注册在线状态
- 服务端检测到该用户有离线消息,触发补发
- 补发时每次取一批,比如每批50条,推送给客户端
- 客户端收到后逐条确认,服务端收到ACK后从ZSet里删除对应的消息ID
- 如果客户端在处理过程中异常断开,未确认的部分在下次上线时继续补发
这里要特意强调一点:补发必须是批次确认,不能等全部发完再确认。否则一个用户的离线消息有几万条,一次全量推送会把连接打爆,中途断掉还得全部重来,效率极低。我一开始的版本就是全量确认,结果测试环境里有人一个月没登录,累计了两万多条离线消息,一上线直接把网关进程搞到OOM,后来改成批次确认才把这个坑填上。
3.4 关键代码实现参考
下面给一份简化版的Go代码,展示消息状态流转和离线补发的基本逻辑,帮助你快速理解整个闭环。
// 消息发送接口 func SendMessage(ctx context.Context, msg *Message) error { msg.ServerMsgId = generateSnowflakeId() msg.Status = MessageStatusStored // 1. 消息落库 if err := mysql.SaveMessage(ctx, msg); err != nil { return err } // 2. 尝试在线推送 delivered, err := pushToReceiver(ctx, msg) if err != nil { // 推送异常,进入离线队列 return saveToOfflineQueue(ctx, msg) } if !delivered { // 推送到接收方,但ACK超时,进入离线队列 return saveToOfflineQueue(ctx, msg) } return nil } // 补发离线消息 func ResendOfflineMessages(ctx context.Context, userId string) error { offlineList, err := getOfflineMessages(ctx, userId, 50) if err != nil { return err } for _, msg := range offlineList { delivered := false for retry := 0; retry < 3; retry++ { err := pushToReceiver(ctx, msg) if err == nil && waitForAck(ctx, msg.ServerMsgId, 3*time.Second) { delivered = true break } } if delivered { removeFromOfflineQueue(ctx, userId, msg.ServerMsgId) } } return nil }代码逻辑不复杂,核心在于“先落库,再投递,投不了进离线队列,上线后按序补发”这个闭环。实际生产环境要加的东西还有很多,比如并发补发时要控制连接带宽、防止离线补发压垮在线消息的延迟,这些需要在实际调优中逐步打磨。
4. 踩坑总结:常见问题与可靠补发的排查经验
4.1 消息重复了,怎么办
消息重复是离线补发里最容易被用户感知到的问题。用户明明只发了一条消息,接收方却看到两条,这在企业IM里是绝对不可接受的体验。
重复的来源一般有两个。一个是发送方在弱网情况下重试发送,导致服务端收到两条一样的消息。解决办法就是前面提到的客户端消息ID,服务端在落库前先查一下这个ID是否已经存在,存在则直接返回原消息的服务端消息ID,不重复存储。另一个是补发流程里接收方先收到了在线推送的消息,又收到了离线补发的消息,两边撞在一起。解决办法是接收方以服务端消息ID维度的去重表,处理完消息就把ID记下来,重复的不管是从哪个通道来的,直接丢弃并返回ACK。
4.2 消息乱序了,如何规避
消息乱序大多出现在离线补发和在线推送并存的时候。比如接收方本来在线,消息A推送过去了,但客户端在本地处理比较慢,这时候接收方切了下网络,离线队列里的消息B补发过来了,结果B先处理完入库,A后处理完,界面就出现了B在前A在后的错乱。
规避办法是在客户端本地维护一个消息顺序缓冲区。接收到消息后先不直接上屏,而是按消息ID进行排序,只有确认某条消息的前序消息都到了,才把这条消息上屏。如果发现中间有缺失,就先等一等或触发一次定向补拉。服务端的离线消息用ZSet按消息ID排序发下来,能够很大程度上降低乱序概率,但客户端侧的兜底排序依然不能少。
4.3 消息丢了,排查路径是什么
消息丢失是最棘手的问题,因为它的表现往往是“没有异常”。我遇到过一种经典场景:发送方客户端显示消息发送成功,接收方客户端显示一切正常,但消息就是不出现。最后排查下来,问题出在服务端的ACK超时判断上。
当时服务端对每条推送消息设了5秒的ACK超时,超过5秒没收到ACK就认为投递失败,消息转入离线队列。但有些低端安卓手机上,客户端收到消息后要经过主线程序列化、数据库写入、UI刷新等多个环节,5秒根本不够。客户端还没处理完,服务端已经判定超时并把消息塞进了离线队列。而客户端处理完后又上报了一个ACK,服务端误以为这条消息已经处理完毕,没有继续补发。两边都以为对方没问题,消息就卡在了一个中间状态,既不在会话里,也不在离线队列里。
这个问题的排查思路是给消息状态流转加上详细的可观测日志,尤其要注意消息在“已投递”和“已确认送达”之间有没有中间状态被漏掉。后来我们把ACK超时时间改成了动态的,先按5秒超时,超时后不立即判定失败,而是再等客户端的心跳上报,等服务端确认这个连接确实不可用,才转入离线队列,消息丢失率立刻降了下去。
4.4 多设备场景下的补发细节
企业IM几乎都有多端登录的需求,手机、电脑、网页同时在线。这时离线消息的补发就不能简单按用户维度处理,得按会话维度加设备维度去考虑。
我的做法是维护一张设备在线表,记录用户在每个设备上的在线状态。消息推送时,只要用户有一台设备在线,就把消息实时推送给这台在线设备,同时其他离线设备记录未读状态。用户在某台设备上线后,先拉取这台设备的离线消息,同时把其他设备已读的消息同步为已读状态。这里要注意,离线消息的补发不能把所有设备都发一遍,否则每个设备的已读状态会互相冲突,后端得维护一个以用户为维度的消息状态中心,各设备只保留自己的投递游标。
还有一个容易被忽略的细节:电脑端和手机端的消息处理能力不一样,电脑端网速快、内存大,可以一次补发几百条;手机端在弱网环境下一次最多补发十几条,要按设备类型差异化设置批次大小。
5. 离线补发的性能优化与容量规划
离线补发不是简单的功能逻辑,它会在瞬时给系统带来很大的压力。尤其是早上上班时段,几百个用户同时登录,每个人触发一次离线补发,服务端的压力会瞬间飙升。
我遇到过最夸张的一次,某天早上9点整,同时有300多个用户登录,每个人的离线消息平均有200条,服务端一瞬间被打出几万个推送请求。当时的系统没做任何限制,直接导致推送网关CPU跑满,正常在线的用户发消息也开始延迟。后来我们做了三件事才把这个问题解决。
第一件是补发限流。每个用户的离线补发用令牌桶控制速率,比如每秒最多补发100条,避免单个用户的消息补发占用过多带宽。第二件是错峰。客户端登录后不立即请求全量补发,而是先拉一个未读数,然后从最早的未读消息开始按页拉取,加载速度和用户体验都能接受。第三件是推送通道复用。补发消息和在线实时消息共用同一条连接,但在服务端按消息类型打不同的优先级标签,优先确保实时消息先发,离线补发排在后面慢慢发。
容量规划上,建议按峰值并发在线的10%来估算同时触发补发的用户数,每个用户的消息量按平均100条保守估算,再乘以每条消息的平均大小,这个数值就是推送网关在高峰期需要处理的吞吐量。以此为依据来配置线程池大小、Redis连接数和消息队列的消费者并发数。
6. 测试离线消息补发要靠什么手段
离线消息补发这个功能,写起来不难,难的是测试。因为触发条件涉及网络断开、进程被杀、连接假死等异常场景,常规的单元测试根本覆盖不到。我建议把测试重点放在集成测试和混沌测试上。
集成测试要覆盖的最核心场景有三个:发送方发出消息后立即断网,消息在服务端成功落库;接收方在线但连接假死,消息转为离线补发;接收方补发过程中再次断线,剩余消息在下一次上线后继续补发。这三个场景能跑通,基本就证明主链路是通的。
混沌测试就更实用一些,可以在测试环境里人为制造服务端进程重启、Redis主从切换、MySQL连接池耗尽等故障,观察消息会不会丢、卡住或被重复投递。我们当时用了一套脚本,随机杀掉服务端推送进程,然后检查消息库里所有消息的最终状态,凡是停在SENT状态超过10分钟的都视为异常,逐个排查。这套机制跑了一周,揪出来好几个深埋的问题,其中一个就是Redis连接池在重启后没有自动重连,导致离线消息队列短暂不可写。
如果你是刚开始做这个功能,我的建议是先写清楚消息状态机的变更日志,每一条消息的状态变化都记录下来。将来无论是测试还是线上排查,都能通过状态变更日志快速定位消息在哪个环节出了问题,这比任何测试手段都有效。
7. 我的一点体会
离线消息可靠补发,本质上是一个分布式的可靠投递问题。你永远无法保证网络不抖、进程不挂、客户端不抽风,所以只能在确定性上做文章:消息必须有个唯一ID、必须落盘、必须有状态、必须靠ACK驱动流转。
我在实际项目中最大的体会是,不要迷信“高深”的方案。Raft共识、分布式事务这些技术确实很强,但大部分企业IM根本用不上。做好消息落库、设计好状态机、把ACK和重试逻辑闭环,就足以解决95%以上的离线消息问题。剩下的5%,靠的是日志、监控和一套能快速定位问题的排查手段。
做技术的惯性是总想添加更多复杂度来解决问题,但真正成熟的设计往往是用最简单的机制把边界情况焊死。先把“发出去”和“可确认送达”的定义写清楚,再一层层把异常场景堵住,这个功能就没有想象中那么难。
如果你正在做类似的企业即时通讯模块,希望这篇文章能帮你少走一些弯路。尤其是那个ACK超时转离线的细节,我在生产环境里被它坑了整整两个星期,写出来也算是给同行们排个雷。