
做企业IM做了这么多年最常被问的一句话就是为什么别人的消息能准确送达我们做的东西在弱网环境下就各种丢消息、收不到推送、重复显示企业即时通讯和普通聊天软件最大的区别就在于消息可靠性。普通聊天丢一条消息最多被朋友吐槽一下企业IM里丢一条审批、一条生产指令或者一条客户跟进记录那就不是吐槽的问题了直接就是事故。这些年我处理过的线上问题里大概有三分之一都和数据不一致、消息状态错乱有关。所以“消息可靠性”这四个字看着是通信领域的老话题真要在企业场景落地远比想象中复杂。这篇文章不会跟你聊太多理论模型我会从实际落地角度把一条消息从发送端出发到接收端完整展示中间会经历哪些环节、哪些环节会“坑队友”、每个环节怎么兜底全部拆开讲清楚。不管是正在自研企业IM还是做IM系统运维又或者只是想搞明白“消息可靠性到底是怎么保证的”这篇内容都值得你花15分钟读完。1. 先把可靠性这件事拆开一条消息从发出到显示中间有多脆弱聊可靠性之前先把问题打开。很多人以为“消息发出去对方收到了”是理所当然的但实际一条消息的生命周期远比你想象的惊险。1.1 一条消息的完整旅程每个节点都可能成为丢消息的元凶我们假设用户在客户端A给客户端B发一条文本消息这条消息大致要经过这么几个节点客户端A将消息封装好传给接入层通常是一个长连接网关接入层把消息转发给业务逻辑层业务层生成或校验消息ID消息进入消息队列或者直接进入存储层做持久化存储层返回写入结果确认“消息已经妥善保管”服务端把消息推送给客户端B在线走长连接离线走APNs/FCM/厂商推送客户端B收到消息回执确认客户端B的UI层校验和渲染用户最终看到消息。这七个步骤每一步都可能出问题。接入层宕机、业务层超时、数据库写入失败、推送通道延迟、客户端进程被杀、网络闪断……任何一个环节出问题用户感知到的结果都是“消息丢了”或“消息没发出去”。你可能会说我们用TCP/IP网络层不是有可靠性保证吗TCP协议保证的是字节流可靠传输但应用层的“收到”和“处理成功”是两码事。TCP能保证数据报文到达对端内核缓冲区但不能保证对端应用进程把消息成功持久化并在界面上展示出来。所以在IM系统里可靠性的核心从来不是网络层而是应用层的确认与补偿机制。1.2 可靠性的三个维度不丢、不重、不乱序做企业IM可靠性不能靠“感觉”得定义成明确的工程指标。我把可靠性的目标拆成三个维度不丢消息一旦被发送方成功提交就必须被服务端持久化并在网络条件恢复或设备在线后送达接收方。这是最基础的底线。不重消息不能重复展示。网络超时导致的重传、客户端重试都可能让同一条消息被服务端接收两次。业务场景下重复消息等同于脏数据。不乱序同一个会话里的消息接收方看到的顺序必须和发送方的操作顺序一致。这在群聊、单聊、互相穿插回复时尤为重要。这三点看着朴素但每一点做扎实都不容易。实际项目里“不重”和“不乱序”往往会互相打架——为了保证不丢我们会重传但重传会带来重复为了保证顺序我们会给消息排队但排队性能差又会加剧超时重传的概率。所以落地可靠传输核心就是在这些约束之间找平衡然后用机制把漏洞补齐。下面我会逐个拆解具体怎么做。2. 落地可靠传输的“三板斧”消息ID、ACK确认与超时重传消息可靠性落地看一个团队水平怎么样就看这三板斧用得怎么样。这三样可以说是企业IM可靠传输的地基。2.1 消息ID设计全局唯一只是一半另一半是“可追溯”消息ID是一条消息从出生到消亡的唯一身份标识。它有两个核心作用一个是给消息做幂等去重另一个是在排查问题的时候通过消息ID能完整地追踪一条消息的完整链路。消息ID的生成策略业界主流的做法是雪花算法Snowflake或者基于Redis自增。雪花算法生成的ID是64位长整型包含时间戳、机器ID和序列号全局唯一且趋势递增。这里有个容易忽略的点——如果你们的消息ID是给用户展示用的比如“消息编号”就不适合直接用雪花ID太长用户记不住。一般建议内部消息ID走雪花对外展示用的短ID单独生成。还有一个经验消息ID在客户端生成就在客户端生成不要让服务端来统一分配。为什么因为客户端生成ID可以让“发送中”状态的消息也有一个稳定ID服务端重试、状态同步、本地缓存都能复用同一个ID避免客户端显示层做ID映射。这个设计对后面的去重和乱序处理帮助很大。注意消息ID的生成最好做到全局唯一不要只做会话内唯一。因为消息在数据库里定位、在日志系统里排查、在跨端同步时对齐状态都要靠这个ID。如果只保证会话内唯一多端同步时麻烦非常大。2.2 ACK与超时重传把“不确定”变成“确定”的核心手段可靠性最基本的保障模式是“发送-确认-重传”。这个模型在TCP协议里已经验证了无数遍IM系统直接借鉴过来即可。具体到实现上分三个层级来做第一层客户端 → 服务端的ACK客户端A把消息发给服务端服务端收到并成功持久化后给客户端A回一个ACK。客户端A只有在收到ACK之后才把这条消息的状态从“发送中”改成“已发送”。如果客户端在一定时间内没收到ACK就自动重发。这个“一定时间”该怎么设我在实际项目里的经验是默认3000毫秒弱网环境下可以调整到5000~8000毫秒。设太短会频繁触发无谓的重试设太长又会让用户在发送失败时等待过久。第二层服务端 → 客户端的ACK服务端给客户端B推送消息客户端B收到之后需要回一个ACK给服务端。服务端只有在收到B的ACK后才认为这条消息“投递成功”。如果超时未收到ACK服务端会持续重推。第三层消息到达UI层的确认这里容易踩坑的是——客户端B收到消息后ACK什么时候发如果在网络层收到消息就立刻回ACK然后应用崩溃消息还没写入本地数据库那这条消息实际上又“丢”了但服务端已经认为投递成功。所以在我的项目里客户端收到消息后先写本地存储SQLite或Room落库成功后再回ACK。这样能最大程度保证“服务端认为成功” “用户一定能看到”。重传策略方面不能傻乎乎地固定间隔重试要用指数退避。例如第1次重试间隔1秒 第2次重试间隔2秒 第3次重试间隔4秒 第4次重试间隔8秒 ... 第n次重试间隔 2^(n-1) 秒上限30秒或60秒这样做的好处是网络短暂抖动时重试间隔短恢复快网络长期中断时又不会高频轰炸服务端。同时要设置重试上限一般重试6~8次后如果ACK还是没回来就标记为“发送失败”让用户手动选择重发。2.3 幂等去重为什么服务端必须做而不是只靠客户端在超时重传机制下同一条消息被客户端重发一次或多次是正常现象。这时候如果服务端不做去重接收方就会看到两条一模一样的消息。去重的关键就是用好消息ID。实现上去重有两种思路服务端接收层去重服务端在处理消息之前先查一下Redis或数据库里是否已经有相同消息ID的记录如果有直接返回“成功”但不重复落库、不重复推送。客户端展示层去重客户端渲染消息列表时根据消息ID去重。这个主要用来兜底比如服务端推送重复了客户端能过滤掉。两条兜底我建议都做。服务端去重是主流保护客户端去重是最后一道防线。关于服务端去重的存储不建议每次都查数据库性能扛不住。通常的做法是消息进来时先用Redis的SETNX命令判断key msg:id:会话ID:消息ID如果是第一次写入返回1继续处理如果返回0说明已经处理过直接丢弃或返回成功提示。实操心得Redis里的去重key一定要设置过期时间建议7天。因为用户不可能去查看7天前的旧消息的重复状态而且key长期不清理会让Redis内存膨胀。3. 离线消息、多端同步与消息存储可靠性不只是传输的事很多人做IM把可靠性全部寄托在传输层的确认与重传上结果上线后被用户骂“消息丢了”排查下来发现是离线消息、多端同步和存储环节出了问题。传输层只是前半程后半程一样要花心思。3.1 离线消息的可靠补偿服务端持久化是最后的保险用户不在线的时候消息往哪儿放这是离线消息机制要解决的问题。核心逻辑很简单用户不在线时服务端先把消息存起来等用户上线后拉取。但简单背后有几个坑。第一个坑推送和离线拉取的边界怎么控制。用户A给用户B发消息时B恰好断网了服务端是先推还是先存我的做法是——无论如何都要先落库再尝试实时推送。因为一旦先推后存推送通道失败后消息就丢了你还得补做补偿机制。先存后推存储成功是“确定事件”推送是“尽力而为”推送失败后走离线补偿链路即可。第二个坑离线消息的存储模型。业界常见的有两种——inbox模型和拉取模型。inbox模型每个用户一张消息表消息直接投递到每个接收者的信箱里。好处是读取快坏处是群聊场景下一条群消息要给N个人各存一份存储成本线性膨胀。拉取模型消息只存一份拉取的时候根据时间戳或者同步游标去过滤。好处是省存储坏处是拉取查询逻辑更复杂。真实项目里通常两种结合单聊拉取模型群聊用inbox模型做读写分离或者用同步游标的方式来做增量拉取。我见过很多初级方案在群聊里用inbox200人的群一条消息存200份数据库直接爆炸。第三个坑离线消息的保留时长。无限期保留离线消息会让存储无限膨胀建议在策略上明确——离线消息默认保留30天或90天超期未拉取就归档或清理。具体保留多长时间取决于业务数据和公司合规要求。3.2 多端同步下的乱序问题顺序号才是真正的裁判现在的企业IM基本都要求多端消息同步手机、PC、网页版都要看到一致的消息列表。这时候你会发现“确保传输不丢”远远不够还得确保多端看到的消息顺序一致。消息顺序在多端同步场景下不能依赖客户端的时间戳。因为各终端系统时间可能不一致有时候用户手机时间快了5分钟发出来的消息时间戳就比上一条还早。所以正确做法是由服务端为每一条消息生成一个全局递增的服务端序号sequence number业内常叫seq。消息落库时服务端按持久化顺序分配seq。客户端不管是实时推送还是离线拉取都以seq为准来排列消息。群聊场景下每个会话维护一个会话级别的seq消息列表按会话seq排序。这里有一个容易忽略的细节多端同步时的增量拉取游标应该用seq而不是时间戳。用时间戳做游标会出现漏消息或重复拉取的问题用seq做游标天然支持按序拉取服务端实现和客户端渲染都简单很多。实操心得seq分配只能在单节点或强一致环境里进行。如果服务端是多副本部署且副本之间没有做全局递增协调很容易生成重复或乱序的seq。这个问题我们在早期踩过坑后来直接用Redis的INCR命令来分配会话seq或者在DB层用带自增主键的表来派号才彻底解决。3.3 消息落库先写DB还是先发推送这顺序别搞反了虽然上面的内容提到“先存再推”但实际工程里还有更细的讲究——业务处理和消息存储之间的事务一致性怎么保证。我见过一个很典型的错误方案先发消息到消息队列由异步任务写库再推送。听起来很丝滑但一旦消息队列积压或者消费失败消息在长时间内查不到用户那边已经开始刷“消息发出去了怎么对方没收到”了。我的建议是业务层同步写DB或者写一个内部消息表DB写入成功后再做推送或MQ投递。为什么因为DB是可靠存储写入成功就是确定性的后续无论推送链路怎么失败都是可恢复的。如果反过来先推MQ再写DBMQ成功但DB失败这条消息就成了“幽灵消息”——无法查询、无法补推、无法排序。万一DB写入失败怎么办业务层要做事务回滚并返回给客户端失败消息让客户端进入重试流程。这样消息要么彻底失败要么完全成功不会出现中间状态。另外消息存储的表设计也建议别太笨重。常见的设计是message表存消息内容、发送者、会话ID、消息类型、seq、消息状态等session表存会话信息、最后一条消息ID、最后更新时间等明细同步表/用户同步游标表记录每个用户每个会话已拉取到的最大seq。这三个表看起来简单但索引设计、分库分表策略都在后面大型IM系统的性能瓶颈几乎都出在消息存储层。4. 实战排坑那些线上故障教会我的事这一节的内容来自真实线上问题复盘。说句实话这些坑比任何文档里的理论都值钱因为每一个都曾经直接导致过线上事故。4.1 超时重传引发消息重复你以为服务端去重就够了之前做一个企业内部IM项目时有段时间频繁收到用户反馈“我收到同样的消息好几次”。排查发现客户端超时重传导致服务端收到重复消息服务端虽然做了去重但去重的判断条件用的是“发送方ID 会话ID 客户端消息ID”这三者结合。问题出在——有部分老版本客户端消息ID是在发送前临时生成的如果发送失败后用户手动点了“重新发送”会生成一个新的消息ID于是服务端去重失效消息重复入库、重复推送。这次事故之后我们做了三个调整客户端生成消息ID的时机前移到用户点击“发送”按钮的那一刻而不是每次网络请求时生成客户端本地维护“待重发队列”重发时用原始消息ID保证同一业务消息ID不变服务端去重表增加一个“fingerprint”字段由发送者ID会话ID消息内容Hash组成单纯靠消息ID兜不住手动重发的场景时用这个做第二层兜底。4.2 消息乱序的元凶并不是网络而是多线程并发推送还有一次用户投诉“PC端收到消息的顺序是乱的”。一开始我们都以为是网络延迟导致后来查了很久才发现问题出在服务端推送模块的线程模型上。推送模块为了实现高吞吐用了多线程池处理同一会话的不同消息。线程A在处理第5条消息线程B在处理第6条消息结果线程B先完成推送第6条消息比第5条先到达客户端客户端按到达时间渲染顺序就错了。问题根因很清楚同一会话的消息推送必须串行化不能并发。后来我们给每个会话加了一把“会话级别锁”确保同一会话的消息在推送模块内部是按序处理的。客户端也加了兜底——收到新消息时如果发现seq小于或等于本地最新seq就放入待排序缓冲稍后按seq排序后再上屏。这个改动上线后乱序问题基本消失。4.3 排查工具消息链路追踪是效率的十倍提升最后强烈建议做IM系统一定要有一套消息链路追踪机制。最简单的实现方式微服务之间传context包含消息ID在关键节点记录日志接入层收到、业务层处理、存储写入、推送发出、客户端ACK到达日志统一汇总到日志平台。这样当线上出现“消息状态不一致”的问题时你拿到消息ID直接查日志平台一条消息的完整生命周期立刻就能看到卡在哪一环。没有这套系统前排查一个消息丢失可能要翻十几个服务日志耗时几个小时有系统之后十分钟内定位问题效率提升巨大。注意日志的key一定用消息ID不要用人名、手机号。原因有两个一个是性能更好另一个是避免在日志平台留存太多个人敏感信息符合数据合规要求。5. 衡量可靠性用数据指标倒推工程质量聊完机制和踩坑最后说说衡量。很多人做IM没有量化可靠性的意识全靠“感觉还行”。但在我带领下团队从第一天开始就建立了一组可靠性相关指标。这组指标不光用来验收更是用来倒推工程质量的——一旦指标异常必然是链路里某个模块出了问题。5.1 几个必须盯的数据指标我一般要求团队每天都盯这几个指标任何一个指标劣化都要能追溯到原因消息投递成功率成功投递给接收方的消息数 / 服务端应投递的消息总数。目标是99.9%以上。消息确认率客户端ACK返回的服务端消息数 / 服务端推送给客户端的消息总数。和投递成功率联动看。重复展示率用户实际看到重复消息的次数 / 总展示次数。目标越低越好理想状态是0。乱序率客户端检测到新消息seq小于等于本地最新seq的次数 / 总消息数。也是越低越好。消息端到端延迟从发送方点击发送到接收方渲染展示这中间的时间P95要小于1秒P99尽量小于2秒。延迟如果太高意味着链路有瓶颈会影响“可靠性”的体感。这些指标不用自己做特别复杂的采集客户端埋点服务端日志统计即可。关键是每天看趋势而不是一句话“今天正常”就完事。一旦波动明显就要按链路追踪排查。5.2 混沌工程式自测别等故障发生才被动响应最后建议有条件的话做一点“故意破坏”的测试。我举个我们做过的例子——在灰度环境里定期对客户端做弱网模拟丢包率10%、延迟500ms同时故意重启后端某个消息服务。这样做的目的就是看看在极端场景下消息是丢、重还是乱然后把问题修复在用户发现之前。这类混沌测试不需要太复杂即使在测试阶段用一些简单的限流、断网脚本就能模拟出大部分可靠性问题。关键是把这个做成例行机制而不是上线前搞一次就完了。可靠性是长期维护出来的不是一锤子买卖。写在最后回到开头的问题——企业即时通讯的消息可靠性到底怎么落地说白了没有银弹就是一套组合拳消息ID打底、ACK重传确认、幂等去重、服务端持久化、seq排序、多渠道补偿再加上可视化的指标系统和链路追踪。每一步单独看都不复杂难的是把它们串联起来在每一个环节都做好兜底让“确定性”贯穿始终。我个人在实际操作中体会最深的一点是永远不要在客户端或服务端单方面信任任何一方。客户端说发出了不代表服务端收到了服务端说推成功了不代表用户看到了。你的工作是让每一次“不确定”都有地方确认让每一次失败都有机会重来。这听起来朴素但把这件事做到极致用户体验自然就立住了。