上一篇把握手流程、协议帧格式、浏览器端WebSocket对象的行为边界拆得比较细,不少人在本地跑通一个 echo demo 之后就停在那里。但从我这些年接触的实时通信项目看,真正让人半夜被叫起来的东西,几乎都不在握手阶段——握手失败至少报错明确,你能看到 400、403、502;而线上那些连接数看着正常、消息却延迟几分钟才到、每隔一阵子集体掉线、内存缓慢爬升的问题,才是长连接真正的成本所在。
这篇接着往下聊实战部分:心跳怎么定、断线怎么重连不把服务端打挂、消息怎么做到不丢不重、鉴权在握手段和连接期间怎么衔接、吞吐上不去时先看哪三处、以及几个典型故障的完整排查链路。里面涉及的参数取值、退避公式、缓冲区阈值,都会给出可直接抄的数值和代码,也会说明这些数字是怎么算出来的。不管你用的是 Node 的ws、Java 的 Spring WebSocket,还是浏览器原生 API,思路都是通用的;面向的是已经能把 WebSocket 连起来、准备把它推到真实用户面前的那批人,纯新手建议先把基础帧格式补一下再读。
1. 握手成功只是入场券:把 WebSocket 当长连接来设计
1.1 从“能连上”到“敢上线”之间隔着哪几件事
很多团队对 WebSocket 的心理预期还停留在“HTTP 之外的一条捷径”,觉得new WebSocket(url)成功触发onopen就等于功能完成了。这是个很典型的认知偏差。HTTP 请求的生命周期是秒级的,一次请求一个连接或复用连接,出问题重试一次就完事;而 WebSocket 是一条可能存活数小时、跨越手机切网、穿过若干中间设备的长连接,它的生命周期里会遇到的状态变化,比短连接多一个数量级。
我通常会把“敢上线”拆成六个必须回答的问题:连接怎么保持活着、断了怎么恢复、恢复之后数据怎么对齐、谁能连上来、连上来之后消息怎么路由、单条连接吃掉多少资源。这六个问题任何一个没有明确答案,上线后都会以某种形式还回来。比如说心跳,不做的话云厂商的负载均衡空闲超时一到就直接掐断,表现就是“用户放着不动十分钟就掉线”;再比如重连,不做退避的话服务端重启时几千个客户端在同一秒涌上来,二次雪崩比第一次还严重。
再往细一点,长连接的资源账也要提前算清楚。一条空闲的 WebSocket 连接在服务端占用的内存,从几十 KB 到几百 KB 不等,取决于运行时、缓冲区大小和是否开启压缩。按 100KB 算,单机 1 万条连接就是 1GB 内存,还没算上应用层为每条连接维护的会话对象、订阅关系、发送队列。很多“压测没问题、上线就 OOM”的案例,根源是把连接当成了零成本对象。所以从设计的第一天起,就应该把“每条连接都是一个有状态的对象,有创建、有维护、有销毁”这件事写进架构文档。
1.2 长连接的三类失效来源:网络、中间设备、服务端自身
排查掉线问题时,我习惯先把失效来源分成三类,因为它们的现象和对策完全不同。
第一类是网络本身。移动端切 Wi-Fi、进电梯、地铁隧道,链路会静默断开。注意“静默”两个字:TCP 不会立刻告诉你对端没了,操作系统的重传要跑完才返回错误,这段时间可能是几十秒到十几分钟。应用层如果只看onclose,就会长时间以为自己还连着。
第二类是中间设备。反向代理、负载均衡、NAT 网关、企业防火墙,这些东西普遍有“空闲连接回收”策略。常见配置是 60 秒或 300 秒没有数据往来就断开,而且是直接 RST,不给任何解释。这类断开的特征是时间点非常规律,每隔固定时长掉一批,而且往往掉的是那些“安静”的连接。
第三类是服务端自身。进程重启、发布滚动更新、GC 长停顿、连接数触顶、文件描述符耗尽。这类断开会集中在某个时间窗口,日志里通常能找到对应的重启记录或告警。
分类的意义在于,对策是分层的:网络问题靠心跳探测 + 客户端重连兜底,中间设备问题靠“心跳间隔小于最严空闲超时”来预防,服务端自身问题靠优雅关闭 + 重连退避来削峰。三者缺一不可,只做其中一项,另外两项照样会让你掉线。
1.3 一上来就该确定的四个基础参数
为了避免后面反复改,项目启动阶段我就会把这四个参数定下来并写进配置中心,而不是散落在代码里。
| 参数 | 典型取值 | 决定它的因素 |
|---|---|---|
| 心跳发送间隔 | 25~30 秒 | 必须小于链路中最严的空闲超时,常见 60 秒,取一半留余量 |
| 心跳响应超时 | 5~10 秒 | 大于一次正常 RTT 的若干倍,弱网下的容错空间 |
| 连续失联判定次数 | 2 次 | 单次抖动不判死,两次连续失败才断开重建 |
| 单连接发送缓冲上限 | 1~4 MB | 单机连接数 × 上限 不能超过可用堆内存的合理比例 |
提示:心跳间隔不是越小越好。5 秒一次的 ping 在百万连接规模下就是每秒 20 万条无用报文,服务端 CPU 和网络都会被无谓消耗掉。25 到 30 秒是大多数场景下“够安全又不浪费”的平衡点。
2. 心跳机制:间隔怎么定、假死怎么判、误杀怎么防
2.1 为什么 TCP 层的 keepalive 一般指望不上
经常有人问:操作系统不是有 TCP keepalive 吗,为什么还要在应用层自己发心跳?原因有两个,一个是默认值不可用,另一个是语义不对。
先说默认值。主流操作系统的 TCP keepalive 默认空闲时间是 7200 秒,也就是两个小时。这个时间对长连接业务来说毫无意义,等它发现连接断了,用户早就投诉了。你可以调小,但调小的作用范围是整台机器或整个进程,会影响所有 TCP 连接,包括数据库连接、内部 RPC,副作用不好控。
再说语义。TCP keepalive 探测的是“链路和内核状态”,它探不明白应用层是不是还活着。举个很常见的场景:服务端进程被挂起了(不是崩溃,是卡住),内核仍然会正常回 ACK,TCP 层看起来活得好好的,但你的业务消息一条都处理不了。这时候只有应用层心跳能发现问题——因为应用层心跳要求对端“回一个业务层面的响应”,进程卡住就回不了。
还有一个更隐蔽的点:中间的代理和 NAT 设备认的是“有没有数据流过”,TCP keepalive 的探测包有些设备是不转发的,或者不刷新 NAT 表项。所以指望它来保活,经常是两头都不讨好。
结论很简单:TCP keepalive 可以作为最后一道保险开着(比如设成 300 秒),但应用层心跳必须自己实现,不能省。
2.2 心跳间隔的计算过程与探针设计
间隔怎么算?倒推最靠谱。先查清楚链路上所有中间设备的空闲超时,取最小值,然后取它的一半甚至三分之一作为心跳间隔。
举个具体的:反向代理的空闲超时设的是 60 秒,负载均衡设的是 900 秒,NAT 网关常见 300 秒,那么最严的是 60 秒。心跳间隔取 25 秒,留出 35 秒的余量,这个余量是为了应对网络抖动导致的单次心跳延迟、以及服务端 GC 停顿造成的响应变慢。如果你的链路里存在 30 秒级的超时设备,那心跳间隔就得压到 10 到 15 秒。
探针本身我倾向于用协议原生的 ping/pong 帧,而不是业务消息。理由是:ping/pong 是控制帧,协议层直接处理,不进入应用的消息队列,不会被业务逻辑的阻塞拖累;而且浏览器端会自动回 pong,客户端代码一行都不用写。但要注意浏览器端有个限制——前端 JS 无法主动发 ping 帧,所以通常是服务端发 ping、客户端协议栈自动回 pong、服务端根据 pong 是否按时到达来判定连接健康。
如果是浏览器端也要主动探测,那就得退而求其次用业务层的“约定消息”,比如客户端每 30 秒发一条{"type":"ping"},服务端回{"type":"pong"}。这种方式有个副作用:这些消息会进入业务消息通道,在消息积压的时候会排在后面,导致心跳延迟,进而误判。所以业务层心跳一定要走独立的处理分支,在消息分发的最前面就拦掉,不能排到业务队列里。
服务端的判定逻辑我一般这么写:
const HEARTBEAT_INTERVAL = 25000; const PONG_TIMEOUT = 8000; const MAX_MISS = 2; function setupHeartbeat(ws) { ws.isAlive = true; ws.missCount = 0; ws.on('pong', () => { ws.isAlive = true; ws.missCount = 0; }); const timer = setInterval(() => { if (ws.isAlive === false) { ws.missCount += 1; if (ws.missCount >= MAX_MISS) { // 先关闭再清理,避免僵尸连接占位 ws.terminate(); return; } } ws.isAlive = false; ws.ping(); // 给 pong 一个明确的等待窗口 setTimeout(() => { if (!ws.isAlive && ws.readyState === ws.OPEN) { // 交给下一轮 missCount 累计,这里只做标记 } }, PONG_TIMEOUT); }, HEARTBEAT_INTERVAL); ws.on('close', () => clearInterval(timer)); }这里MAX_MISS = 2是刻意选的。设成 1 的话,一次网络抖动就会触发断连,用户会频繁感知到“重连中”;设成 2 意味着容忍 50 秒左右的失联,对绝大多数场景够用,同时也不会让僵尸连接存活太久。如果业务对时效性要求极高(比如协同编辑的光标位置),可以配合客户端的主动探测来缩短感知时间,而不是简单把服务端的心跳间隔调到 5 秒。
2.3 心跳和业务消息共用通道时的三个坑
第一个坑是心跳被大消息堵住。如果一条连接正在推送一个 2MB 的图片或者一段长文本,而发送队列是串行的,那么心跳帧会排在它后面。服务端的 pong 回应同样排在该连接的发消息队列里,一旦大消息传输超过响应超时窗口,就会被误判为死连接。解决思路是给控制帧优先级,或者在应用层发送大消息时主动延长该连接的超时判定。
第二个坑是移动端后台冻结。iOS 和安卓在 App 切到后台一段时间后会挂起 JS 执行或冻结定时器,心跳自然就断了。这时候会出现“用户切回来,连接已死但要等半分钟才发现”的体验问题。常见的处理是在页面visibilitychange事件里主动触发一次探测,切回前台立刻验证连接活性,不通就直接重建,不等心跳超时。
第三个坑是服务端 GC 停顿导致的批量误杀。如果服务端做的是全量扫描式的心跳检测(每 25 秒扫一遍所有连接发 ping),一次 3 秒的 GC 停顿就会让所有 pong 响应集中在同一时刻返回,随后的扫描又全部集中在同一时刻发出,形成周期性的毛刺。把扫描时间打散是必要的:可以按连接建立时间分片,或者用时间轮来调度,让心跳发送均匀分布在 25 秒的时间窗口里,而不是齐刷刷一波。
注意:判定连接死亡之后,一定要先把
readyState改成 CLOSING 并从业务侧的连接注册表中摘除,再去做清理动作。我见过不止一次因为清理逻辑里还要发一条“下线通知”,结果发到了已经死掉的 socket 上,报错又把清理流程中断,最后连接一直挂在注册表里,连接数只涨不跌。
3. 断线重连:退避、抖动与重连风暴的防治
3.1 固定间隔重连为什么会把服务端打挂
最朴素的写法是setInterval(connect, 3000),连接断了就每 3 秒重试一次。单个客户端这么干没问题,问题在于它是“同步”的——所有客户端的心跳节奏、掉线时间高度相关(因为它们连的是同一台服务端,同一时刻断的),重连时刻也高度相关。
设想一个中等规模的场景:5000 个客户端,服务端做一次滚动发布,重启耗时 5 秒。这 5000 个客户端几乎同时断开,然后每 3 秒集体重连一次。第一次重连时服务端还没起来,全部失败;第 3 秒第二次、第 6 秒第三次……服务端一启动就要在一瞬间处理 5000 个握手请求,加上 TLS 协商、鉴权查询,很可能直接被压垮、或者触发限流、或者把所有 CPU 吃满导致已经连上的连接也开始超时。这就是典型的二次雪崩。
更糟的是违约重试:某些客户端还会在onclose里立刻重连,形成同步循环。服务端还没准备好,客户端已经把自己 CPU 跑满了。
我经历过的真实情况是:服务端只是重启了 8 秒,结果业务整整抖了 6 分钟才恢复,原因就是重连风暴把连接池和数据库打满了。所以退避不是优化项,是必需品。
3.2 带抖动的指数退避:公式与实现
标准做法是“指数退避 + 随机抖动”。公式长这样:
delay = min(cap, base * 2^attempt) * (0.5 + Math.random() * 0.5)base取 1000 毫秒,cap取 30000 毫秒,attempt 是连续失败次数。展开就是:
| 失败次数 | 理论上限 | 实际区间(含抖动) |
|---|---|---|
| 1 | 2 秒 | 1.0 ~ 2.0 秒 |
| 2 | 4 秒 | 2.0 ~ 4.0 秒 |
| 3 | 8 秒 | 4.0 ~ 8.0 秒 |
| 4 | 16 秒 | 8.0 ~ 16 秒 |
| 5 及以上 | 30 秒(封顶) | 15 ~ 30 秒 |
抖动的系数取0.5 + random * 0.5而不是random,是为了保证下限不会太低——如果抖动范围是 0 到上限,可能出现一堆客户端在接近 0 的时刻重连,反而又聚成一团。用一半到全部这个区间,既能打散,又不至于让某些幸运客户端过早重试。
客户端的实现大致是这样:
class ReconnectingSocket { constructor(url) { this.url = url; this.attempt = 0; this.manualClose = false; this.connect(); } connect() { this.ws = new WebSocket(this.url); this.ws.onopen = () => { this.attempt = 0; // 连接成功即重置计数 this.startPing(); }; this.ws.onclose = (evt) => { this.stopPing(); if (this.manualClose) return; // 4001 这类业务自定义关闭码表示不可重试 if (evt.code === 4001 || evt.code === 4003) return; this.scheduleReconnect(); }; this.ws.onerror = () => { // onerror 之后必然跟 onclose,这里不重复调度 }; } scheduleReconnect() { this.attempt += 1; const base = 1000; const cap = 30000; const raw = Math.min(cap, base * Math.pow(2, this.attempt)); const delay = raw * (0.5 + Math.random() * 0.5); clearTimeout(this.timer); this.timer = setTimeout(() => this.connect(), delay); } close() { this.manualClose = true; clearTimeout(this.timer); this.ws && this.ws.close(1000, 'client close'); } }几个细节值得单独说。第一,onopen里必须把attempt清零,否则一个运行了一天的页面,中间偶发掉线几次之后,重连间隔会一直卡在 30 秒上限,用户会觉得“怎么这么慢才连回来”。第二,必须区分“主动关闭”和“被动断开”,主动关闭不能触发重连,否则用户点了退出登录,客户端还在后台反复连。
第三,要处理业务层的不可重试错误。比如鉴权失败(令牌失效)、账号被封禁、被服务端主动踢下线,这些情况下重连没有任何意义,只会白白消耗服务端资源。做法是约定一组自定义关闭码,比如 4001 表示令牌无效、4003 表示被顶号,客户端收到这些码就不再重连,转而跳登录页或提示用户。标准关闭码 1000-1015 里没有这些语义,1000 到 2999 是协议保留的,3000 到 4999 可以自定义使用,这是协议本身留的口子,别浪费。
3.3 重连成功之后的会话恢复:补消息与去重
连回来了不等于恢复了。断开的那段时间里,服务端可能推送过消息,客户端也可能有未发出的消息,这部分数据怎么对齐,是比重连本身更复杂的问题。
我通常用的方案是“客户端维护一个已收到的最大序号,重连时带上它,服务端补发序号之后的消息”。具体流程:每条业务消息带一个单调递增的seq,客户端记录lastSeq;重连成功后第一条消息就是{"type":"resume","lastSeq": 1024};服务端从消息缓冲(比如 Redis 的 stream 或者按会话存的环形缓冲)里取出 1025 之后的消息推回去。
这里有几个必须设上限的地方。补发窗口不能无限大,一般限制在最近 N 条(比如 1000 条)或者最近 M 分钟(比如 5 分钟)。如果一个客户端离线了两小时再回来,你要么给它全量拉一遍(那就不该走补发通道,应该走一次 HTTP 全量同步),要么告诉它“偏移太旧,请重新拉全量”。我做过的项目里,超过窗口的情况直接返回一个“需要重新同步”的标记,客户端走一次初始化接口,比硬塞几万条补发消息靠谱得多。
去重同样重要。因为重连存在“客户端以为自己没收到、实际上收到了”的情况(比如消息已经到了但客户端在处理前崩溃),补发就会产生重复。解决方式是让消息带唯一 ID,客户端维护一个近期收到 ID 的集合(有界,比如最近 500 个),收到消息先查重再处理。这个集合不能无限增长,用环形缓冲或者带 TTL 的 Map 都行。
提示:如果业务对顺序敏感(聊天记录、协作编辑),补发必须严格按
seq升序;如果不敏感(在线状态、通知),可以并发推送,但每条仍然要带序号以便客户端排序展示。
4. 消息可靠性:从“发出去”到“确认收到”的完整链路
4.1 WebSocket 本身不保证你的业务消息不丢
这是最容易误解的地方。WebSocket 建立在 TCP 之上,TCP 保证的是“字节流有序到达”,但它保证不了“应用层处理了”。一次send调用成功,只代表数据交给了本地内核缓冲区,中间可能经历:发送方缓冲溢出被丢弃(有些实现会直接抛异常,有些会静默截断)、连接在传输中断开、对端收到但进程崩溃没来得及处理。这些环节里任何一个出问题,消息就没了。
我一般会跟团队讲清楚这个分层:传输层可靠不等于业务层可靠。要业务层可靠,必须自己加确认机制。至于加不加,取决于业务对丢消息的容忍度——在线状态推送丢一条无所谓,订单状态变更丢一条就是事故。
判断标准很简单:如果这条消息丢了,用户会不会打电话来投诉?会,就得加。不会,就可以省。不要无脑给所有消息都上 ACK,那会把吞吐量砍掉一大截。
4.2 序号、ACK 与重发队列的三件套设计
需要可靠传递的消息,我会用这套结构:
发送端给每条消息分配一个会话内单调递增的msgId,把消息存入“待确认队列”,然后发送,同时启动一个超时定时器。接收端收到后,回一条{"type":"ack","msgId": 123}。发送端收到 ack 就把消息从待确认队列里移除并清掉定时器;超时没收到 ack 就从队列里取出来重发,重发次数超过阈值(比如 3 次)就判定失败,触发上层的错误处理(提示用户、转 HTTP 补发、或者标记为失败态)。
const pending = new Map(); // msgId -> { payload, timer, retries } let nextId = 1; function sendReliable(ws, payload) { const msgId = nextId++; const packet = { ...payload, msgId }; const attempt = () => { if (ws.readyState !== WebSocket.OPEN) return; ws.send(JSON.stringify(packet)); const rec = pending.get(msgId); rec.retries += 1; if (rec.retries > 3) { pending.delete(msgId); onSendFailed(packet); return; } rec.timer = setTimeout(attempt, 2000 * rec.retries); // 逐次拉长 }; pending.set(msgId, { payload: packet, retries: 0, timer: null }); attempt(); } function onAck(msgId) { const rec = pending.get(msgId); if (!rec) return; // 重复 ack,忽略 clearTimeout(rec.timer); pending.delete(msgId); }这里的超时时间我用了2000 * retries,也就是依次 2 秒、4 秒、6 秒。为什么不用固定 2 秒?因为网络抖动的持续时间通常不止一个 RTT,固定间隔重发大概率在还没恢复的时候就重试完了,白白浪费三次机会。逐次拉长能给链路更多恢复时间,同时也不会让整体延迟失控。
待确认队列必须有容量上限。比如限制 200 条,超了就拒绝新的可靠消息并给上层报错,而不是无限制增长。没有上限的待确认队列,在连接假死期间会无限堆积,最后把客户端内存吃光——这比丢消息还糟。
4.3 乱序、重复与幂等:接收端要做的三件事
发送端做了 ACK,接收端不能只负责“收到就回 ACK”,还得做三件事。
第一是排序。如果允许并发发送(多条可靠消息同时在途),到达顺序未必和 msgId 顺序一致。接收端可以按 msgId 排序后再交付业务,也可以让业务层自己处理。我倾向于在协议层就排好序,业务层拿到的永远是有序的,这样业务代码简单得多。
第二是去重。重发必然导致重复。接收端维护一个“已处理 msgId 的窗口”(比如记录最大连续 msgId,加上一个窗口内已到达的集合),收到重复的直接回 ACK 但不再交付业务。注意回 ACK 这个动作必须有,否则发送端会一直重发。
第三是幂等。有些场景下协议层的去重覆盖不到,比如服务重启后内存里的 msgId 记录丢了,或者跨连接的重复投递。这时候就要求业务处理本身幂等——比如“把订单状态设为已支付”这种操作天然幂等,但“余额增加 100”就不幂等。对于不幂等的操作,一定要在服务端用业务唯一键(订单号 + 状态版本)做一次校验,而不是指望客户端不重复发。
注意:别把 msgId 只放在 JSON 的 body 里而不做校验。我见过客户端发来的 msgId 是字符串 “123” 而服务端按数字比较,导致去重完全失效,重发消息被处理了三遍。类型强校验在协议层就要做。
5. 鉴权与连接生命周期:握手段、连接期、下线时的处理
5.1 握手阶段的鉴权方案与各自的取舍
WebSocket 握手本身是一个 HTTP Upgrade 请求,所以鉴权方式本质上受限于“HTTP 请求能带什么”。浏览器端的WebSocket构造函数只接受 URL,不支持自定义 Header,这是最硬的约束,也决定了方案选择。
常见的三种做法,各有各的问题:
第一种是把令牌放 URL 查询串,wss://host/ws?token=xxx。优点是浏览器端零障碍,缺点是令牌会进访问日志、代理日志、浏览器历史,泄露面大。缓解办法是使用短时效的一次性票据(专门为建立连接签发,有效期 30 秒,用过即失效),而不是长期有效的令牌。
第二种是用 Cookie。同域场景下浏览器会自动带上,服务端在 Upgrade 请求里读 Cookie 校验。优点是令牌不出现在 URL 里,缺点是需要处理 CSRF 相关的问题(虽然 WebSocket 不受同源策略限制,但握手是普通 HTTP 请求,跨站发起握手是可能的),以及对跨域部署不友好。
第三种是先走一次 HTTP 拿票据,再用票据建连。这是我目前最推荐的:客户端先调用一个普通的 HTTPS 接口(带正常鉴权),拿到一个一次性、短时效、绑定用户 ID 的票据;然后wss://host/ws?ticket=xxx建连;服务端校验票据并立即作废。这样即使票据泄露,窗口期也只有几十秒且只能用一次。
握手阶段的拒绝要干脆:鉴权失败直接返回 HTTP 401 并拒绝 Upgrade,不要在升级成功之后再发一条“你无权连接”的消息然后关闭——后者会让客户端看到一次“连接成功后又断开”,触发重连逻辑,形成无效循环。
5.2 连接期间的令牌过期:不能只靠握手时验一次
长连接可能存活几小时,而访问令牌的有效期通常只有 15 分钟到 2 小时。握手时验过不等于连接期间一直合法。这是个很实际的坑:用户上午登录,下午还在页面上,令牌早就过期了,但连接还开着,所有消息照常收发,权限校验形同虚设。
处理方式有两种。一种是连接时把令牌的过期时间记下来,在过期前主动推送一条“令牌即将过期,请刷新”的通知,客户端刷新后通过一条业务消息把新令牌发上来,服务端更新该连接的鉴权上下文。如果客户端没在宽限期内刷新,服务端主动关闭连接并给一个特定的关闭码,客户端收到后走刷新 + 重连流程。
另一种是服务端在每条业务消息处理时都校验一次当前连接的权限快照。这种方式更严格,但要注意别每次都去查数据库——应该在连接建立时把用户的权限信息缓存到连接的上下文里,只在收到“权限变更”事件时才刷新,避免每条消息一次查询把数据库打爆。
还有一类容易忽略的:用户被管理员踢下线、或者账号在另一台设备登录。这时候需要服务端能主动断开指定用户的连接。这就要求有一个“用户 → 连接列表”的反向索引,通常用共享存储(比如集中式缓存)维护userId -> [connId...],踢人时按这个索引找到所有连接并逐个关闭。没有这个索引,你就只能靠令牌过期自然淘汰,无法做到实时下线。
5.3 连接注册表与集群下的消息路由
单机部署时,连接注册表就是一个内存里的 Map,简单直接。一旦上了多实例,问题就来了:用户 A 连在实例 1,用户 B 连在实例 2,A 给 B 发消息,实例 1 上找不到 B 的连接。
解决方案有两层。第一层是路由:给每条连接分配全局唯一的连接 ID,在共享存储里维护userId -> instanceId的映射,发消息时先查出目标在哪个实例,再通过实例间的通道(消息队列、内部 RPC、或者共享缓存的发布订阅)转发过去。第二层是广播:对于群组消息这种“一对多”的场景,用发布订阅模型更合适——每个实例订阅自己关心的频道,收到消息后推给本实例上的相关连接。
这里有个容量问题要提前想清楚:把userId -> instanceId放在集中式缓存里,意味着每次消息路由都要一次网络往返。消息量大时会成为瓶颈。优化方式是在本实例内存里缓存一层“最近联系过的人”,命中就不查缓存,未命中再回源。缓存要设 TTL,否则用户重连到别的实例后,旧映射会一直生效,消息发到错误的实例上——这是最难查的 bug 之一,现象是“偶尔有人的消息发不出去,重连一次就好了”。
提示:实例优雅下线时,一定要先从路由表中摘除自己,再停止接收新连接,最后给现存连接发送一个“服务重启,请重连”的关闭码。顺序反了就会出现:实例已经关了,路由表里还指着它,消息发过去全部失败。
6. 二进制、压缩与背压:吞吐卡住时先看这三处
6.1 文本协议与二进制协议的取舍
JSON over WebSocket 是最省事的做法,可读性好、调试方便、各语言生态无缝。但它的开销是实打实的:一个字段名"messageType"占 13 个字节,实际值可能只有 1 个字节;数字 12345 用 JSON 表示是 5 个字节,用 32 位整数是 4 个字节(其实差别不大),但加上引号、逗号、括号,整体膨胀率经常到 3 到 5 倍。
什么情况下该换二进制?我的判断标准是:单条消息超过 1KB 且频率高,或者消息里大量是数值型数组(比如坐标、采样点、传感器读数)。这两种情况下文本协议的开销会非常明显。
二进制的选型上,Protobuf 是常见选择,体积小、有 schema、跨语言。但要注意一个细节:Protobuf 需要客户端也接入解析,如果你的客户端是浏览器且团队没有现成的构建链,引入成本不低。另一种更轻量的做法是自定义的二进制布局——固定头 + 变长体,头部用 4 个字节放类型和长度,体部直接放原始字节。这种方式解析极快,但完全没有 schema 校验,字段一多变起来就难受,只适合结构极其稳定的场景。
还有一类是“混用”:控制消息用 JSON(连接管理、心跳、错误),数据消息用二进制。这样调试期可以只抓控制通道,数据通道走二进制拿性能。我在实际的音视频和协同编辑项目里都用过这种混合方案,效果不错。
6.2 permessage-deflate:省带宽的代价是 CPU 和内存
permessage-deflate是 WebSocket 的扩展,对每条消息做 deflate 压缩。文本消息(尤其是 JSON)压缩比经常能到 3 到 5 倍。开不开?要看你的瓶颈在哪。
省的地方很明确:带宽和发送时延。JSON 消息压缩后体积大幅下降,弱网下的传输时间随之减少。
代价也不小。第一是 CPU,压缩解压都要算力,消息量大时这部分开销可观,我见过开了压缩之后单机 CPU 从 40% 涨到 70% 的情况。第二是内存,压缩上下文需要为每条连接维护一个 zlib 状态对象,这个对象不小(几十 KB 级别),连接数一多就是几百 MB 的额外开销。第三是延迟,压缩是有缓冲的,小消息可能不会立刻发出,而是等攒够或者等到 flush 时机,这对低延迟场景不友好。
业界有个共识性的经验:合理配置下permessage-deflate能省下六七成的传输量,但内存开销会显著增加。所以我的建议是——
| 场景 | 是否开启 | 说明 |
|---|---|---|
| 消息小、频率高、延迟敏感 | 不开 | 压缩收益低,延迟代价高 |
| 消息大(>4KB)、带宽受限 | 开 | 收益明显,比如日志推送、大表格同步 |
| 已经用二进制协议 | 一般不开 | 二进制本身已经紧凑,二次压缩收益有限 |
| 连接数极多(10 万+) | 慎重 | 上下文内存开销会很明显 |
还有一点:如果开启压缩,要限制解压后的最大消息尺寸,防止压缩炸弹(一条很小的压缩数据解压出几十 MB)。多数服务端库都有maxPayload之类的配置,一定要设,别留默认的无限制。
6.3 背压:发送缓冲区堆积怎么发现、怎么处理
背压是长连接里最容易被忽略、后果最严重的问题之一。它的本质是“生产速度大于消费速度”——你的服务端拼命往一条慢客户端推消息,消息进了内核缓冲区,缓冲区满了之后要么阻塞写入,要么在用户态排队,队列越堆越长,内存一直涨。
怎么发现?看两个指标就够了:单连接的发送缓冲字节数,和待发送队列的长度。Node 的ws有ws.bufferedAmount,底层的 socket 有writableLength;Java 的容器一般有getSendBufferSize或者 session 的待写队列长度。给这两个值设阈值告警,比如单连接超过 1MB 就记一条日志,超过 4MB 就采取动作。
动作怎么做?分三级。第一级是限流:对这条连接降低推送频率,比如把实时推送降级为批量推送。第二级是丢弃:如果是可以丢的消息类型(在线状态、光标位置这类),直接丢掉旧的,只保留最新的。第三级是断开:如果缓冲区持续超过上限(比如 10MB 且持续 30 秒),说明这个客户端已经不可救了(弱网、卡死、假死),主动断开让它重连,重连后走补发流程,比让它一直占着内存强。
const MAX_BUFFER = 4 * 1024 * 1024; function safeSend(ws, data, droppable) { if (ws.bufferedAmount > MAX_BUFFER) { if (droppable) return; // 可丢消息,直接放弃 ws.close(1013, 'slow consumer'); // 不可丢且严重积压,断开 return; } ws.send(data); }这里用 1013(Try Again Later)这个关闭码,语义上比较贴切。客户端收到后应该走退避重连,然后通过补发把积压的消息拉回来。
需要强调的是:慢客户端的存在是必然的,不是异常。一场直播里总有几个网络差的用户,如果服务端的推送逻辑是“每个用户都同步推、不考虑缓冲”,那么这几个用户就能拖垮整个推送循环,甚至影响其他用户。所以推送一定要做成异步的、有队列上限的、可按客户端降级的。
7. 生产排查实录:三个典型故障的完整定位链路
7.1 连接数正常但消息延迟飙升:从指标倒推到根因
现象:连接数曲线平稳,CPU 不高,但这部分用户的收消息延迟从正常的几十毫秒涨到了 30 秒以上,且是间歇性的。
第一步先看是“所有用户”还是“部分用户”。按连接维度统计延迟分布,发现是部分用户,而且集中在几个实例上。这就排除了全局性的网络问题。
第二步看这几个实例的共性。日志里发现它们都对应同一批订阅关系——也就是几个大的群组频道。再查消息量,发现这几个频道在特定时段消息量陡增(一个群组里几十个人同时在线,频繁互发)。
第三步看队列。这几个实例的发送队列长度在高峰期涨到几万条,而且队列消费是串行的单线程循环。根因找到了:推送循环是for (msg of queue) { for (conn of subscribers) { conn.send(msg) } },一个消息要同步推给几千个连接,推送完才处理下一条。慢连接的存在让整个循环被拖住。
第四步验证。把推送改成异步 + 按连接设缓冲上限 + 慢连接降级后,延迟回落到正常水平,队列长度峰值从几万降到几百。
这个案例的教训是:推送循环的复杂度是 O(消息数 × 订阅者数),在大群里这个乘积很容易爆炸。要么做批量合并(把同一时刻的多条消息合并成一次推送),要么做扇出异步化,要么按连接去重。
7.2 每隔固定时间批量掉线:中间设备超时的识别方法
现象:连接不是随机掉,而是每隔大约 5 分钟掉一批,比例不大但持续不断。
第一步看时间间隔。5 分钟这个数字太规律了,基本可以确定是某个设备的空闲回收。统计一批掉线连接的“最后一次数据往来时间”,发现都恰好是断开前 300 秒。这个数字极有可能是某个中间层的空闲超时。
第二步定位是哪一层。在客户端和服务端分别打点记录连接建立和断开的时刻,同时在中间设备(反向代理)的日志里找对应记录。代理日志显示是它主动断开的,说明代理的空闲超时被配成了 300 秒。
第三步确认心跳是否生效。查代码发现心跳间隔配的是 600 秒——比代理超时还长,等于完全没起作用。这就是根因:心跳间隔大于中间设备的空闲超时,连接在心跳发出前就被回收了。
第四步修复。把心跳间隔改成 120 秒(小于 300 秒的一半),同时把代理的空闲超时调到 900 秒作为双保险。改完之后掉线现象消失。
这个排查看起来简单,但很多人第一步就卡住——因为他们没有记录“最后一次数据往来时间”这个指标。所以监控里一定要有“距上次收发数据的秒数”这个维度,它能直接暴露心跳是否在正常工作。
7.3 压测通过但上线后内存缓慢上涨:连接泄漏的查找过程
现象:压测跑满 1 万连接,内存稳定;真实上线后内存每小时涨几百 MB,两天后 OOM。
第一步看堆快照。对比两个时间点的堆转储,发现连接对象数量在持续增长,关掉的连接没有被回收。
第二步查引用链。发现这些死连接被注册表里的 Map 强引用着,而这个 Map 的清理逻辑写在onclose回调里。问题来了:有些连接根本没触发onclose。
第三步找“为什么没触发”。查下来有两类:一类是被中间设备直接 RST 的,这类会触发onclose;另一类是客户端进程被杀(比如 App 被杀、浏览器关闭),服务端的内核 socket 还在 FIN_WAIT 状态,因为在没有数据往来的情况下,服务端要等到下一次发数据失败才会知道对端没了。
第四步修复。双管齐下:一是把心跳检测加强到 25 秒间隔、连续两次失联即terminate(注意是terminate而不是close,绕过关闭握手直接销毁 socket);二是给注册表里的每个连接记录建立时间,加一个定期扫描,把超过“心跳失联判定时间”还没更新的条目强制清理。改完之后内存曲线平稳。
这件事让我形成了一个习惯:任何“靠回调来清理资源”的设计,都必须配一个“兜底扫描”。回调可能因为各种原因不执行,兜底扫描是最后的安全网。
8. 上线前值得逐条打勾的检查项
把上面这些散落的点收敛成一份可执行的清单,每次新项目上线前我都会过一遍。它不复杂,但漏掉哪一项都可能在某个场景下变成事故。
| 检查项 | 通过标准 | 常见遗漏 |
|---|---|---|
| 心跳间隔 | 小于链路最严空闲超时的一半 | 只按服务端配置定,没查中间设备 |
| 失联判定 | 连续 2 次失败才断,避免抖动误杀 | 设成 1 次,弱网下频繁重连 |
| 重连退避 | 指数退避 + 抖动,有上限 | 固定间隔,服务重启时形成风暴 |
| 不可重试错误 | 有专属关闭码,客户端识别后不再重连 | 鉴权失败也疯狂重连 |
| 消息补发 | 有窗口上限,超窗口走全量同步 | 无限补发,离线用户回来打爆内存 |
| 消息去重 | 接收端有 ID 去重窗口,业务操作幂等 | 只靠客户端不重发,实际做不到 |
| 鉴权时效 | 连接期校验过期,或推送刷新通知 | 只在握手时验一次 |
| 反向索引 | 支持按用户 ID 主动踢线 | 只能等连接自然过期 |
| 发送缓冲上限 | 单连接有阈值,慢客户端可降级可断开 | 无限制堆积 |
| 资源兜底清理 | 有定时扫描,不依赖回调 | 回调不执行导致连接泄漏 |
| 压缩开关 | 按消息大小和连接数决策,限制解压尺寸 | 默认开启,CPU 和内存双爆 |
| 优雅下线 | 先从路由摘除,再通知客户端重连 | 顺序反了导致消息投递失败 |
这份表里我最想强调的是最后两条。优雅下线的顺序问题,几乎每个团队第一次上多实例时都会踩;压缩开关的默认值问题,则是那种“本地测试一切正常、上线后 CPU 打满”的经典来源。我给的建议很朴素:压缩默认关,需要时按场景单独开;下线流程写成脚本固化成 SOP,别靠人记。
再补充一个不在表里但同样重要的东西——可观测性。长连接系统的排查极度依赖指标,至少要埋这几项:当前连接数(按实例、按用户分群)、连接存活时长分布、心跳失联次数、重连次数、单连接发送缓冲字节数、消息投递延迟分位数(P50/P95/P99)。没有这些数,前面那三段排查实录里的任何一步你都走不下去,只能靠猜。
最后分享一个小技巧:在客户端埋一个“连接质量自检”,每次重连成功后上报一次(包含上次连接存活时长、断开原因码、重连尝试次数)。这批数据积累一两周,你就能清楚地看到真实用户的连接质量分布,而不是靠自己在办公室开着页面猜。我靠这个数据发现过一次区域性的大面积掉线,原因是某个地区的运营商对长连接有额外的空闲回收策略,光看服务端日志完全看不出来。
这类问题没有一劳永逸的方案,链路在变、客户端环境在变、用户规模在变,能做的就是把这些机制做扎实,把指标埋全,然后在下一次出问题时能快速定位而不是抓瞎。我自己踩过的坑里,绝大多数都不是什么高深的技术问题,而是“少了一个上限”“漏了一层校验”“顺序反了一步”这种细节。