
说实话,我在第一次做即时通讯项目的时候,根本没把消息丢失当回事。那时候觉得,只要能把消息发出去,对方就能收到,哪有那么玄乎。结果上线第一周就被用户投诉炸了——有人聊天聊到一半消息不见了,有人转账记录凭空消失,这才让我意识到消息防丢失这块水有多深。
即时通讯系统最核心的价值就是「消息能到」,但网络这东西天生就不靠谱。弱网环境、服务器抖动、客户端崩溃……任何环节出问题都可能让消息凭空消失。今天我想用最接地气的方式,聊聊声网在这块是怎么做的,也分享一些我踩坑总结出来的经验。
在聊解决方案之前,咱们得先搞清楚敌人在哪。消息丢失不是玄学,它就发生在几个特定的场景里。
首先是网络传输过程中丢了。就像你寄快递,快递在路上可能丢件、可能被退回,网络包也一样。TCP协议虽然可靠,但在弱网环境下超时重传次数用尽照样会失败。更麻烦的是移动网络的频繁切换,4G切WiFi那几秒钟,消息可能就卡在半路。
然后是服务器处理时出了问题。比如消息到达服务端了,但数据库写入失败,这时候如果没做好幂等处理,重试反而可能重复发送。还有一种更隐蔽的情况:消息写入成功了,但客户端还没收到确认就崩溃了,这时候服务器认为消息已送达,但用户那边根本看不到。
客户端异常也是重灾区。App闪退、手机没电、用户误杀进程……这些场景下消息可能还在发送队列里没发出去,或者已经发出去但没来得及存本地,下次打开就不知道哪去了。

说到防丢失,最基础也最重要的就是ACK机制。这个概念听起来很技术化,其实理解起来特别简单——就像寄挂号信,对方签收了你要知道。
声网在IM SDK里实现的是多级 ACK 机制。第一级是服务器确认,当消息到达服务端时,服务端会立刻给客户端返回一个ACK,告诉他「我收到了」。这时候客户端就可以把消息状态从「发送中」改成「已送达」。但这只是万里长征第一步,因为消息还在服务器上躺着呢。
第二级是对方确认。当接收方真正拿到消息并且存好本地之后,会再给服务器发一个ACK,服务器再通知发送方「对方已读」。这个两层确认听起来有点繁琐,但缺一不可。没有第一层,发送方不知道消息有没有出自己手机;没有第二层,发送方不知道对方到底看没看到。
这里有个关键点要提醒:ACK消息本身也可能丢失。所以声网的设计里,ACK确认消息也会被无限重试,直到收到确认为止。很多新手会在这里栽跟头——他们只重试业务消息,不重试ACK,结果ACK丢了,双方都以为对方收到了,其实谁都没收到。
消息发出去没回应,那就得重试。但重试这事特别考验功力,重试太频繁会把网络搞崩,重试太稀疏又会让用户等太久。
比较合理的做法是指数退避。一开始重试间隔可能只有1秒,如果还是没响应,间隔变成2秒、4秒、8秒……这样既能在网络恢复时快速补发,又不会在网络持续不通的情况下疯狂浪费资源。声网的SDK默认最大重试间隔是32秒,累计重试时间大概在5分钟左右。5分钟之后如果还是不行,系统会提示用户检查网络,而不是无限等下去。
重试的时候还需要注意消息的唯一性。每条消息都要有一个全局唯一的ID,这个ID由发送方生成,服务器只认ID不认内容。这样即使同一条消息因为重试发了十次,服务器也知道这是同一条,不会给用户展示十条重复消息。这个ID怎么生成呢?声网用的是时间戳加随机数的组合,理论上不会冲突。

