资讯动态

WebSocket长连接实战:心跳重连、消息可靠性与背压排查

发布时间:2026/10/26 11:59:29 来源:尧图企业网站定制
上一篇把握手流程、协议帧格式、浏览器端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 探测的是“链路和内核状态”它探不明白应用层是不是还活着。举个很常见的场景服务端进程被挂起了不是崩溃是卡住内核仍然会正常回 ACKTCP 层看起来活得好好的但你的业务消息一条都处理不了。这时候只有应用层心跳能发现问题——因为应用层心跳要求对端“回一个业务层面的响应”进程卡住就回不了。还有一个更隐蔽的点中间的代理和 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 是连续失败次数。展开就是失败次数理论上限实际区间含抖动12 秒1.0 ~ 2.0 秒24 秒2.0 ~ 4.0 秒38 秒4.0 ~ 8.0 秒416 秒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?tokenxxx。优点是浏览器端零障碍缺点是令牌会进访问日志、代理日志、浏览器历史泄露面大。缓解办法是使用短时效的一次性票据专门为建立连接签发有效期 30 秒用过即失效而不是长期有效的令牌。第二种是用 Cookie。同域场景下浏览器会自动带上服务端在 Upgrade 请求里读 Cookie 校验。优点是令牌不出现在 URL 里缺点是需要处理 CSRF 相关的问题虽然 WebSocket 不受同源策略限制但握手是普通 HTTP 请求跨站发起握手是可能的以及对跨域部署不友好。第三种是先走一次 HTTP 拿票据再用票据建连。这是我目前最推荐的客户端先调用一个普通的 HTTPS 接口带正常鉴权拿到一个一次性、短时效、绑定用户 ID 的票据然后wss://host/ws?ticketxxx建连服务端校验票据并立即作废。这样即使票据泄露窗口期也只有几十秒且只能用一次。握手阶段的拒绝要干脆鉴权失败直接返回 HTTP 401 并拒绝 Upgrade不要在升级成功之后再发一条“你无权连接”的消息然后关闭——后者会让客户端看到一次“连接成功后又断开”触发重连逻辑形成无效循环。5.2 连接期间的令牌过期不能只靠握手时验一次长连接可能存活几小时而访问令牌的有效期通常只有 15 分钟到 2 小时。握手时验过不等于连接期间一直合法。这是个很实际的坑用户上午登录下午还在页面上令牌早就过期了但连接还开着所有消息照常收发权限校验形同虚设。处理方式有两种。一种是连接时把令牌的过期时间记下来在过期前主动推送一条“令牌即将过期请刷新”的通知客户端刷新后通过一条业务消息把新令牌发上来服务端更新该连接的鉴权上下文。如果客户端没在宽限期内刷新服务端主动关闭连接并给一个特定的关闭码客户端收到后走刷新 重连流程。另一种是服务端在每条业务消息处理时都校验一次当前连接的权限快照。这种方式更严格但要注意别每次都去查数据库——应该在连接建立时把用户的权限信息缓存到连接的上下文里只在收到“权限变更”事件时才刷新避免每条消息一次查询把数据库打爆。还有一类容易忽略的用户被管理员踢下线、或者账号在另一台设备登录。这时候需要服务端能主动断开指定用户的连接。这就要求有一个“用户 → 连接列表”的反向索引通常用共享存储比如集中式缓存维护userId - [connId...]踢人时按这个索引找到所有连接并逐个关闭。没有这个索引你就只能靠令牌过期自然淘汰无法做到实时下线。5.3 连接注册表与集群下的消息路由单机部署时连接注册表就是一个内存里的 Map简单直接。一旦上了多实例问题就来了用户 A 连在实例 1用户 B 连在实例 2A 给 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 有writableLengthJava 的容器一般有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); }这里用 1013Try 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。没有这些数前面那三段排查实录里的任何一步你都走不下去只能靠猜。最后分享一个小技巧在客户端埋一个“连接质量自检”每次重连成功后上报一次包含上次连接存活时长、断开原因码、重连尝试次数。这批数据积累一两周你就能清楚地看到真实用户的连接质量分布而不是靠自己在办公室开着页面猜。我靠这个数据发现过一次区域性的大面积掉线原因是某个地区的运营商对长连接有额外的空闲回收策略光看服务端日志完全看不出来。这类问题没有一劳永逸的方案链路在变、客户端环境在变、用户规模在变能做的就是把这些机制做扎实把指标埋全然后在下一次出问题时能快速定位而不是抓瞎。我自己踩过的坑里绝大多数都不是什么高深的技术问题而是“少了一个上限”“漏了一层校验”“顺序反了一步”这种细节。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