凌晨两点半,手机在床头柜上震得像个疯子。我迷迷糊糊接起来,对面是驻场运维的急促声音:“客户那边IM系统看起来是正常的,所有账号都在线,但有个群里有人发消息,只有一部分人收到了。”
这句话我到现在还记得。不是因为它多罕见,而是它把私有化部署即时通讯系统的可靠性设计里最典型的认知误区说透了:服务在线,不代表消息可信。很多团队做私有化IM,第一版跑通的时候欢呼“连上了”“能发消息了”,可真交付给客户之后才发现,“能发消息”和“消息一定到、不会乱、不会重”之间,隔着一条巨大的工程鸿沟。
这篇文章想聊的,就是从“服务在线”走到“消息可信”这段路要面对的设计问题。文章不会只讲概念,会按我自己实际经历过、也帮客户排查过的方案,把链路分层、数据一致性、故障演练和一次真实事故的复盘一起写清楚。适合正在做企业IM、内部协作工具、客服系统这类私有化项目的人参考。
1. 把“服务在线”和“消息可信”分开看,是设计的第一步
大多数IM项目在一开始会把可靠性等同于可用性:进程在跑、端口在监听、心跳正常、用户头像是绿的。这些确实属于“服务在线”的范畴,但它回答的只是“能不能连上”,回答不了“消息到底对不对”。
可靠性设计真正要解决的,是“消息可信”。这四个字写下来很短,拆开却不简单:消息发出后对方一定收得到;收到的内容和发送时一致;同一个消息不会出现两遍;多条消息在所有人的时间线上顺序一致;“已读”的状态不会来回跳。
这个区分不是文字游戏,因为两者的技术路线差异非常大。服务在线主要靠健康检查、负载均衡、进程守护、容器重启策略;消息可信则涉及应用层确认、幂等控制、持久化策略、游标同步、状态账本。把这两件事混在一起,最常见的结局是:监控面板一片绿,用户投诉一箩筐。
我一直喜欢用一个比喻:银行网点开门营业,这是“在线”;账目分毫不差、每一笔流水都能对上,这是“可信”。网点开门不等于账目没问题,同理,IM系统在线也不等于消息没丢。
1.1 三个最典型的“在线但不可信”场景
先看三个我在现场碰过的真实场景,这三个场景基本覆盖了“在线但不可信”的典型形态。
第一个,长连接还在,消息却进了死信队列。服务端把消息从队列拿出来转发时遇见一个瞬时错误,没重试成功,消息被扔进死信队列,没有告警。用户A这边显示“已发送”,用户B那边什么也没收到。问运维,所有人第一反应都是“不可能吧,连接不是好好的吗”。
第二个,半开连接。客户端的网络从Wi-Fi切到4G,或者笔记本休眠了一整晚,NAT映射已经失效,但服务端看到的TCP连接还没断开。服务端往这条连接上写数据,内核返回成功,客户端实际一个字节都没收到。这是“向对端应用层投递成功”和“写入内核缓冲区成功”之间的差别,很多团队都会在这一步踩坑。
第三个,重复与乱序。客户端发送时网络抖动,界面卡住,用户手快点了几次。服务端没做幂等,同一个消息被存了三条。还有一个版本:用户的手机时间比服务器快,服务端用了客户端时间做排序,结果后发的消息排到了前面,用户眼中的对话时间线完全错乱。
这三个场景有一个共同点:从服务端进程视角看,一切都是正常的。只有站在客户端、站在用户视角看,才能发现消息并不可信。
1.2 衡量可信度,得把指标从“进程”下沉到“消息”
既然可信度和在线不是一回事,那衡量方式也不能只盯进程。我在项目里会同时盯两组指标。
进程在线指标:网关的连接数、CPU负载、内存占用、磁盘IO、进程存活状态。
消息可信指标:消息端到端送达率、重复投递率、乱序率、已读回执一致率、断线重连后的补齐耗时。
后面这组指标的可观测性要弱得多,因为需要从客户端上报数据,或者靠探针账号在真实环境里跑对账。很多团队并不是不想做,而是压根没把它纳入“可靠性设计”的范畴。所以我的第一个建议是:设计文档里先写清楚“可信”的定义,没有定义,后面全是扯皮。
2. 私有化环境里,哪些隐蔽因素在悄悄破坏可靠性
同样的IM系统,跑在公有云上和自己机房、客户内网里,遇到的问题完全不一样。私有化部署最大的特点就是“环境不可控”,而不可控的环境会在你最不在意的地方给你埋雷。
2.1 网络分区:不是“会不会断”而是“什么时候断”
私有化项目最常见的网络问题不是防火墙把端口挡了,而是专线故障、交换机单点故障、跨机房的丢包。你没法假设内网一定是稳定的,更没法假设两栋楼之间的骨干链路不会断。
一旦发生网络分区,最危险的动作是“两边同时继续写”。如果IM系统的服务端是多节点部署,节点之间无法通信时,两个节点都认为自己还是主节点,都接收写入,等网络恢复后数据合并不了,消息就会错乱。所以私有化IM集群我从来不做双活写,一定走单主模型:同一时刻只有一个节点负责写入,另一个节点热备。选主用租约机制,租约到期后旧主必须停止写入,宁可短暂拒绝服务,也不能两头写。
有不少人觉得IM系统不像数据库,写丢了还能容忍。实际上企业客户对消息的敏感度远高于普通IM,少一条通知、少一条审批消息,都可能直接变成事故。
2.2 存储选型:别把Redis当唯一真相
我见过一个交付架构,消息先写Redis的List,再异步同步到MySQL。设计的人说“Redis快,做写入缓冲,MySQL做持久化”,听着好像没问题,直到某次Redis内存被打满触发淘汰策略,一批list key被清掉了,消息悄无声息地消失。更别说Redis重启、主从切换导致的丢数据。
私有化IM的主存储必须是MySQL或者PostgreSQL这类真正保证持久化的关系型数据库,Redis只能做缓存、队列、临时状态这类辅助角色。核心消息表在事务提交成功后才返回ack给客户端,这个顺序不能反过来。
主库本身的可靠性也要注意。很多私有化项目为了省钱用单机库,磁盘坏道、断电、文件系统损坏都会要命。我的底线是至少一主一备,主库开启半同步复制,innodb_flush_log_at_trx_commit=1、sync_binlog=1,备库不是摆设,要定期做恢复演练。
2.3 单机交付的“方便”和“孤注一掷”
客户预算有限,要求把所有模块塞进一台8C16G的服务器里,这在私有化项目里太常见了。省了一台机器的钱,代价是整个系统的可靠性押在一台机器上:磁盘满、内存溢出、CPU被打满、进程被OOM Killer杀掉,任何一个发生都是全站不可用。
客户嘴上说“我们内部用,要求不高”,但一旦服务不可用,第一个打电话的一定是客户老板。我的建议是最小交付配置也要两台服务器:网关节点至少两个,数据库主备分离,应用服务可以跑在一起,但关键角色不能单点。如果实在只能单机,那就要在方案里写清楚风险,并在运维层面把磁盘、内存、CPU的告警阈值调得足够敏感。
2.4 时钟漂移会引发“时间线倒挂”
IM消息的展示顺序是一个看似简单、实际上很容易翻车的问题。有些系统直接用客户端时间戳排序,结果就是前面说的那个现场:用户A的手机时间比服务器快30秒,用户B的手机时间比服务器慢20秒,A先发了一条消息,B后回了一条,在B的手机上看起来是B的消息在前、A的消息在后。
消息的排序必须用服务端分配的单调递增序号,不能信客户端时间。我一般给消息加两层语义:seq用于排序,服务端统一分配;client_time和server_time只用于展示。客户端展示时按seq排序,seq相同的情况基本只有一种,就是同一条消息的副本,直接按msg_id去重。
2.5 离线用户的积压洪峰
用户出差一周没连网,回来一登录,服务端发现他有五万条未读消息。如果设计成“上线就把所有消息全推给客户端”,轻则手机端卡死,重则推送通道被塞爆,连其他正常在线用户都跟着遭殃。
处理办法是分页拉取加游标。客户端恢复连接后,先同步最近一页消息,用户往上翻时再按游标继续拉。服务端要给每个用户维护一个同步游标,保证客户端每次拿到的是一个自洽的增量切片,而不是盲目补数据。
下面是这类问题的一个汇总,私有化交付评审时我会逐条对着看:
| 隐患 | 影响 | 应对方向 |
|---|---|---|
| 网络分区 | 双主写、消息错乱 | 单主模型 + 租约选主 |
| 存储选型不当 | 消息静默丢失 | 关系型主存储 + 半同步复制 |
| 单机交付 | 单点故障全站不可用 | 最小双节点 + 数据卷持久化 |
| 时钟漂移 | 消息顺序倒挂 | 服务端seq排序 |
| 离线积压 | 拉取超时、通道雪崩 | 分页拉取 + 游标同步 |
3. 消息链路分层:从发送到落库再到送达的可靠性设计
聊完了环境问题,再回到系统内部。我习惯把一条消息的生命周期拆成几个关卡:发送端确认、服务端落库、推送通道、拉取兜底。每一关各管一段,各解决一类问题。
3.1 发送端的“两阶段确认”与重试幂等
客户端点击发送,绝对不能只把消息交给内核Socket就完事。我的设计是两阶段确认:客户端生成一个全局唯一的client_msg_id,把消息发给服务端;服务端落库成功以后,返回服务端分配好的msg_id和seq;客户端收到这个ack以后,才把消息状态从“发送中”改成“已发送”。
如果客户端一直没收到ack怎么办?重发,但重发时带上同一个client_msg_id。服务端收到重复请求时,先查这个client_msg_id是否已经处理过,处理过就直接返回原来的msg_id和seq,不再重复插入。
这个幂等设计对应消息表里的一把唯一键:
CREATE TABLE message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, conversation_id BIGINT NOT NULL, sender_id BIGINT NOT NULL, client_msg_id VARCHAR(64) NOT NULL, seq BIGINT NOT NULL, content BLOB NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_sender_client (sender_id, client_msg_id), KEY idx_conv_seq (conversation_id, seq) ) ENGINE=InnoDB;发送逻辑用“先查再插”或者“插入冲突后查询”都行,关键是不能默认客户端只发一次。网络世界里超时重发是常态,没有幂等设计,重复消息只是迟早的事。
3.2 服务端持久化:落库成功才算“收下”
服务端收到消息后,ack之前必须保证消息已经安全落库。我说安全落库,不是指写进应用进程内存,也不是指写进Redis,而是关系型数据库事务提交成功。如果是集群,还要等备库确认,这就是半同步复制的意义。
这里有个人尽皆知但经常被绕开的点:消息先写Redis、再后台批量刷MySQL,在性能测试中很好看,可一旦Redis重启或者批量任务延迟,消息的真实性和顺序都会打折扣。IM不是统计系统,不能接受“最终可能丢”,所以我在关键链路上宁可慢一点,也要同步提交。
有人会问,同步落库会不会拖慢体验?实测下来,内网环境下MySQL单条插入也就毫秒级,对用户体验的影响几乎可以忽略。真正拖慢系统的,往往是顺序写日志、复杂事务、跨节点调用,这些和“同步落库”不是一回事。
3.3 推送与拉取:Push是优化,Pull是正确性兜底
在线用户的消息投递,第一反应是“服务端通过长连接推给客户端”。这个思路本身没错,但要注意一条铁律:任何一次推送都不能被当作“消息一定到了”。TCP写成功、网关转发成功、应用进程处理成功,这三层之间每一层都可能出问题,尤其是那句“半开连接”。
我的做法是在推送之上强制加应用层ack:客户端收到消息后,必须再发一条msg_ack给服务端。服务端收到msg_ack,才认为这条消息对于这个用户是“已投递”。如果几秒钟内没收到ack,就认为投递失败,转入重推或者等待客户端主动拉取。
同时,客户端重连成功后的第一件事,不是重新渲染最近聊天记录,而是先调用同步接口补数据。同步接口基于游标:给我这个用户当前最大seq,把所有比游标大的消息都拉回来。
def sync(user_id, device_id, cursor, limit=200): max_seq = get_user_max_seq(user_id) messages = load_messages_from_cursor(user_id, cursor, max_seq, limit) return {"messages": messages, "max_seq": max_seq}这就是我一直说的“Push是优化,Pull是正确性兜底”。哪怕推送一张都没有发出去,只要客户端能拉取,消息就不会丢;反过来,只靠推送而没有任何拉取兜底,一旦推送链路出错,丢消息就是必然的,只是时间问题。
3.4 三种游标各管一摊:用户游标、会话游标、设备游标
聊游标的时候很多同事会问:到底维护多少种序号才够?我的经验是三种,各管一摊。
用户级游标,解决“我有哪些新消息”,用于同步拉取。每个用户维护一个全局单调递增的user_seq,客户端同步时只要记住自己拉到哪里,之后的增量就都清楚了。
会话级游标,解决“某个会话内消息怎么排序”。每个会话维护一个conv_seq,发送方在会话内给消息编号,所有接收端按这个序号排序,跟用户级游标互不干扰。
设备级游标,解决“我的手机和电脑各自拉到哪里”。每个设备的进度独立维护。这个尤其重要,后面讲多端同步时还会再提到。
有读扩散和写扩散两种架构,对游标的影响也不同。私有化IM用户量不大,我会直接给每个接收者在user_inbox表里记一条消息投递记录,用户级游标就是这张表的最大行号。虽然会多占一些存储,但换来的是同步逻辑非常简单、问题好排查。对可靠性要求高的私有化项目,简单比性能更值钱。
4. 除了消息本身,会话、未读数与已读回执也要可信
很多团队把可靠性设计停在“消息本身不丢”,但用户感知的不只是消息文本,还有会话列表、未读数、已读回执。这些状态一旦错乱,用户会觉得“这个系统坏了”,哪怕单条消息其实都在。
4.1 会话列表和未读数:可重建的“投影”
会话列表里的最后一条消息预览、最后一条消息时间、未读数,本质上是消息数据的投影。投影的意思就是,它可以通过消息表重新算出来,不应该被当作独立的高可靠状态来维护。
我在设计时会让会话表可以随时从消息表重建,甚至专门写一个补偿任务定期扫描会话表和消息表是否一致。未读数的计算也尽量不依赖“每次收到消息就+1”这种累加方式,尤其不能依赖Redis计数器。更稳的做法是通过公式推导:未读数等于当前会话最大seq减去用户已读游标。
这个设计的好处是,即使Redis计数器因为重启丢了,会话表里的未读数也不会错。真要出现不一致,重跑一次投影计算就能恢复。
4.2 已读回执:单调递增、幂等更新
已读回执不能用“收到已读事件就置位”的粗暴方式,因为客户端的网络请求可能乱序到达。用户先阅读了新消息,产生一个较大的已读seq;之前某个旧的已读请求因为网络延迟后到,如果直接把它写进去,已读位置就会倒退。
我的做法是更新已读游标时只允许单调递增:
UPDATE conversation_user SET read_seq = GREATEST(read_seq, :new_read_seq) WHERE user_id = :user_id AND conversation_id = :conversation_id AND :new_read_seq > read_seq;这样无论客户端发多少条已读请求、顺序如何,服务端的已读状态只增不减。用户看到已读回执只会往前走,不会出现“明明刚读完了,又变回未读”的灵异事件。
4.3 多端同步的“已读回退”陷阱
多端场景是已读回执最容易翻车的地方。比如用户在手机上把某条消息读掉了,电脑上还不知道,此时电脑端的未读角标不能重新把这条消息算成未读。
这里的核心是区分两个状态:设备本地已读到哪,以及服务端已读到哪。设备本地为了渲染进度,可以维护自己的游标;但决定用户未读角标的,是服务端保存的该用户在该会话的已读游标。设备同步时要把服务端的已读游标拉下来,而不是仅仅拿本地消息列表去算。
如果手机离线期间读了很多消息,恢复网络后要先把本地已读状态同步给服务端,再通过服务端把已读变化推送到其他设备。否则就会出现“手机已读、电脑显示未读”的不一致。这个数据流看起来琐碎,但在可靠性设计里非常关键,因为用户对已读状态的信任,和对消息本身不丢的信任,本质上是一回事。
5. 故障演练与“可信度”度量:拿什么证明系统没白设计
一套可靠性设计做完,怎么证明它有效?往上堆文档没用,要拿故障去逼它。
5.1 把故障当成例行演练,而不是年终总结
我会在私有化项目验收前,安排几场雷打不动的故障演练:杀掉网关进程、杀掉主数据库、人为断网30秒、模拟Redis主从切换、让客户端断线重连。每场演练结束以后,检查的不是“系统有没有崩”,而是六件事:消息还能不能发、有没有重复、时间线有没有乱、未读数对不对、已读回执有没有跳变、重连之后消息有没有补齐。
这套检查在开发环境跑一百遍,都不如在客户现场真实测试一遍。因为私有化环境的网络设备、防火墙策略、NAT行为,跟开发环境完全是两个世界。很多问题只有到了现场才会冒头。
5.2 四个关键指标,缺一个都不行
我常给团队立四个核心指标,少了哪个都说明可靠性监控不完整。
可用性指标,看网关健康检查成功率、数据库连接成功率,这是底线。端到端延迟,看一条消息从发送到对方收到的时间分布,重点关注p95。丢失率,靠探针账号互发编号消息,定期统计有没有缺口。乱序率和重复率,靠客户端记录收到的msg_id和seq,检测逆序和重复。
探针这个东西很多团队会忽略。我建议在部署包里内置几个隐形账号,每隔几分钟自动互发一条带编号的消息,客户端和服务端都记录下来,后台定期对账。哪一天丢失率开始往上抬头,探针会先于真实用户发现问题。
5.3 告警要盯“用户能感知的路径”,不只是进程
只盯进程是否存活,是一个很常见但远不够用的告警策略。进程活得好好的,不代表消息链路是通的。
我现在的告警体系会覆盖:推送通道的ack成功率、消息同步接口的平均拉取延迟、未读数重建任务的扫描差异、数据库写延迟、消息队列积压长度。这些指标任何一个异常,都比“某个进程死了”更能代表可靠性出了问题。
5.4 故障预案要具体到“谁、按什么顺序、点哪里”
故障预案不能只写“发现主库宕机后切换备库”,要写到具体命令、具体脚本、具体人。私有化项目现场基本没有专职DBA,出问题的时候运维往往比较慌。我会把预案做成一个“手术清单”:第一步执行什么脚本确认主库状态,第二步修改哪个配置让网关切换到备库,第三步验证哪些探针账号的消息连续,第四步恢复后怎么追平数据。
这些清单平时看起来枯燥,但真到凌晨两点接到客户电话时,它就是救命稻草。
6. 复盘一次真实事故:消息“在线”却不到
最后用一个真实事故收尾。前几年交付到一个客户现场,某部门反馈:A用户在群里发消息,B用户显示在线,但大概20%的消息收不到。最诡异的是,数据库里消息都在,服务端日志也显示已投递,B的账号也确实是连接状态。
6.1 第一轮排查:连接、落库、日志都没问题
我先让运维拉了三块数据:B的长连接状态、A发的消息在数据库里的记录、推送服务的投递日志。结果全对得上:B的连接在,消息在库里,投递日志显示网关已经发出去。
这里很容易得出“系统没问题,是客户端缓存坏了”的结论。但我不信,因为客户端看不到的就是看不到,用户不会撒谎。问题一定藏在某个“看着没问题”的环节里。
6.2 第二轮排查:把“写入Socket”和“客户端收到”分开
我们把B的客户端日志拿回来,发现它根本没有收到那20%的消息。此时网关日志里却显示已经写出去了。这让我想到半开连接。
B用户的环境很典型:电脑长时间不操作后休眠,休眠前Wi-Fi连接还算正常,醒来后网络环境已经变了,路由器和NAT映射都已经不是原来的状态。网关这边连接没有正常断开,于是认为B还在线,但实际网络路径已经断了。
往这个连接上写数据,在TCP层面可能看起来成功了,内核缓冲区把数据“收下”了,但这根本不代表对端应用层收到了。
6.3 根因:只认推送,不认ack,也没有拉取兜底
继续往下挖发现,系统原本是有一套ack设计的,但网关判断“投递成功”用的是“已写入Socket”,不是“客户端已回ack”。两阶段确认只做了一半,等于没做。
更致命的是,客户端重连后只会拉取最近5分钟的消息。而断连时间稍微长一点,重连后无法通过拉取补齐,那20%的消息就永远丢了。
这次事故的根因整理出来就两句话:缺少应用层确认,把“服务端已投递”错当“客户端已收到”;缺少全量同步兜底,让拉取机制覆盖不到断连期间的消息。
6.4 修复与后续:两阶段确认补全,拉取兜底兜满
修复方案没有发明新东西,就是把本该做的做完:客户端收到消息后必须回ack,网关在限定时间内收不到ack就试探连接、确认失败后转入重推;客户端重连后无条件先同步游标增量,再更新界面;再加一个对账任务,每天扫描探针账号的消息,把丢失率和乱序率出成报表。
上线后那20%的丢失率立刻归零。过了一段时间我从客户那边拿到数据,断线重连后的消息补齐时间稳定在10秒以内。
后来我在内部定了一条规矩:任何长连接投递,只要没有对端应用层确认,就不能算成功。服务在线是基础设施,消息可信才是最终目的。这条规矩救了我们很多次,也希望能帮你少踩几个坑。