第一版是最朴素的实现:客户端连上来,服务端把 userId 和 Channel 存进一个 ConcurrentHashMap,发消息时查这个 map。本地测试完美,上线后问题一个接一个。
一是连接假死。手机进电梯、切飞行模式、网络从 WiFi 切到蜂窝,TCP 连接在服务端看起来还是 ESTABLISHED,但数据实际发不出去。服务端以为用户在线,消息发给一个死连接,用户什么都收不到,而服务端不认为消息丢了。这类连接还会一直堆积,占着内存和文件描述符。
二是重连风暴。服务端发一次版本,所有连接同时断开,然后所有客户端在同一秒全部重连,瞬时的连接建立请求把新起来的实例又打挂,形成雪崩。
三是心跳一刀切。为了快速发现断连把心跳设成 10 秒一次,结果移动端耗电和流量投诉激增——App 在后台每 10 秒唤醒一次网络,电量掉得很快。
所以三个设计点:心跳要双向且自适应(服务端也要能主动探测,前后台不同频率)、重连要退避加随机抖动(把重连时间打散)、连接的注册与清理必须严格配对(否则路由表会残留脏数据,而路由表是下一个模块的地基)。
IdleStateHandler 检测到长时间没有读事件时先主动发一次探测帧再决定关闭,避免误杀刚好网络卡了一下的连接。kill -9 或者机器宕机时,清理代码压根来不及执行,Redis 里会留下指向已死实例的映射。TTL 让脏映射自动过期,心跳续期保证活连接的映射不过期。没有 TTL 的路由表迟早会烂掉。为什么用 WebSocket 而不是自定义 TCP 协议。自定义协议在流量和灵活性上更优,但WebSocket 能穿透绝大多数代理和防火墙,而国际业务里用户的网络环境极其复杂,很多地区的运营商会拦截非标准端口的长连接。可用性优先于效率,所以选 WebSocket。代价是协议开销略大,通过消息体用二进制序列化来补偿一部分。
单机连接数上不去时,先看内存还是先看 CPU。长连接场景瓶颈几乎总是内存而不是 CPU——每个连接要维持读写缓冲区、会话上下文。所以调优方向是把每连接的内存占用压下来:缓冲区大小按实际消息体调小、会话上下文只存必要字段、用堆外内存承接缓冲。盲目加机器不如先算清单连接内存。
踩过的坑:连接数上到几万后 GC 停顿明显。大量连接对象和缓冲区在堆上,每次 Full GC 停顿几百毫秒,期间所有连接的心跳都收不到,触发大面积误判断连,然后大面积重连,雪上加霜。修法两个:缓冲区改用堆外内存减少堆压力;把断连判定的容忍度调宽(允许错过更多个心跳周期),避免一次 GC 停顿就误杀连接。这个坑很能体现「参数不是孤立的」——GC 表现直接影响心跳阈值该设多少。
踩过的坑二:路由 TTL 设得比心跳间隔短。后台心跳拉长到 3 分钟后,路由 TTL 还是 2 分钟,导致后台用户的映射频繁过期,消息投递时查不到路由,被当成离线用户。修法是 TTL 必须显著大于最大心跳间隔(通常两到三倍)。心跳间隔一改,TTL 必须跟着改,这两个参数是耦合的。
没做的部分:没做多机房的连接就近接入。用户全球分布但网关只在少数机房,远地区用户的连接延迟明显更高。理想方案是就近接入加机房间消息转发,涉及跨机房路由和数据同步,成本较大,当时没做。
单机连接数:用压测工具建立大量 WebSocket 连接并保持心跳,逐步加压看到什么量级出现异常(心跳超时、内存告警、GC 频繁)。必须同时报出堆内存配置、消息收发频率、机器规格——「单机 5 万连接」如果不说这三个就是没有意义的数字,纯空闲连接和每秒都在收发消息的连接,能撑的量差好几倍。
重连恢复时间:客户端埋点,量从「检测到网络恢复」到「重连成功且能正常收发」的耗时。要分场景报:主动切换网络(快,1 到 2 秒)、超时断连后退避重连(慢,取决于当前退避到第几档)。只报快的那个是选择性呈现。
发版冲击:观察发版期间的连接建立速率曲线。「未出现集中冲击」的依据是曲线平缓而不是尖峰,这个用监控截图说明比数字更直观。要能说出分批的批次大小和间隔。
不要报「消息到达率 99.99%」。长连接场景下「到达」的定义本身很模糊(发出去算到达?客户端 ack 算?渲染出来算?),分母也说不清。可以报的是端到端延迟分位数和离线消息补齐的成功情况,这两个口径清晰。
单机的时候发消息很简单:从本地 map 里找到对方的 Channel 写进去。一上集群就全塌了。
一是找不到人。A 连在实例 1,B 连在实例 2。A 发给 B,实例 1 的本地 map 里没有 B,于是认为 B 不在线。集群化之后必须有一个全局的「用户在哪台机器」的路由表。
二是「发出去」不等于「收到了」。前一个模块讲过,写入 socket 成功只说明操作系统缓冲区收下了,用户可能在电梯里什么都没收到。而服务端把这次投递记为成功,这条消息就永久丢了——用户永远不知道有人给他发过消息。私信丢消息是不可接受的。
三是离线用户的消息没有着落。用户根本没连接的时候,消息发给谁?必须存起来,等他上线补给他。
所以三个机制:全局路由表(解决找人)、客户端 ack 作为送达凭证(解决丢消息)、离线消息加序号增量补齐(解决不在线)。
还有一个必须提前想清楚的取舍:可靠投递必然带来重复投递。ack 丢了服务端会重发,用户就收到两条。所以接收端必须做去重——这是「至少一次投递 + 接收端幂等」的经典组合,追求「恰好一次」在分布式下代价极高且没必要。
INCR 按会话 key 自增即可,性能足够。要处理 Redis 丢数据的情况——重启后 seq 回退会造成严重混乱,所以 seq 也要能从数据库的最大值恢复。选择「至少一次 + 接收端去重」而不是追求「恰好一次」。严格的恰好一次需要投递方与接收方做两阶段确认,在移动网络下几乎不可实现(确认包本身也会丢)。接受重复、在接收端用消息 ID 去重,是成本最低且可靠的方案。代价是客户端必须维护本地消息库并建唯一索引,这个成本很小。面试时能说出「我不追求恰好一次以及为什么」,比声称实现了恰好一次可信得多。
路由表放 Redis 而不是注册中心或数据库。路由查询在每条消息的关键路径上,QPS 极高,必须是内存级读取。Redis 合适,而且天然支持 TTL 做兜底清理。代价是 Redis 成了强依赖——它挂了消息就投递不出去。缓解手段是网关本地缓存一份自己持有的连接(同实例内的消息可以不查 Redis),以及 Redis 集群化部署。
踩过的坑:ack 丢失导致消息重复补齐,用户看到大量重复消息。客户端收到了消息但 ack 在回传路上丢了,服务端超时后把消息转离线,重连时又补一遍。当时客户端没有去重,用户会话里出现成对的重复消息。修法就是接收端按消息 ID 去重(本地库唯一索引)。这个坑说明「可靠投递」的完整方案必须包含接收端的幂等,只在服务端做重试是半个方案。
踩过的坑二:seq 用了全局自增 ID,客户端无法判断漏消息。最初的 seq 直接用了消息表的自增主键,同一会话里的 seq 是 1、57、203 这种跳跃的值,客户端拿到 1 和 203 时无法判断中间是不是漏了。修法是改成会话维度自增,让同一会话的 seq 严格连续。「序号必须在什么维度上连续」取决于你要用它做什么判断,这个设计不能随手来。
没做的部分:没做消息的端到端加密。私信的隐私要求下这是应该做的,但会让服务端无法做内容审核(违规内容检测),两者天然冲突。当时业务上选择了可审核,这个取舍要说清是业务决策而非技术做不到。
端到端延迟:发送方发出时打时间戳,接收方渲染时对比本地时间上报差值。跨设备时钟不同步会污染这个数字,所以更可靠的做法是在服务端量两段:发送请求到落库完成、落库到目标实例写出 socket。客户端那一段单独用「同一台设备自己发给自己」的方式量(时钟一致)。压测 3000 条/秒下 P95 约 180ms,要说明是几个网关实例、多少并发会话。
离线补齐完整性:造场景验证——客户端断网,期间发 100 条消息,重连后检查是否收到全部 100 条且无重复、顺序正确。这是个可复现的确定性测试,比统计线上的「消息到达率」有说服力,因为后者的分母口径说不清。
重复投递的量:可以从客户端去重命中次数统计。报这个数字反而增强可信度——它说明你知道重复真实存在并且被正确处理了。声称「没有重复」的人大概率没做 ack 超时重投。
不要报「消息不丢不重」。这是绝对化断言,而且「不重」和「至少一次投递」在语义上就矛盾。正确表述是「不丢(有落库和补齐兜底),可能重复(由接收端去重消化)」。
多端是最容易被低估的复杂度。用户在手机上把消息看完了,切到网页发现还是红点未读,或者反过来——网页看过了手机上还提示。这类问题的投诉量远超预期,因为它每天都在发生。
第一版的实现是逐条标记已读:每条消息一个 is_read 字段,用户看到就更新。三个问题:
一是写放大严重。用户滑过一屏 20 条消息,就是 20 次更新。会话列表一次刷新可能触发几百次写。
二是多端同步逻辑爆炸。每条消息在每个端的已读状态都要单独存,用户三个端就是三倍数据量,而且要处理「手机已读网页未读」这种中间态——但业务上其实不需要区分哪个端读过,只需要「这个用户读到哪了」。
三是未读数算不准。未读数靠 count where is_read = 0 算,会话消息多了这个查询就慢,加缓存又要面对缓存与实际不一致。
关键洞察是:已读不是「逐条的布尔状态」,而是「会话上的一个位点」。用户看消息是从旧到新连续看的,不会跳着看,所以只需要记「读到 seq 多少了」这一个数字。这一个改动同时解决了上面三个问题——写放大(一次会话一个数字)、多端同步(同步一个数字就行)、未读数(最新 seq 减已读 seq,纯计算不查表)。
where read_seq < 新值。多端并发上报、网络乱序导致旧的上报后到,如果无条件覆盖,已读会被拉回去,用户会看到已经读过的消息又变未读。这一条是整个模块最关键的实现细节。last_seq - read_seq 就是未读数。前提是 seq 在会话内严格连续(模块二保证的),如果 seq 有跳跃这个减法就不准。这也是为什么 seq 必须会话维度自增。user_conversation 表里,走同一套增量同步。水位模型的前提是「用户按顺序阅读」,这个前提大部分成立但有例外。如果产品支持「标记某条消息为未读」或者「跳到某条历史消息」,纯水位模型就不够了。我们的处理是不支持逐条标记未读(产品也确认这个功能价值不大),保住模型的简单性。如果业务必须支持,就要在水位之外额外维护一个「例外集合」——这会让复杂度显著上升,要慎重。面试时说清「我的模型依赖什么前提」,比声称模型万能强。
草稿要不要多端同步,我们选了不同步。同步草稿体验更好(手机上打了一半的字网页能接着写),但草稿是高频变更的——用户每敲几个字就要同步一次,写入量和长连接消息量都会大幅上升,收益却很边缘。折中是只在切换会话或退出时保存一次草稿到服务端,不做实时同步。
踩过的坑:已读上报被无条件覆盖,未读数变负数。两个端并发上报,先到的是 seq 150、后到的是 seq 120(网络乱序),无条件覆盖后 read_seq 变成 120,而客户端本地认为已读到 150,界面上出现「未读 -30」。修法就是那条条件更新 where read_seq < 新值。另外未读数在展示层也要做下限保护(小于 0 就显示 0),双重保险。
踩过的坑二:删除会话在多端表现不一致。用户在手机删了会话,网页上还在;而且删除后如果对方又发来消息,手机上会话又出现了(这是对的),但被删除前的历史消息该不该恢复显示,两个端行为不同。修法是明确定义删除语义——我们定的是「删除只是隐藏会话并把已读水位推到最新,历史消息保留,新消息到达时会话重新出现且不显示旧消息」,然后两端严格按这个定义实现。这个坑的本质是产品语义没定清就各自实现,不是技术问题。
没做的部分:没做「对方已读」(回执)功能。它需要把接收方的已读水位反向同步给发送方,技术上不难但涉及隐私(有些用户不希望别人知道自己已读),需要开关和产品设计,当时没做。
多端已读同步延迟:两台设备同时登录同一账号,在 A 上标记已读,量 B 上红点消失的时间。用同一台机器的两个客户端或者时钟同步过的两台设备量,否则时钟偏差会污染结果。约 1 秒上下(主要是长连接推送的延迟)。
会话列表响应体大小:抓包对比全量与增量两种模式的响应字节数。要说清测试条件——会话总数多少、其中变更的有几个。「下降一个数量级」是在「几百个会话、只有几个有变更」这个典型场景下的结果,如果所有会话都变了增量就没有优势。给出条件的数字才可信。
未读数准确性:造场景验证——发 N 条消息、读一部分、撤回一条、断网重连,检查未读数是否符合预期。这类正确性用确定性用例覆盖,不用比率。定时校准发现的偏差记录数可以作为线上指标,用绝对数报。
不要报「已读同步准确率 100%」。异步链路不可能保证,而且这个指标怎么统计说不清。用「偏差由定时校准收敛」加上「校准发现的偏差量级」来描述。
count where is_read = 0,会话消息一多就慢。而水位模型只用一个数字就表达了同样的信息,因为用户阅读消息是从旧到新连续的,不会跳着读——这个业务前提让「读到哪了」这一个位点足够描述全部已读状态。代价是不支持「标记某条为未读」这类跳跃操作,如果产品要这个功能就得在水位之外加例外集合,复杂度会上一个台阶。我们和产品确认过不需要,所以选了简单模型。
read_seq,然后通过长连接把已读事件推给该用户的所有其他在线端——路由表里能查到这个用户还有哪些连接。网页端收到「会话 X 已读到 seq 120」后,本地把该会话未读数清掉并更新自己的水位。如果网页端当时不在线,它下次上线走会话列表增量同步时会拿到最新的 read_seq,同样能正确显示。关键点是已读水位存在服务端而不是各端本地,服务端是唯一真相,各端只是它的缓存。
update ... set read_seq = ? where read_seq < ?。这样无论到达顺序如何,最终水位都是较大的那个,而且晚到的小值不会把水位拉回去。这是必须做的——我们踩过坑,无条件覆盖会导致未读数变负数(客户端本地以为读到 150,服务端被改回 120,两边一减就是负数)。另外展示层要做下限保护,未读数小于 0 一律显示 0,作为双重保险。「只许前进的状态用条件更新」是分布式状态同步的通用手法,不止用在已读上。
last_seq - read_seq 会把撤回的消息也算成未读,用户看到未读 1、点进去发现没有新消息。两种处理:一是单独维护该会话「已撤回且在未读区间内的消息数」,从未读数里减掉,准确但要额外维护;二是接受这个误差,因为撤回本身是低频操作,而且用户点进去就自动清零了,影响很小。我们选了第二种,但在会话列表的摘要上做了处理——如果最新消息被撤回,摘要显示上一条未撤回的消息,避免列表里显示「对方撤回了一条消息」这种无信息量的内容。能说出「这里有误差、误差多大、为什么可以接受」,比假装没问题好。
没有匹配的内容,换个关键词试试。
项目拆解 · 消息与长连接(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据