ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

从服务在线到消息可信:私有化IM可靠性设计实战解析

从服务在线到消息可信:私有化IM可靠性设计实战解析 凌晨两点半手机在床头柜上震得像个疯子。我迷迷糊糊接起来对面是驻场运维的急促声音“客户那边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_commit1、sync_binlog1备库不是摆设要定期做恢复演练。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) ) ENGINEInnoDB;发送逻辑用“先查再插”或者“插入冲突后查询”都行关键是不能默认客户端只发一次。网络世界里超时重发是常态没有幂等设计重复消息只是迟早的事。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, limit200): 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秒以内。后来我在内部定了一条规矩任何长连接投递只要没有对端应用层确认就不能算成功。服务在线是基础设施消息可信才是最终目的。这条规矩救了我们很多次也希望能帮你少踩几个坑。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表