ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

企业IM离线消息补发机制:从状态机到ack确认的完整设计

企业IM离线消息补发机制:从状态机到ack确认的完整设计 先抛个场景早上九点你给销售总监发了条项目排期确认的消息他当时在高铁上手机信号断断续续。你看着他头像一直没回心里没底。过了半小时他回复刚出隧道你说的事我看到了。这里的关键点不在于这条消息最终有没有到而在于它到你同事手机里这个过程中间经历了多少道关卡每一道关卡又是靠什么机制保证不丢、不重、不乱。企业即时通讯的离线消息补发就是解决这个问题的核心环节。跟普通社交软件不同企业IM里的消息往往是任务指令、审批结果、会议通知丢一条可能就是一个事故。所以“发出去”只是起点“可确认送达”才是终点。今天我就把这条链路从设计到落地完整拆一遍你会看到消息状态怎么流转、离线窗口怎么判定、补拉机制怎么做、以及那些让人头疼的重复消息和乱序问题到底怎么治。1. 离线消息补发的整体设计思路1.1 为什么企业IM必须做到“可确认送达”先说清楚一个本质区别消费级IM的离线消息补发是“尽力而为”企业IM的离线消息补发是“必须确认”。原因是业务模型完全不同。消费场景里你给朋友发一句“晚上吃啥”对方如果一直离线消息在服务端存个七天也就够了丢了就丢了没人会因为一条聊天记录去找客服。但企业场景里消息本身就是业务动作的载体。比如一条“同意”可能对应一个采购审批一条“下午三点开会”关联着参会人的日程安排一条“生产线的参数已经改完”是下一环节操作的直接依据。所以企业IM在这件事上的核心指标不是“发出去的数量”而是“确认送达率”你必须能回答一个问题这条消息到底有没有成功地被对方客户端接收并存储回答不了这个问题的IM在企业的关键业务链路里就是不合格的。这就引出离线消息补发的目标定义任何一条发送成功的消息在目标用户从离线状态恢复后必须能完整地被拉取、去重、按正确顺序写入本地并且这个过程需要让发送方和服务端都能感知到“已完成”。我把它简称为端到端确认链路。1.2 整体链路拆解推、拉、确认三件事整个离线消息补发的设计核心就是推、拉、确认三件事的组合。正规做法是推拉结合而不是单纯靠某一种。先看消息到达服务端之后发生什么。发送方客户端把消息发给IM服务端服务端先写入消息存储通常是分布式数据库或消息中间件然后立刻检查接收方的在线状态。如果接收方在线直接通过长连接推送下去这叫推。如果接收方不在线或者推送后没收到确认这条消息就进入离线消息存储等接收方下次上线时主动来拉这叫拉。但离线补发不能只靠拉因为客户端可能长期不主动重连。所以成熟方案里还会有一个补偿推送服务端检测到用户重新建立长连接后先主动把离线消息数量和时间范围推给客户端客户端再决定要不要拉全量。这个过程我叫它“拉前通知”。这里的环境比我刚入行时复杂得多手机上网络切来切去PC端和手机端同时登录消息要同时分发到多个终端。所以设计时还要考虑多端同步的问题。不是简单推给在线端就完了每个端点都要单独维护自己的确认状态。2. 消息状态机与可靠性分级2.1 消息从“发出”到“确认”的完整状态流转离线消息补发要做到可确认第一步是给消息定义明确的状态。我会用状态机来管理每一条消息在服务端至少经历以下几个状态待发送、已投递待确认、送达、已读。待发送状态很好理解客户端把消息提交到服务端服务端写入存储成功但还没向接收端推送。已投递待确认是消息已经推给某个在线客户端但还没收到客户端的ack回执。送达状态代表接收方至少有一个终端确认收到了这条消息并且成功写入本地存储。已读状态则更进一层代表用户实际打开了会话窗口并看到了消息这是另一套语义跟离线补发关系不大但状态机里要预留。这里我踩过一个坑早期版本把“推送成功”和“送达”混为一谈。服务端的推送模块把数据包交给了socket就标记为已送达。后来发现客户端拿到数据包之后在写入本地数据库时Crash了消息就丢了服务端却毫不知情。所以**“投递”和“确认接收”必须分开**客户端收到数据包并完成本地落库后才能回ack。一个相对可靠的状态流转可以这样设计发送方客户端发送消息 → 服务端收到写消息存储 → 标记为待发送。服务端查询接收方在线状态通过长连接推送数据包 → 标记为已投递待确认。接收方客户端收到数据包写入本地SQLite/LevelDB成功后回ack。服务端收到ack把这条消息标记为送达同时更新会话维度的最近消息时间戳。已读回执是另一次独立上报不影响消息本身是否“可靠送达”的判断。如果第2步之后一直没有收到第3步的ack服务端就需要启动重推逻辑直到超过阈值转入离线补发队列。这个机制就是消息可靠性的核心。2.2 QoS分级不是每条消息都要用同一套保障企业IM场景里不同消息的重要性其实不一样。如果所有消息都按最高可靠性标准处理性能和成本都会很吃亏。我习惯把消息分成两级类似MQ里的QoS语义。QoS 0尽力而为适用于系统提示、好友上线通知、输入状态这类不重要的消息。服务端推送一次丢了就丢了不重推不进离线消息队列。这类消息即使丢几条用户也无感知。QoS 1至少一次适用于所有正式的业务消息和聊天消息。核心是“至少一次”语义允许重复但不允许丢失。服务端推送后必须等ack超时重推客户端必须做去重。这是离线消息补发里主要处理的级别。至于QoS 2恰好一次在大规模IM场景里很少用因为开销大到不划算。我会通过客户端去重服务端幂等来达到“业务上不重复”的效果而不是在传输层做严格的一次性投递。2.3 离线窗口的判断规则离线不是只有“在线/不在线”二元状态。移动端应用被系统挂起、网络切换、甚至用户主动断网服务端看到的都是“连接断开”。但断开的时间点前后消息的处理方式差别很大。我的方案是基于**会话session**维护在线状态。客户端建立长连接时服务端记录连接建立时间连接断开时记录最后心跳时间。判断一条消息是否需要走离线补发不是看“用户现在在不在线”而是看“这条消息产生时接收方有没有可用的长连接”。这里有一个重要细节消息产生时刻和连接断开的先后顺序决定了补发时机。举个例子用户A和B都在线A发消息给B服务端把消息推给B的在线连接同时写入消息存储。如果这时候B的客户端刚断线消息已经在推送路上了服务端没收到ack这条消息应该立即进入补偿重推流程。但如果B在消息产生前就已经断开连接超过30秒服务端就直接把消息写入离线存储等B重新上线后走补拉。出于效率考虑我还会设置一个离线判定阈值比如30秒。断开连接在30秒内的下次连接恢复时不用做全量补拉只做增量差值超过30秒的就按完整的离线补发流程走。3. 实操过程从客户端上线到补发完成的完整链路3.1 客户端上线后的同步触发机制下面进入实操环节我把一个完整的离线补发过程按顺序讲一遍这部分内容你基本上可以直接参考实现。客户端重新建立长连接后第一步不是拉消息而是做连接级握手。握手包里带上客户端本地最后一条消息的全局消息IDglobal_msg_id或者上次同步的游标位置。服务端收到握手请求后拿这个ID跟服务端存储的该用户最新消息ID做比对。比对结果决定同步策略如果差值很小说明只是短暂断线服务端直接把差值部分的消息全部下发如果差值很大服务端会返回一个“离线消息数量提示”客户端再决定是否分页拉取。这个前置提示很重要我在实际设计里踩过坑最初是一上线就全量分页拉取如果有几千条离线消息手机流量和本地写入都会崩溃还会造成消息风暴。3.2 分页拉取与全局游标设计离线消息拉取不能一次性把所有消息都拉到客户端。我的做法是建立一套分页拉取协议每一页有固定大小比如200条游标用来记录当前拉到哪个位置。协议字段设计大致是这样的start_msg_id起始消息ID第一次拉取时用握手包里的值。end_msg_id结束消息ID通常取服务端当前最新消息ID。page_size每页条数建议不超过200。direction方向这里固定为forward。服务端收到请求后从消息存储中查出这个区间内的消息按msg_id升序返回同时返回下一条游标位置。客户端收到一页数据后先做本地去重再按顺序写入本地数据库全部落库完成后再请求下一页直到游标追上最新的end_msg_id。这里关于游标字段的选择值得多说一句。有两种主流思路按msg_id游标和按时间游标。我强烈推荐按msg_id游标因为msg_id是全局严格递增的天然具备“比大小”的语义按时间游标会遇到两个消息同一毫秒的边界问题非常容易造成重复或漏拉。3.3 消息存储与去重表设计离线消息的可靠补发底层靠的是存储设计。我会在服务端为每个用户维护一个消息拉取表表结构大致长这样字段类型说明user_idvarchar消息接收方用户IDmsg_idbigint全局单调递增消息ID联合主键之一from_user_idvarchar发送方用户IDsession_idvarchar会话ID单聊或群聊会话的唯一标识payloadblob消息内容的序列化数据is_acktinyint该消息是否已被客户端确认接收create_timebigint消息写入时间使用悲观锁或者唯一索引都行但必须保证同一个msg_id对同一个user_id只能出现一次这是语义上的硬要求。客户端本地则维护一张已接收消息表表里也存msg_id和会话ID客户端每次写入新消息前先在本地表里查一下msg_id是否存在。内存中去重的方式我补充一下客户端可以维护一个当前会话已加载消息ID的滑动窗口缓存比如最近500条。查询本地数据库之前先查这个缓存命中就直接忽略。这样能省掉大量重复的本地磁盘IO。3.4 ack确认机制与超时补偿策略消息补发完之后如果没有“确认”动作整个链路还是闭环不了。ack确认机制是整个设计的最后一环也是最容易被忽略的。客户端的流程是收到一页消息 → 依次写入本地数据库 → 全部写完后发一个batch_ack请求里面带上这页消息的最大msg_id。服务端收到batch_ack后把该用户这个msg_id之前的消息全部标记为is_ack 1。用最大msg_id做批量确认能省掉逐条ack的大量网络开销这个优化在离线消息量大的时候特别明显。服务端如果长时间没有收到ack就会启动重推。我建议用指数退避的方式比如30秒、1分钟、2分钟、4分钟最多重推5次。超过最大重推次数后消息保留在离线存储中等待客户端下一次主动拉取。这里有一个数据清理策略要提一下已经被ack的消息不会立刻从服务端删除而是会保留一段时间比如3天防止客户端本地数据被清除后二次拉取失败。超过保留期的ack消息定期清理归档。4. 常见问题与排查技巧实录4.1 离线补发的典型坑位速查表这部分我把日常运维和开发中可能遇到的问题都整理出来了每一项都是我在实际项目里遇到的。问题现象可能原因排查思路消息重复出现客户端拉取时本地去重失败ack丢失导致服务端重推检查客户端本地msg_id索引看服务端是否在收到ack前重复推送消息丢失客户端收到后落库失败但未回ack服务端提前清理过期消息检查客户端回ack时机看服务端清理策略的保留期是否过短消息乱序分页拉取时没有按msg_id排序本地写入时多线程并发落库检查拉取SQL的order by本地写入加串行队列客户端上线后消息风暴没有分页拉取离线消息数量过大直接全量推增加离线数量提示前置走分页拉取ack拥塞大量消息逐条回ack服务端支持batch_ack客户端按页确认多端登录时消息不同步服务端未按设备维护会话状态每个设备建立独立会话消息状态按session_id隔离4.2 重复消息问题的现场排查记录我印象最深的一次线上故障是某次版本升级后一部分用户反馈聊天记录出现大量重复消息。查了半天最后定位到根因客户端升级后上一版本本地数据库表结构变了旧版本写入的msg_id对应的主键索引没有被正确迁移导致每次拉取时msg_id重复校验失败。这个事给我的教训是客户端本地去重不能只依赖ORM框架的saveOrUpdate一定要在数据库层面建唯一索引。索引没建对代码里判断再多也会在边界条件下翻车。另外一个常见翻车点是在多设备登录场景手机端和PC端同时在同一个会话里拉取离线消息两台设备分别拉取如果服务端没有按设备标记同步状态就可能出现两边互相覆盖本地消息记录的情况。解决思路是服务端为每个设备维护同步游标PC端确认到msg_id 10000不影响手机端从5000开始继续拉。4.3 离线补发压测的一个小技巧最后分享一个压测层面的实用技巧。企业IM做离线消息补发最容易忽略的是“大量用户同时离线再同时上线”的场景。比如早上上班高峰期几千名员工同时从家切换到公司Wi-Fi长连接全部重连服务端要同时处理大量补拉请求。我建议做一套专门的压力场景往测试用户的离线消息队列里注入1万条消息分3批模拟100、500、1000个用户同时上线拉取。重点观察内存对象数量、数据库连接池水位、以及有没有大量的索引锁竞争。实测下来最有效的优化往往不是加机器而是加一层热点会话的消息缓存把频繁拉取的会话消息缓存到Redis里减少数据库压力。5. 多端协同与扩展方向5.1 多端在线时的拉取策略调整现在的企业IM基本都是多端并行手机、PC、网页、甚至Pad。多端在线给离线补发带来的问题在于一条消息可能已经在一个终端确认接收了但另一个终端才刚刚上线。这种情况下那台新上线的终端要不要补拉所有历史消息答案是要但不是全量。我的设计方案是消息是否进入离线队列的判断按“设备未确认”来区分而不是按用户维度。用户维度确认后只是代表用户至少有一个终端接收到了这条消息但新上线的设备仍要按自己的游标补拉补拉范围是这个设备本地最后一条消息ID之后的增量。这个策略的整体链路复杂度会高一些但用户体验是最完整的。为了控制成本可以给跨设备的补拉设置一个时间范围比如只补最近7天的消息更早的消息需要通过滚动查询历史记录接口来加载。5.2 从“可靠送达”到“安全送达”的演进聊完了可靠我还是想多提一句安全层面的事。离线消息补发的数据往往缓存在服务端相对长的时间这中间如果涉及到敏感内容其实是有安全风险的。成熟的方案里消息在服务端离线存储时应该加密客户端拉取后解密密钥跟用户绑定的设备证书相关。这不是本题的必需项但如果你正在设计企业IM我建议你把加密设计放到同步规划里因为后面改造迁移的成本比你想象中要高。另外对离线消息的合规保留要求不同企业诉求差别很大。有的要求消息留存6个月以上以便审计有的要求所有离线数据过期即焚减轻合规负担。我建议把保留策略做成配置项支持按会话、按部门、按消息类型分别设置过期时间。我个人在实际操作中最大的体会是离线消息补发这个东西表面上是个技术问题本质上是个信任问题。企业把消息交给你传输就是信任你在各种极端网络条件下都能把它完整送到。技术上靠状态机、靠ack、靠幂等设计这些都能一步步做扎实但真正决定上限的是你愿不愿意在每个容易说“差不多得了”的环节多追问一句如果这里失败了用户和数据会发生什么想清楚这个你的设计才会真的不一样。
返回列表