光有重试还不够,消息得存住才行。你想啊,如果消息只在内存里,服务器一重启就没了,那之前发的消息全得重新发,用户不得疯掉?
所以消息持久化是必须的。但存到哪儿、怎么存,这里面的学问就大了。
声网的做法是多副本存储。消息一到服务器,同时写两份,一份放到主数据库,一份放到消息队列。主数据库负责日常查询,消息队列作为备份兼做异步处理。这样即使主数据库挂了,消息队列里的副本还能撑一段时间。而且消息队列有个好处,它可以异步处理一些耗时操作,比如推送到APNs、写入搜索索引这些,不会阻塞主流程。
本地持久化同样重要。用户手机上的消息不能只存内存,得写到SQLite或者类似的本地数据库里。这样即使App闪退,消息还在,下次打开能直接从数据库加载。声网的SDK在这一点上做了强化——每收到一条消息,必须先写入本地数据库,然后才通知UI层渲染。如果写入失败,这条消息干脆不显示,等重试成功再说。虽然用户可能看到短暂的加载状态,但总比消息丢失强。
| 存储环节 | 存储位置 | 失败后果 | 处理方式 |
| 发送方本地 | SQLite数据库 | 消息丢失,无法恢复 | 写入成功后才显示发送成功 |
| 服务端队列 | Redis/消息队列 | 服务器重启消息可能丢失 | 双写主库和队列 |
| 服务端主库 | MySQL/PostgreSQL | 永久性消息丢失 | 主从复制,定期备份 |
| 接收方本地 | SQLite数据库 | 换手机后消息消失 | 多端同步,云端备份 |
移动端的网络,大家懂的,一会儿有网一会儿没网。断线重连看起来是小事,处理不好就是大麻烦。
我见过最坑的重连逻辑是这样的:检测到断线,立刻重连,重连失败,疯狂重试。这不叫重连,这叫DoS攻击自家服务器。正确的做法是渐进式重连,一开始等1秒,没连上等2秒,再没连上等4秒,最高指数级增长到30秒或者60秒一次。而且要加个最大重试次数限制,比如重试10次还不行,就进入离线模式,让用户知道现在发不了消息了。
重连成功之后,还有一步关键操作:同步。客户端要和服务器对一下「我知道的消息」和「服务器知道的消息」差在哪儿。这个过程叫消息同步。声网的方案是让客户端上报自己最后收到的消息ID,服务器从这个ID之后的所有消息都重新发一遍。这样既能补上丢失的消息,又不会重复发送已经收到的。
总有人不在线,那消息还发不发?发的话存在哪儿?
离线消息的处理逻辑是这样的:服务器先存着,等用户上线了再推。但存多久呢?存一辈子不现实,存太短用户不满意。行业里比较常见的做法是保存7天或者30天,声网默认是7天,开发者可以根据需求调整。
用户上线后一次性推多少消息?这个要谨慎。如果用户离线了一个月,一次性推几十万条消息,客户端直接卡死。声网的做法是分页拉取,每次最多拉200条,用户看完了再拉下一页。这样既保证了消息能全部到达,又不会把客户端搞挂。
还有一点容易被忽视:离线消息的顺序。假设用户离线期间,有人发了一条消息A,然后撤回 了A,又发了消息B。用户上线后应该先看到A再看到B最后看到撤回标记,而不是直接看到B。声网的离线消息队列会严格按时间戳排序,保证消息的时序性。
上面说的都是传输层的可靠性,但有时候我们还需要更高级别的保障——比如端到端加密场景下,服务器根本看不到消息内容,那防丢失怎么做?
这种情况需要把更多责任交给客户端。发送方在发送加密消息的同时,要把消息的加密密钥碎片存在服务器(注意是碎片,服务器拼不出完整密钥)。接收方需要向发送方请求密钥才能解密。如果消息丢失了,接收方可以告诉发送方「我少收了某条消息」,发送方重新加密再发一遍。
声网的加密IM方案里集成了这个机制,虽然实现起来比普通消息麻烦很多,但确实做到了即使服务器被攻破,攻击者也看不到消息内容,同时消息也不会因为加密而丢失。
说完了理论,最后聊几个我亲身经历过的坑,都是教训。
这些坑踩过之后,我才真正理解了什么叫「细节是魔鬼」。消息防丢失这件事,99%的场景都能正常工作,但那1%的异常情况往往才是决定产品能不能用的关键。
做即时通讯这些年,我越来越觉得消息防丢失不是某一个技术点,而是一整套系统工程。从客户端的网络库到服务端的存储,从ACK机制到重试策略,从断线重连到多端同步,每一个环节都不能有短板。
声网在这块投入了大量的研发资源,毕竟对于IM产品来说,消息丢失一次,可能用户就跑了。但技术嘛,就是这样,没有捷径,只能一点点抠细节、一遍遍测场景。
如果你正在开发自己的IM系统,希望这篇文章能帮你少走点弯路。有什么问题,欢迎随时交流。
