尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

离线消息可靠补发:从状态机到ACK的完整链路解析

离线消息可靠补发:从状态机到ACK的完整链路解析 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的离线消息队列。设计上我用了一个按照用户维度拆分的ZSetkey是offline:{userId}score是服务端消息IDvalue是序列化后的消息内容。为什么用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超时转离线的细节我在生产环境里被它坑了整整两个星期写出来也算是给同行们排个雷。
返回列表