
1. 先把边界划清楚第二篇要解决的是什么问题上一篇把 WebSocket 的握手流程、基本收发和最小可用示例过了一遍很多人照着敲完本地能跑浏览器控制台能看到消息来回蹦就觉得这事儿成了。真把服务推上线用户一多问题就密集冒出来连接莫名其妙在 60 秒后断掉、重连逻辑把服务器打挂、内存曲线一路上扬不回头、多台机器部署之后 A 用户发的消息 B 用户永远收不到。这些都不是 API 用错了而是实时通信这套东西在真实环境下暴露出来的工程问题。这篇就专门啃这些硬骨头。核心话题围绕 WebSocket 的协议细节、连接生命周期管理、鉴权边界、集群投递、性能容量和线上排障展开。适合两类人看一类是把 WebSocket 当成能收发消息的管道用了很久但从没深究过帧结构和连接状态机的开发者另一类是正准备把实时功能推上生产想提前知道坑在哪、怎么绕的工程师。文中涉及的代码以 Node.js 和 Python 为主思路可以平移到其他语言栈。我先说个判断标准你拿去对照自己的项目如果你的实时服务满足以下三条——单机能扛住预期峰值连接、断网恢复后数据不丢不乱、多实例部署消息不串不丢——那这篇大部分内容你可以快速扫过只要有一条不满足下面的内容基本都得看。1.1 为什么跑通 Demo和上线可用之间隔着一整个太平洋Demo 的环境是理想化的客户端和服务端在同一台机器或者同一个内网、没有反向代理、没有负载均衡、连接数是个位数、网络稳定得像实验室。生产环境完全是另一副面孔中间隔着 CDN、四层或七层代理、TLS 终结、可能还有一层网关每一层都有自己的空闲超时和缓冲区大小客户端可能是地铁里的手机网络在 4G、Wi-Fi、飞行模式之间反复横跳。这里的关键认知是WebSocket 只是把 HTTP 的一次请求升级成了一条长连接连接本身依然是脆弱的。TCP 层面的连接在不断网的情况下不会主动告诉你对端已经没了而中间任何一层设备的超时策略都可能悄悄把这条连接掐掉。很多人第一次遇到每隔 60 秒准时断开第一反应是代码有 bug实际上是 Nginx 的proxy_read_timeout默认值在起作用服务端在这 60 秒内没有往下游写过任何数据代理就认为这条连接空闲了。所以 Demo 到生产之间缺的那部分不是功能是状态管理和故障假设。你得假设连接随时会断、假设消息可能重复、假设节点随时会挂、假设客户端是不可信的。把这几条假设当成设计前提后面的架构选择才有依据。1.2 本篇的推进路线与前置假设我按协议认知 → 连接治理 → 安全边界 → 集群扩展 → 容量与排障这条线往下走顺序不能乱。协议细节不清楚你没法判断心跳该发在哪一层连接治理没做好集群方案再漂亮也会被重连风暴冲垮鉴权放最后做往往要推翻前面的连接模型重写。前置假设也说清楚默认你已经有一个能收发文本消息的服务端骨架默认部署在 Linux 上默认客户端包含浏览器和移动端两类。文中出现的数据都是我在实际压测和线上观察里得到的经验区间具体数值要按你的业务重新测算我给的更多是计算方法和数量级参考。2. 把协议摊开看握手与帧结构里藏着的坑大部分人用 WebSocket 只关心onmessage和send中间的协议层被库封得严严实实。这没问题但一旦出问题你连抓包之后该看哪几个字节都不知道。这一章把关键部分拆开讲重点讲那些会直接影响你线上稳定性的字段。2.1 一次握手到底交换了什么客户端发起的是一个标准的 HTTP/1.1 请求带几个特殊头GET /ws HTTP/1.1 Host: example.internal Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13 Sec-WebSocket-Protocol: chat.v2, chat.v1 Origin: https://example.internalSec-WebSocket-Key是客户端随机生成的 16 字节做 Base64服务端把它和固定 GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼起来做 SHA-1 再 Base64放进Sec-WebSocket-Accept返回。这个设计的意图并不是加密或鉴权而是防止中间缓存设备把一个普通的 HTTP 响应误当成 WebSocket 握手响应。所以别指望它有什么安全强度真正的身份校验得自己加。服务端返回 101 之后这条 TCP 连接就从 HTTP 语义切换成了全双工的消息通道。这里有个实操细节值得记住握手阶段是可以携带业务参数的比如Sec-WebSocket-Protocol里的子协议名、URL query 里的版本号、Cookie 里的会话标识。这些参数在升级完成后依然可以读到是后续做灰度分流和版本协商的重要抓手。我在实际项目里踩过的一个坑某次客户端升级了协议版本服务端还按老版本解析消息格式结果消息全乱。后来在握手阶段加了子协议协商服务端从Sec-WebSocket-Protocol里挑一个自己支持的返回客户端发现返回的不是自己首选的就降级处理。这个机制是协议自带的用起来成本很低比自己在消息体里塞版本号要干净。还有一个容易被忽略的点是Sec-WebSocket-Extensions。最常见的扩展是permessage-deflate它会给每条消息做压缩。压缩对文本类消息能省 60% 以上的带宽听起来很香但它同时带来两个副作用一是服务端 CPU 开销上升二是存在压缩炸弹风险——攻击者构造一个高度可压缩的超大消息解压后瞬间吃光内存。我一般的做法是默认关闭只在明确知道消息体大且内容冗余度高比如 JSON 或日志的场景下开启并且务必限制解压后的最大长度。2.2 帧结构与分片一条消息不是一个帧WebSocket 的数据传输单位是帧不是消息。一帧的头部结构大致是这样字段长度说明FIN1 bit是否为消息的最后一帧RSV1-33 bit扩展用途未协商扩展时必须为 0Opcode4 bit帧类型0x1 文本、0x2 二进制、0x8 关闭、0x9 Ping、0xA Pong、0x0 续帧MASK1 bit客户端发往服务端必须为 1服务端发往客户端必须为 0Payload len7 / 716 / 764 bit载荷长度分三档Masking key0 或 4 字节掩码密钥仅 MASK1 时存在Payload data变长实际数据长度字段的三档设计很有意思小于 126 字节用 7 位直接表示126 到 65535 用 7 位标记 126后面跟 16 位更大的用标记 127后面跟 64 位。这个设计是为了让常见的小消息头部尽可能紧凑一条 20 字节的聊天消息头部只占 2 字节。分片机制是另一个重点。一条大消息可以被拆成多个帧发送第一帧 opcode 是 0x1 或 0x2后续帧 opcode 是 0x0最后一帧 FIN1。这个设计原本是为了让大消息传输时不被小消息阻塞——中间可以插入控制帧。但绝大多数库会自动帮你组装日常开发感知不到。注意控制帧Ping/Pong/Close不允许分片载荷也不能超过 125 字节。如果你的心跳带业务数据必须控制在这个范围内超了就是协议违规对端会直接断开。掩码这个字段值得单独说一句。协议规定客户端发的每一帧都必须掩码服务端发的不能掩码违者视为协议错误立即断开。这个规则的初衷是防止缓存污染攻击——通过掩码让攻击者无法构造出固定的字节序列。自己实现协议解析的时候如果不做这个校验有些测试工具会直接报错排查半天才发现是掩码没处理。2.3 关闭握手收尾比开场更需要规矩Close 帧0x8里可以带一个 2 字节的状态码和一段 UTF-8 描述。常见的状态码1000 正常关闭、1001 端点离开比如页面关闭、1002 协议错误、1003 数据类型不支持、1006 异常关闭这个码永远不会出现在 Close 帧里只代表连接非正常断裂、1009 消息过大、1011 服务端内部错误。关闭流程是有来有回的一方发 Close 帧另一方收到后回一个 Close 帧然后双方才真正关闭 TCP。发起方发完 Close 后不能再发数据帧但可以继续收直到收到对端的 Close 或者超时。很多库把这段封装得很好但如果你用底层 API一定要记得在收到 Close 后主动回一个否则对方会一直等到超时。这里有个非常实际的教训服务端主动踢掉一个违规连接时如果直接socket.close()而不发 Close 帧客户端收到的是 1006它无法区分服务端正常关闭和网络断了于是会触发重连重连上来又被踢形成循环。正确做法是先发一个带 1008策略违规或者 1011 的 Close 帧客户端识别到这个码就知道不该重连把重连逻辑停掉。这些状态码看着琐碎实际上是你的重连治理的第一道防线。3. 连接的生死管理心跳、重连与资源回收到了这一章才算真正进入工程实践。前面那些协议细节库基本帮你兜住了但连接的存活判断、断开后的恢复策略、服务端的资源回收没有哪个库能全权代劳必须自己设计。3.1 心跳该谁发、发多快、超时怎么判心跳要解决的核心问题是如何在一个可能被中间设备静默掐断的通道上尽早发现对方已经不在了。TCP 自带的 keepalive 默认是两个小时而且很多系统内核参数不可控指望它不现实。所以必须在应用层或协议层做心跳。协议层的做法是 Ping/Pong 帧一端发 Ping另一端必须回 Pong且 Pong 必须原样带回 Ping 的载荷。这个机制的好处是开销极小头部加几个字节而已。但浏览器端有个限制——WebSocket 的 JS API 不暴露发送 Ping 帧的能力ping方法根本不存在。所以浏览器场景下只能走业务层心跳也就是双方约定一个 JSON 消息比如{type:ping,t:1710000000}服务端收到后回一个{type:pong,t:...}。间隔定多久合适这个要看中间链路的最短空闲超时。常见的反向代理默认 60 秒负载均衡可能是 30 秒到 300 秒不等云上的四层负载均衡有些是 900 秒。我的经验取值是20 到 25 秒理由是要比最短的那个超时明显小同时给丢包留出重试空间。如果你设 30 秒而代理是 60 秒超时中间只要有一次心跳丢了下一次到达时已经超过 60 秒连接就被掐了从现象看就是随机断开极难排查。超时判定不能只看一次没回。合理的做法是连续丢失 2 到 3 次才判定死亡单次丢失很可能是网络抖动。以 25 秒间隔、丢 2 次判定为例实际检测延迟在 50 到 75 秒之间这个延迟对大多数业务可以接受。如果业务对实时性要求高比如协同编辑可以把间隔压到 10 秒代价是心跳流量翻倍。我算一笔账10 万在线连接25 秒一次心跳每秒心跳消息量大约是 100000 / 25 × 2一来一回 8000 条。这个量级对单机来说不算什么但如果你的心跳处理逻辑里带了数据库查询或 Redis 写入8000 QPS 就会变成实实在在的压力。心跳响应必须是纯内存操作不查库、不打日志或者只采样打日志这是硬性要求。3.2 断线重连的退避策略与状态恢复客户端的重连逻辑写不好就是灾难。最常见的错误写法是onclose里直接setTimeout(connect, 1000)服务端一重启几万个客户端在同一秒涌上来服务器刚起来就被打挂然后客户端又全断再来一轮。这个现象叫重连风暴。正确的做法是指数退避加随机抖动。基础思路第一次等 1 秒第二次等 2 秒第三次 4 秒逐次翻倍封顶 30 秒每一次的实际等待时间加上 0 到 30% 的随机偏移。伪代码如下let retry 0; const MAX_DELAY 30000; const BASE 1000; function nextDelay() { const exp Math.min(MAX_DELAY, BASE * Math.pow(2, retry)); const jitter exp * Math.random() * 0.3; retry 1; return exp * 0.7 jitter; }随机抖动这一步不能省。假设 5 万客户端都用纯指数退避第 5 次重连时所有人在同一时刻醒来等效于没做退避。加了 30% 抖动之后这批请求会散开到一个时间窗口里服务端的压力曲线就平缓了。重连成功之后还有一件事状态恢复。TCP 重连之后是一条全新的连接服务端不知道你之前订阅了什么、收到哪条消息了。所以重连成功后必须做三件事重新鉴权拿新 token 或校验旧 token 是否还有效、重新订阅把之前的订阅关系重新下发、拉取增量用一个递增序号或者时间戳把断线期间漏掉的消息补回来。第三件事最容易被漏掉漏了就会表现为用户反馈偶尔丢消息。增量拉取需要一个单调递增的游标。我一般让服务端给每条下发的消息打一个seq全局递增或者按会话递增都可以。客户端记录最后收到的seq重连后带着它请求补发。服务端返回seq大于该值的历史消息。如果历史消息量太大就只补最近的 N 条同时告知客户端gap 太大建议全量刷新让客户端走一次完整的状态同步。这个设计比纯粹靠 WebSocket 保证不丢要可靠得多因为任何长连接方案都无法承诺消息不丢只能承诺丢了能被发现并补上。3.3 服务端连接回收那些悄悄吃掉内存的东西服务端的连接表通常是一个MapconnectionId, Connection。看起来简单但只要有一处漏删内存就会缓慢上涨跑几天之后 OOM。漏删的路径比想象中多正常 close 事件、error 事件、心跳超时主动踢、进程收到信号优雅退出、读写超时。每一条路径都要确保连接从表里移除并且清掉所有关联的定时器和监听器。Node.js 里有个典型泄漏场景给每个连接setInterval做心跳检测连接关闭时忘了clearInterval。定时器持有连接对象的引用连接对象持有 socketsocket 的缓冲区也不释放于是每个死连接都占着几百 KB。10 万个死连接就是几十 GB。这种现象在内存快照里表现为大量由定时器保持引用的对象用堆分析工具很容易看出来。判断死连接本身也是个问题。有一种状态叫半开连接客户端那边已经断电或者进了飞行模式没有发 FIN服务端这边的 socket 依然是 ESTABLISHED写数据也能成功写进内核缓冲区但实际上永远收不到回应。这种连接只能靠心跳超时来清理。所以心跳不仅客户端要做服务端也要做——服务端维护一个lastActiveAt每次收到任何帧就更新后台有个低频扫描任务比如每 10 秒扫一次把超过阈值没活动的连接主动关掉。# 简化的服务端活跃度扫描 IDLE_LIMIT 90 # 秒约等于 3 个心跳周期 async def sweep_connections(conns): now time.time() dead [cid for cid, c in conns.items() if now - c.last_active IDLE_LIMIT] for cid in dead: conn conns.pop(cid, None) if conn: await conn.websocket.close(code1001, reasonidle timeout)提示扫描任务本身要用低优先级、分批处理别一次性遍历几十万连接做同步操作容易造成事件循环卡顿。分批遍历、每批之间await asyncio.sleep(0)让出控制权这个细节在大连接量下差别很明显。4. 鉴权与安全连接建立前后的两道闸WebSocket 的安全模型和普通 HTTP 不太一样因为它是一条长连接鉴权只在连接建立时做一次之后的所有消息都复用这次的身份。这带来一个后果token 过期了连接还活着。怎么处理这个时间差是设计时要想清楚的。4.1 握手阶段的身份校验怎么做浏览器端能用的手段有限new WebSocket(url)不支持自定义请求头所以常见的三种做法第一种把 token 放在 URL query 里wss://host/ws?tokenxxx。简单直接缺点是 token 会出现在服务端访问日志、代理日志里有泄露风险。缓解办法是给 token 设很短的有效期比如 60 秒一次性使用握手成功即作废。第二种用 Cookie。同域部署时浏览器会自动带上服务端从 Cookie 里取会话标识。缺点是天然带 CSRF 风险——第三方页面可以发起跨站 WebSocket 连接浏览器会带上 Cookie。防护手段是严格校验 Origin 头只放行白名单里的域名。第三种连接建立后发一条鉴权消息服务端在收到之前不接受任何业务消息。这种做法最灵活token 不出现在 URL 里也绕开了 Cookie 的跨站问题。代价是要多一个往返并且服务端要设一个鉴权超时比如 5 秒内没收到鉴权消息就直接断开否则攻击者可以建立大量未认证连接占资源。我个人偏向第三种特别是在有移动端和 Web 端混合的场景下。移动端其实可以自定义 header但为了两端逻辑统一还是用首帧鉴权更省心。实现上给连接加一个状态字段AUTH_PENDING→AUTHED→CLOSED只有AUTHED状态才允许业务消息通过其余状态一律丢弃或断开。4.2 权限粒度与消息级校验连接级的身份只回答了你是谁不回答你能干什么。真正的权限控制要落到消息级别。举个具体场景一个协作看板用户加入某个房间后能收到该房间的变更推送。如果只在连接建立时校验一次用户已登录那么他给服务端发一条{type:subscribe,roomId:other-room}如果服务端不校验就直接让他订阅了就是水平越权。所以订阅动作必须做权限判断这个用户有没有权限访问这个房间。判断逻辑放在内存里还是查库取决于订阅频率。高频订阅可以把权限关系缓存在本地设个短 TTL低频就直接查。判断失败的返回一个明确的错误码不要静默忽略否则客户端会以为订阅成功了但收不到消息排查起来很痛苦。消息级的另一个维度是频率限制。WebSocket 是长连接不受 HTTP 那套限流中间件的保护客户端可以在一秒内发上万条消息。必须自己做过载保护按连接维度做令牌桶比如每秒 20 条超了先警告再断开。这个阈值要根据业务定聊天场景可以宽松些指令下发场景要严格。4.3 常见攻击面与防护清单整理一份我实际遇到或排查过的风险清单按危害排序风险表现防护手段未认证连接堆积大量连接不发言占满连接数鉴权超时 5 秒未认证直接断超大消息单条消息几百 MB打爆内存服务端设 maxPayload超限断开1009压缩炸弹开启压缩后小包解压成巨量数据限制解压后长度或直接关闭压缩跨站连接第三方页面借用户 Cookie 建立连接严格校验 Origin白名单消息洪泛单连接高频发送令牌桶限流超限断开慢速攻击逐字节慢发长期占用连接读写超时 空闲扫描订阅越权订阅非授权频道订阅时逐条校验权限这些防护里maxPayload和鉴权超时是性价比最高的两项几乎零成本能挡掉大部分低级攻击。Origin 校验经常被忘因为在本地开发时 Origin 是localhost上线后如果不做校验等于把接口暴露给任何网页。5. 多实例部署消息怎么跨节点送达单机跑得好好的加一台机器就出问题——这是分布式系统的经典开场。WebSocket 的麻烦在于连接是有状态的用户 A 连在节点 1用户 B 连在节点 2A 要发消息给 B节点 1 本地找不到 B 的连接怎么办5.1 单机内存模型的天然局限先说清楚为什么单机模型不能直接扩展。假设你用MapuserId, WebSocket存连接节点 1 只知道连在自己身上的用户。当节点 1 要推送给用户 B 时在本地 Map 里查不到消息就丢了。这是物理隔离导致的问题加机器不会自动解决反而会让消息时有时无这种随机故障变多。有两种基本解法粘性会话和消息总线。粘性会话是让同一个用户的请求包括 HTTP 和 WebSocket始终落到同一台机器靠负载均衡的一致性哈希或者 Cookie 亲和实现。这种做法改造成本最低但有两个硬伤一是扩容时哈希重分布会导致大量连接迁移用户体验是突然断线重连二是某台机器挂了上面所有连接都要重连而且这台机器负责的那部分用户会短暂不可用。消息总线是把投递这件事从单机内存里解耦出来。任何一个节点产生消息都发到总线所有节点订阅总线各自检查本地有没有目标连接有就投递。常见实现是 Redis 的发布订阅、NATS、Kafka 这类。这套方案扩容平滑、容错好代价是多了一跳网络往返延迟增加几毫秒到十几毫秒。5.2 广播与定向投递的实现选型选型要看消息模式。纯广播比如行情推送、公告最简单所有节点订阅同一个频道收到就推给本地所有连接不需要知道用户在哪台机器。定向投递比如私聊就麻烦一些需要知道目标用户连在哪个节点。一种轻量做法是维护一张全局路由表存在 Redis 里key 是 userIdvalue 是节点标识连接建立时写入、断开时删除带 TTL 兜底。发消息时先查这张表找到节点标识然后往那个节点的专属频道发。这个方案实现简单缺点是每次定向投递都要查一次 Redis量大时 Redis 压力不小可以加本地缓存但要注意缓存和真实状态的不一致。另一种做法是所有节点都订阅同一个用户频道每个节点收到后检查本地有没有这个用户没有就丢弃。听起来很浪费——N 个节点收 N 份——但实际在节点数不多十几台以内的场景下完全可接受而且省掉了路由表维护的复杂度。我做过一次测算20 个节点、每秒 5000 条定向消息总投递量 10 万次/秒看起来吓人但每次只是一次内存查表和丢弃CPU 占用很低。节点数超过 50 台之后这个方案就不划算了得回到路由表。顺序性是个绕不开的话题。同一用户的两条消息如果分别走了不同的路径到达顺序可能颠倒。我的处理原则是在单连接内保证有序这个协议本身能保证跨节点不做全局有序承诺改用业务层的序号和幂等。每条消息带一个单调递增的seq客户端收到乱序的就按seq排一下收到重复的直接丢弃。这个思路和 HTTP 接口的幂等设计是一样的不要指望传输层解决顺序问题。5.3 节点下线与连接迁移的处理节点挂掉的时候它上面挂着的所有连接都需要客户端重连重连后落到其他健康节点上。这个过程要控制节奏否则健康节点会被瞬间涌入的连接压垮。服务端的处理是节点注册时在 Redis 写一个带 TTL 的心跳键比如 15 秒过期每 5 秒刷新一次。网关或负载均衡在分配连接前检查这个键不存在就把该节点从可用列表摘掉。这样节点非正常退出时最多 15 秒内流量就不再往它身上分。客户端这边靠上一章讲的退避重连来削峰。如果连接是被服务端主动关闭的收到 Close 帧状态码 1001 表示端点离开客户端可以立即重连一次而不走退避因为这是预期内的迁移不是故障。但如果短时间内连续收到多次 1001就要退回指数退避避免在集群整体滚动重启时形成风暴。注意滚动重启时别让所有节点同时下线。按批次来每批之间留出 30 秒以上让客户端有时间重连并稳定下来。这个操作规范比任何代码优化都管用。6. 容量估算与压测数字要自己算一遍我们的服务能支撑多少连接这个问题问别人没用得自己测和算。这一章给方法和参数你拿去套自己的环境。6.1 压测怎么做才有参考价值先说几个常见的压测误区。用几台机器各开几百个连接跑一下看消息收发正常就认为能扛住——这测的是功能不是容量。用 ab 或者 JMeter 打 HTTP 接口的方式压 WebSocket测出来的数字没有意义。压测客户端本身成了瓶颈还没把服务端压到极限客户端先跑不动了。有参考价值的压测要满足几个条件客户端机器和服务端机器分开客户端的总资源CPU、内存、文件描述符要明显富余连接数是逐步爬坡上去的观察每个阶段的延迟和资源曲线而不是一步到位要区分空连接只握手不发消息和活跃连接持续收发两种场景这两者的资源消耗差好几倍。工具方面k6有 WebSocket 扩展用 JS 写场景比较方便artillery也不错要更高并发就得自己用 Go 或者 Rust 写客户端单机可以跑到几十万连接。压测时的核心指标连接建立成功率、建立耗时 P99、消息往返延迟 P99、服务端 CPU 和内存、每个连接的平均内存占用、GC 停顿时间如果是托管语言。6.2 从参数倒推容量一次实打实的计算假设目标是单机 10 万连接每条连接每 25 秒一次心跳往返。先算消息量心跳请求 100000 / 25 4000 条/秒心跳响应同样 4000 条/秒合计 8000 条/秒的处理量。这个量级对现代服务端来说不算高一次消息处理如果是纯内存操作耗时在微秒级8000 条/秒只占用很小一部分 CPU。再算内存。每条连接的内存开销包括socket 读写缓冲区内核侧默认几 KB 到几十 KB可通过SO_SNDBUF/SO_RCVBUF调整、应用层的连接对象连接 ID、用户信息、订阅列表、最后活跃时间等通常 1 到 5 KB、框架自身的开销比如 Node.js 每个 WebSocket 对象的额外内存。经验值是每连接 20 到 50 KB取中间值 30 KB10 万连接就是 3 GB。这个数字要留 50% 余量实际准备 4.5 GB 以上。文件描述符是硬限制。每条连接一个 FD10 万连接加上进程自身打开的文件、管道、socket需要至少 15 万个 FD。Linux 默认ulimit -n是 1024必须调大同时fs.file-max也要调。这两个参数不改跑到 1000 连接就报EMFILE而且报错之后连接会大面积异常。# 查看当前限制 ulimit -n cat /proc/sys/fs/file-max # 临时调整重启失效 ulimit -n 200000 sysctl -w fs.file-max1000000 # 永久生效要写进 /etc/security/limits.conf 和 /etc/sysctl.conf还有端口范围。如果你用单机做压测客户端每个连接都要占一个本地端口默认的临时端口范围大约 2.8 万个跑到这个数就建不了新连接了。压测客户端要多机分布式部署或者调大net.ipv4.ip_local_port_range。6.3 瓶颈定位CPU、内存还是网络压到一定程度一定会撞到某个瓶颈识别它才能针对性地优化。CPU 打满通常是消息处理逻辑太重JSON 序列化、加解密、压缩或者事件循环被同步代码阻塞。内存涨得快要看是不是连接对象太重、有没有泄漏、GC 是否频繁。网络打满要看是不是消息体太大或者没开压缩。有一个容易被忽略的瓶颈是上下文切换和中断。连接数上到几十万之后网络包的处理本身会消耗大量 CPU表现为%sy系统态 CPU很高。这时候要考虑多队列网卡、调整中断亲和性甚至用 DPDK 这类方案。不过到这个量级之前大多数业务早就该做水平扩展了没必要在单机上死磕。延迟指标要看 P99 而不是平均值。平均值 5ms 看着很漂亮P99 是 800ms用户体验照样糟糕。P99 高通常是 GC 停顿或者某个慢操作在事件循环里排队导致的用火焰图或者 APM 工具抓一下就能定位。7. 线上问题排查实录这一章是我这些年攒下来的问题清单按症状 → 可能原因 → 排查动作组织遇到问题先对号入座能省很多时间。7.1 常见故障速查表症状常见原因排查动作连接恰好 60 秒断开反向代理空闲超时查代理的proxy_read_timeout、proxy_send_timeout握手返回 400/426版本不支持或 head 头缺失抓包看请求头确认 Upgrade/Connection/Version握手成功立刻断开鉴权失败、Origin 被拒看服务端日志的关闭码1008 多为策略拒绝部分用户收不到消息多实例未做投递同步确认用户所在节点检查总线订阅消息延迟偶发飙高事件循环被阻塞抓火焰图找同步阻塞调用内存持续上涨定时器/监听器未清理堆快照对比看连接对象是否被持有频繁重连心跳间隔大于代理超时对齐心跳与各层超时配置重连风暴无退避或退避无抖动检查客户端重连实现服务端 CPU 100%消息处理逻辑重或死循环采样分析热点函数建立连接慢TLS 握手开销或 DNS 慢分离测量看是 TCP、TLS 还是应用层这张表里恰好 60 秒断开和部分用户收不到消息是出现频率最高的两个。前者几乎百分百是代理超时后者几乎百分百是多实例投递没做。这两条我建议直接写进团队的上线检查清单。7.2 日志与监控该埋什么排查效率取决于你埋了什么。连接维度至少要有这些日志连接建立带上连接 ID、用户标识、客户端 IP、协议版本、连接关闭连接 ID、关闭码、原因、存活时长、鉴权结果成功/失败及原因。消息维度不需要每条都打但要有采样尤其是错误消息和超限消息必须打。监控指标分四类连接数当前在线、新建速率、关闭速率三个要一起看新建远大于关闭说明有异常重连、消息量发送/接收 QPS、平均大小、错误率、延迟端到端延迟、心跳 RTT 的 P50/P99、资源每连接内存、CPU、FD 使用率。我特别推荐监控心跳 RTT 的 P99。这个指标非常灵敏网络抖动、服务端负载上升、事件循环卡顿都会在它上面体现出来而且它不依赖业务逻辑任何 WebSocket 服务都能采。有一次线上出现偶发的消息延迟业务指标看不出来就是心跳 RTT 的 P99 从 40ms 慢慢爬到 300ms顺着这条线查下去发现是某个定时任务在高峰期跑阻塞了事件循环。再分享一个小技巧给每个连接分配一个短 ID比如 8 位随机串所有日志都带上它。用户反馈问题时让前端把连接 ID 报出来后端一搜就能把这条连接的全生命周期串起来比按时间范围翻日志快十倍。这个 ID 通过一个首帧下发的 welcome 消息告诉客户端前端存到内存里出问题时随错误上报一起发出来。8. 几个我踩过的坑和随手可用的小经验先说一个最典型的。曾经有个服务压测时一切正常上线后每天晚上八点准时出现一批连接异常断开。查了很久最后发现是那个时间点有一个批量导出任务占满了数据库连接池心跳处理逻辑里有一段顺手更新一下用户最后活跃时间的数据库写入阻塞在连接池上超过 25 秒没响应客户端判定心跳超时主动断开。教训就是前面说的那条心跳路径上不能有任何 IO。把最后活跃时间改成写内存、定期批量刷盘之后问题消失。第二个坑跟压缩有关。开了permessage-deflate之后服务端 CPU 使用率上了一个台阶同时出现偶发的内存尖峰。原因是压缩上下文默认会跨消息复用服务端为每条连接维护一份压缩字典连接数一多内存就上去了。后来改成只对超过 1KB 的消息做压缩并且限制了client_max_window_bits内存才稳定下来。压缩这东西不是开了就赚得看消息特征。第三个是重连逻辑的细节。客户端重连成功后我做了一个立即重发未确认消息的动作结果某些消息被处理了两次。原因是消息已经到达服务端并处理但确认包在断线时丢了客户端以为没成功。解决办法是给每条业务消息带一个客户端生成的唯一 ID服务端做幂等去重保留最近 N 分钟的已处理 ID 集合。这个改动不大但把至少一次变成了效果上恰好一次对订单类业务很关键。最后一个算是经验之谈。实时通信系统最难的不是功能是可观测性。你没法像调试 HTTP 接口那样一条请求一条请求地看连接是长期存在的状态是持续变化的。所以我在项目早期就会把连接生命周期日志、心跳 RTT 监控、消息错误率这三个东西搭起来哪怕功能还没完全做完。这套东西搭好之后后面每一个诡异问题都能在半小时内定位到方向比事后补救划算太多。