上一篇把 WebSocket 的握手流程和一个最小可用的双向通信 Demo 跑通了,不少人照着敲完能连上、能收发,但一放到真实流量里就开始出问题:连上几分钟就断、内存缓慢上涨、多个实例之间消息对不上、压测一上来握手全超时。这篇就接着往下走,专门聊 WebSocket 实时通信从“能跑”到“敢上线”中间那段路,把协议细节、连接生命周期管理、背压与并发写入、接入层配置和排障经验一次讲透。适合已经把基础 API 用熟、准备把实时功能推进到生产环境的后端和全栈同学,也适合正在排查线上长连接抖动的运维和 SRE。里面提到的参数和配置都来自实际项目里踩过的坑,你可以直接对照自己的场景改。
1. 从“能连上”到“敢上线”:第二篇要补的课
1.1 第一版 Demo 在生产环境暴露的三个问题
很多人第一次写 WebSocket 服务,代码大概长这样:服务启动、监听端口、on connection 之后把连接塞进一个数组,收到消息就遍历数组群发,前端 onmessage 里更新页面。这个结构在本地两台机器上跑毫无问题,一旦上线,问题会集中爆发在三个地方。
第一个是连接存活判断。TCP 连接在中间链路空闲一段时间后会被悄悄回收,两端都收不到 FIN 包,服务端眼里的连接还在,实际早就是死的。你以为在线用户有一千,真实可写的可能只剩六百,剩下四百个连接占着内存和文件描述符,消息发出去石沉大海。
第二个是消息发送的并发安全。不少 WebSocket 库明确要求同一个连接不能并发写,而业务代码里定时推送、用户请求响应、广播任务经常同时往一个连接上写,轻则消息粘在一起变成非法帧,重则直接触发库的 panic 把整个进程带崩。
第三个是重连风暴。服务端重启或者网络抖动,几千个客户端在同一秒发起重连,握手请求把接入层打满,CPU 飙到 100%,恢复时间被无限拉长。这三个问题都不是 API 层面的问题,而是连接治理层面的问题,也是这篇要重点拆的部分。
1.2 这篇的实战路线图
内容安排上,我打算从协议最底层往上走。先把握手和帧结构拆到字节级,搞清楚那些“莫名其妙断开”的根因;再讲连接生命周期,心跳怎么设、重连怎么退避、半开连接怎么清理;然后是消息边界和背压,这部分决定了服务在高并发下会不会被慢客户端拖死;最后落到部署层面,接入层怎么配、多实例怎么广播、容量怎么估、出问题怎么查。
路线这么排的原因是,WebSocket 的绝大多数线上故障,追到根上都能落到协议细节或者连接管理上,而不是业务逻辑写错了。你把帧头看懂了,看到 1006 关闭码就知道是对端没发 close 帧就断了;你把心跳和接入层超时的关系理清了,就不会再出现“本地好好的、线上一分钟断一次”这种玄学。下面进入正题,代码以 Node.js 和 Go 为主,思路对任何语言都通用。
2. 握手与帧结构:把协议拆到字节级
2.1 一次 Upgrade 握手到底交换了什么
WebSocket 的连接建立是一次标准的 HTTP/1.1 请求,只是带了几个特殊的头。客户端发出去的东西大概是这样:请求行是GET /ws/chat HTTP/1.1,带上Host、Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key、Sec-WebSocket-Version: 13,浏览器还会自动加Origin,如果前端指定了子协议,还会有Sec-WebSocket-Protocol。
其中Sec-WebSocket-Key是客户端生成的 16 字节随机数,Base64 编码后放进来。服务端拿到这个值,拼上固定的 GUID 字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11,做一次 SHA-1 摘要再 Base64,得到Sec-WebSocket-Accept塞进 101 响应里。RFC 里有个现成的例子:key 是dGhlIHNhbXBsZSBub25jZQ==,算出来的 accept 是s3pPLMBiTxaQ9kYGzzhZRbK+xOo=,你可以拿这个例子验证自己手写的握手逻辑对不对。
这个看似多余的计算,作用不是加密,而是防止普通的 HTTP 请求被中间层误判成 WebSocket 升级,同时也让服务端能确认对面真的理解协议。服务端返回 101 之后,这条 TCP 连接就从 HTTP 语义切换到了 WebSocket 帧语义,后面的字节流不再按 HTTP 解析。理解这一点很关键:握手只发生一次,之后所有通信都是帧,所以任何“给 WebSocket 加请求头”的需求都必须在握手阶段完成,连上之后再加 header 是无效的。
握手失败的情况也值得记一下。返回 200 说明服务端根本没识别这是升级请求,通常是接入层没转发Upgrade头;返回 400 多半是Sec-WebSocket-Key缺失或格式不对;返回 426 是版本不支持,客户端发的是 8 而服务端只认 13;返回 403 常见于 Origin 校验没过。这几种状态码在排查时指向性很强,比看日志猜要快得多。
2.2 帧头逐字段拆解与掩码的由来
WebSocket 的数据帧最小 2 字节、最大 14 字节的头部,后面跟负载。头部字段不多,但每一个都有明确的用途,看懂了排查问题时心里就有底。
| 字段 | 长度 | 作用 | 实操注意 |
|---|---|---|---|
| FIN | 1 bit | 标记是否为消息的最后一帧 | 为 0 表示还有后续分片 |
| RSV1-3 | 3 bit | 保留位,供扩展协商使用 | 没协商扩展时必须为 0,否则对端应断开 |
| Opcode | 4 bit | 帧类型 | 0x1 文本、0x2 二进制、0x8 关闭、0x9 Ping、0xA Pong、0x0 续帧 |
| MASK | 1 bit | 是否使用掩码 | 客户端发往服务端必须为 1,反向必须为 0 |
| Payload len | 7/7+16/7+64 bit | 负载长度 | 0-125 直接表示,126 跟 2 字节,127 跟 8 字节 |
| Masking key | 0 或 4 字节 | 掩码密钥 | 仅在 MASK 为 1 时存在 |
| Payload | 变长 | 实际数据 | 文本帧要求 UTF-8 合法 |
掩码这个设计经常被问“为什么客户端必须掩码、服务端却不能掩码”。原因要追溯到早期中间缓存被攻击的历史:攻击者如果能控制客户端发出的字节,就可能构造出一段看起来像 HTTP 请求的负载,骗过中间的缓存层去污染缓存。强制客户端加掩码后,攻击者无法精确控制线上字节,这个攻击路径就被堵住了。服务端到客户端的流量方向不存在这个问题,所以不加掩码,也省了一次 XOR 运算。
掩码的算法很简单,4 字节密钥循环与负载逐字节异或。虽然简单,但它是协议强制的,自己手写客户端时漏了这一步,服务端会直接以协议错误关闭连接,关闭码通常是 1002。这也是很多人用非标准客户端库连商业服务时“一连就断”的常见原因。
分片机制也在这里一并说清。一个消息可以拆成多个帧发送,第一帧 FIN=0、opcode 为实际类型,后续帧 FIN=0、opcode 为 0x0(续帧),最后一帧 FIN=1。控制帧不允许分片,负载也不能超过 125 字节。实际开发中很少手动分片,库会帮你做,但要知道接收端必须能处理分片,否则遇到大消息就会解析失败。设置最大消息长度时,限制的是分片合并后的总大小,不是单帧大小。
2.3 控制帧与关闭握手:别让连接悬在半空
控制帧只有三种:Ping、Pong、Close。Ping 和 Pong 是协议层的心跳机制,Close 是优雅关闭的握手。这三类帧的 opcode 最高位都是 1,负载上限 125 字节,且不能分片。
关闭握手经常被忽略。标准做法是:主动关闭的一方发送一个 Close 帧,负载里带 2 字节的状态码和可选的 UTF-8 原因,然后等待对方回一个 Close 帧,收到后再关闭底层 TCP。如果等了若干秒没等到,才强制关闭。这样做的好处是双方都能确认“我知道你要关了”,避免数据丢失和连接状态不一致。
关闭码里有两类要分清。1000 表示正常关闭,1001 表示端点离开(比如页面关闭),这两个是业务可以主动使用的。1006 是保留码,表示连接异常关闭、没有收到 Close 帧,这个码永远不会出现在实际的 Close 帧里,只会在客户端的 onclose 事件里出现。看到 1006 就说明链路是断的,不是对端优雅关闭。1011 表示服务端遇到内部错误,适合在业务异常时使用。
我在实际项目里定了一条规矩:服务端主动踢连接时,必须先发 Close 帧并带上明确的状态码和简短原因,再等一个短超时后销毁连接。早期图省事直接socket.destroy(),结果客户端全都收到 1006,前端没法区分“被踢下线”和“网络断了”,重连逻辑就会一直重试,把本来该提示用户重新登录的场景变成了无限重连。
3. 连接生命周期:心跳、重连与资源回收
3.1 心跳放协议层还是应用层
心跳的作用有两个:一是让中间链路(网关、负载均衡、NAT)认为连接还活跃,不被空闲超时回收;二是让两端能尽快发现死连接。放哪一层,取决于你的客户端类型。
协议层的 Ping/Pong 最省事,服务端定时发 Ping,对端协议栈自动回 Pong,不占用业务消息通道。但浏览器提供的 WebSocket API 没有暴露发送 Ping 的方法,也就是说浏览器端只能被动回应服务端的 Ping,不能主动发。所以纯浏览器场景下,服务端发 Ping 是可行的,客户端侧就得靠应用层心跳来探测“服务端是不是还活着”。
应用层心跳就是一个普通的消息,比如{"type":"ping","ts":1730000000000},对方回{"type":"pong","ts":...}。它多消耗一点带宽,但好处是能穿过所有中间层,还能顺带做 RTT 统计和业务侧的健康判断。我的习惯是两层都上:服务端用协议层 Ping 做链路保活,客户端用应用层心跳做端到端探测,两者互不干扰。
间隔怎么定,这里有个容易踩的坑。假设你设 30 秒发一次心跳,那服务端的读超时至少要是心跳间隔的 2 到 3 倍,也就是 60 到 90 秒,给网络抖动留出余量。更关键的是接入层的空闲超时:Nginx 的proxy_read_timeout默认 60 秒,云负载均衡的空闲超时常见也是 60 秒,如果你的心跳间隔是 60 秒,那连接会在心跳发出前的一瞬间被掐断,表现出来就是“每隔一分钟断一次”。经验值是心跳间隔取接入层最小空闲超时的三分之一到二分之一,比如超时 60 秒,心跳就设 20 到 25 秒。
3.2 断线重连的退避曲线与消息补偿
重连策略写得好不好,直接决定了服务端故障后的恢复速度。最朴素的做法是setTimeout(connect, 1000),问题在于服务端重启时所有客户端都在第 1 秒重连,第二波在第 2 秒,请求量是叠加的,很容易把刚起来的服务再打挂。
指数退避加随机抖动是标准解法。公式可以写成delay = min(cap, base * 2^attempt) * (0.8 + 0.4 * random()),base 取 1 秒,cap 取 30 秒,attempt 是连续失败次数,成功后清零。这样第一次大约 1 秒,之后 2、4、8、16 秒,封顶 30 秒,每次再乘一个 0.8 到 1.2 的随机系数,把重连请求打散。几千个客户端同时重连,峰值会被摊平到几十秒的窗口里,服务端压力小一个数量级。
function nextDelay(attempt) { const base = 1000; const cap = 30000; const exp = Math.min(cap, base * Math.pow(2, attempt)); const jitter = 0.8 + Math.random() * 0.4; return Math.floor(exp * jitter); } let attempt = 0; function connect() { const ws = new WebSocket(url); ws.onopen = () => { attempt = 0; }; ws.onclose = () => { const t = nextDelay(attempt++); setTimeout(connect, t); }; }还有两个细节。页面切到后台时,浏览器的定时器会被节流,这时候重连节奏会乱,建议监听visibilitychange,页面重新可见时立即尝试一次,而不是傻等定时器。另外,重连成功不等于状态恢复:之前的订阅关系、房间加入、鉴权态都要重新建立,消息补偿也要做。常见做法是每条业务消息带一个自增序号,客户端记录最后收到的序号,重连后带上这个序号请求增量;服务端保留最近一段时间的消息或者提供拉取接口,客户端按序号去重。没有这层补偿,断线那几秒的消息就永久丢了。
3.3 半开连接与连接表的清理
半开连接是长连接服务里最隐蔽的资源泄漏。链路中间设备重启、NAT 表项过期、客户端进程被杀,这些场景下服务端都收不到 FIN,连接对象一直挂在内存里。光靠 TCP keepalive 不够,默认要两个小时才探测一次,业务上完全等不起。
服务端的做法是维护一张连接表,每个连接记录lastActiveAt,收到任何帧(包括 Pong)就更新。再起一个定时器,比如每 10 秒扫一遍,超过 90 秒没活动的连接先发 Close,等几秒还没反应就强制销毁并从表里移除。扫描的时候注意不要用setInterval里做全表遍历加重活,连接数上万时会有明显的毛刺,可以分片扫描,每次只扫一部分。
清理逻辑里最容易漏的是附属资源的释放。连接关闭时,除了从 Map 里删掉,还要取消该连接上的所有定时器、退出它加入的房间、清理订阅关系、把未发送完的消息队列丢弃。我见过一个线上案例,连接对象本身被回收了,但每个连接上挂的一个setTimeout没清,回调闭包又引用着连接对象,GC 根本收不掉,内存曲线一路向上,最后靠堆快照才定位到。
4. 消息边界、背压与并发写入
4.1 WebSocket 到底要不要处理粘包
这个问题在社区里被问过无数次,答案很明确:WebSocket 本身是基于消息的,不需要像裸 TCP 那样自己拆包。协议层有帧边界,库会把分片合并成完整消息再交给你,onmessage拿到的就是一条完整消息,不会出现两条消息粘在一起的情况。
但这不代表可以完全不管边界。第一个要注意的是最大消息长度。库一般有默认上限,比如 100MB 或者不限制,业务上应该按场景收紧,聊天消息设 64KB、文件分片设 1MB,超过直接以 1009 关闭。不设上限的话,一个恶意客户端发个超大帧就能把服务端内存打满。第二个是分片发送的接收端缓冲,如果对端发来一个 500MB 的分片消息,库在合并之前会一直往内存里堆,同样有风险。
真正需要自己设计边界的是业务协议层。文本帧里塞 JSON 是最常见的做法,简单直观,但要注意几个点:JSON 解析是同步的,超大对象会把事件循环卡住;字段类型要严格校验,别信客户端传来的任何东西;消息里最好带type和seq,前者用于路由,后者用于去重和补偿。二进制场景下用 Protobuf 或 MessagePack 更省带宽,但调试成本高,建议在消息头保留一个字节的版本号和类型,方便后续协议演进。
4.2 慢客户端与发送队列的背压设计
广播场景下最容易出事的就是慢客户端。一万个在线连接,你遍历发一条消息,大部分连接瞬间写完,但总有那么几十个手机信号差的,TCP 发送窗口满了,写操作开始阻塞。如果代码是同步遍历写,整个广播循环就被这几十个连接拖住,其他九千多个用户跟着一起卡。
正确的做法是每个连接一个发送队列,业务侧只负责入队,真正的写操作由每个连接自己的写协程消费。队列要有上限,比如 1MB 或者 100 条消息,超过阈值就判定这个客户端消费不过来,直接断开或者丢弃非关键消息。浏览器的bufferedAmount属性和 Node.js ws 库的同名属性可以直接读到待发送字节数,Go 的 gorilla/websocket 没有暴露这个值,需要自己在写协程里统计。
type Conn struct { ws *websocket.Conn send chan []byte once sync.Once } func (c *Conn) writeLoop() { for msg := range c.send { c.ws.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := c.ws.WriteMessage(websocket.TextMessage, msg); err != nil { c.close() return } } } func (c *Conn) trySend(msg []byte) bool { select { case c.send <- msg: return true default: // 队列满,说明客户端消费不过来 c.close() return false } }这段代码里trySend的非阻塞写是关键。队列满了不等待,直接关连接,把资源让给健康客户端。有人会担心误杀,实际经验是队列设到 1MB 还被填满,基本可以确定这个连接有问题,留着只会拖累整体。
4.3 单写者模型:并发写为什么会炸
gorilla/websocket 的文档里有一句很醒目的话:一个连接不支持并发写。Node.js 的 ws 库虽然内部做了排队,但官方也建议串行化写操作。原因在于 WebSocket 帧是连续字节流,两个协程同时写,帧头和负载会交错,对端解析直接失败。
单写者模型就是每个连接只允许一个协程执行写操作,其他协程通过 channel 把数据交给它。上面那段 Go 代码就是标准实现:sendchannel 是唯一的写入入口,writeLoop是唯一的写者。读和写分离也很重要,读协程负责解析消息和更新lastActiveAt,写协程负责出队发送,两者互不阻塞。关闭的时候用sync.Once保证 channel 只关一次,避免重复关闭 panic。
还有个细节是写超时。SetWriteDeadline必须设,否则一个卡死的连接会让写协程永久阻塞,队列越堆越满。超时时间不用太长,10 秒足够,超时就直接关连接。读侧同理,SetReadDeadline要配合心跳超时设置,读超时触发就认为连接已死。这两处如果不设,服务端的连接回收就只能靠外部定时器兜底,反应会慢很多。
5. 上线部署:接入层、集群广播与容量评估
5.1 Nginx 接入层配置的超时陷阱
WebSocket 服务前面基本都会放一层 Nginx 做接入。配置本身不复杂,但几个参数配错就会出各种诡异问题。核心是这几行:
map $http_upgrade $connection_upgrade { default upgrade; '' close; } location /ws/ { proxy_pass http://ws_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_buffering off; }proxy_http_version必须是 1.1,1.0 不支持 Upgrade。Connection头不能写死upgrade,否则普通 HTTP 请求也会带上这个头,用 map 动态判断才对。proxy_read_timeout默认 60 秒,这是“连上一分钟就断”的头号元凶,要么调到比心跳间隔大很多,要么把心跳间隔压到 20 秒左右。proxy_buffering off对实时性有要求的场景建议加上,避免 Nginx 攒包导致延迟。
另外一个常被忽略的是后端握手响应时间。如果后端服务启动慢或者事件循环被阻塞,握手超过 Nginx 的超时,客户端会收到 504。压测时如果发现大量 504,先看后端握手阶段有没有同步的重操作,比如握手时查数据库鉴权,这种设计在连接数上来后必然出问题。
5.2 多实例广播的三种做法与取舍
单机服务好办,连接都在一个进程里,遍历发就行。上了多实例,一个用户连在 A 实例、另一个连在 B 实例,A 实例产生的消息怎么让 B 实例的用户收到,就需要跨实例广播。
第一种是 Redis 发布订阅。每个实例订阅自己关心的频道,有消息就 publish,所有实例收到后推给本地连接。实现简单,延迟低,缺点是消息不持久化,实例重启期间的消息就丢了,而且频道多了以后 Redis 的压力不小。适合消息重要性一般、允许少量丢失的场景,比如在线状态同步、轻量通知。
第二种是消息队列,Kafka、NATS 这类。优点是可靠、可回放、能削峰,缺点是链路长、延迟比发布订阅高,运维成本也高。适合消息不能丢的场景,比如订单状态变更、协作编辑的操作日志。用的时候注意消费组的设计,每个实例一个消费组能保证每条消息只被一个实例处理,但要广播给所有实例就得每个实例独立消费组,这个语义别搞混。
第三种是一致性哈希路由。按用户 ID 或房间 ID 做哈希,把同一房间的连接尽量路由到同一实例,这样房间内的广播就退化成单机操作。优点是省掉了跨实例通信,缺点是扩缩容时连接会迁移,需要处理迁移期间的消息一致性。规模不大时这招最省事,规模大了再叠加发布订阅做兜底。
实际项目里我一般用组合方案:房间内消息走一致性哈希,尽量本地消化;全局通知和跨房间消息走发布订阅。两层各司其职,比单一方案灵活。
5.3 容量评估:连接数、内存与压测方法
容量评估要先看文件描述符。Linux 默认单进程 1024,ulimit -n一查就知道,systemd 管理的服务还要在 unit 文件里设LimitNOFILE=65535。系统级的fs.file-max也要确认够大。这一层没调,连接数到一千左右就开始报too many open files,日志里一堆 accept 失败。
内存方面,每条连接占用的不只是 socket 缓冲,还有库的内部结构、业务上挂的用户信息、读写缓冲。Node.js 下一条空闲连接大概十几到几十 KB,Go 会小一些。按 30KB 估算,一万连接大约 300MB,加上业务对象和 GC 余量,预留一倍比较稳妥。这个数字不是绝对的,消息越大、队列越长,占用越高,所以发送队列上限一定要设。
压测方法上,别用普通的 HTTP 压测工具去模拟,握手协议对不上。自己写脚本最靠谱:先建 N 条连接,全部握手成功后保持心跳,然后按比例发消息,统计握手成功率、消息往返延迟的 P99、服务端 CPU 和内存。节奏上从一千连接起步,每轮加一千,每轮稳定跑五分钟再看下一轮,这样能看出拐点在哪。重点观察三个指标:握手 QPS 的上限、在线连接数增长时内存的斜率、广播扇出时的延迟变化。拐点出现的那一轮,就是当前配置下的容量上限。
5.4 安全加固清单
生产环境的安全项不多,但每一条都不能省。传输层必须用 wss,明文 ws 在公网上等于把消息广播给链路上的所有人。Origin 要做白名单校验,浏览器场景下这是防跨站劫持的主要手段,服务端在握手阶段比对Origin头,不在名单里的直接 403。
鉴权放在握手阶段做,token 不要放 URL query,会被各种日志记录下来。浏览器不能自定义握手头,可行的方案有两个:一是用 Cookie,配合 SameSite 和 HttpOnly;二是用子协议字段,new WebSocket(url, ['bearer', token]),服务端从Sec-WebSocket-Protocol里读。后者更干净,但要注意子协议字段本身也有长度限制。
限流要分两个维度:每 IP 的最大连接数,防止单机刷连接;每连接的消息速率,防止单连接高频发消息打满 CPU。消息体大小、JSON 嵌套深度、字段长度都要有上限。关闭连接时返回的原因字符串不要带内部错误细节和堆栈,避免信息泄露。这几条做到,基本的安全面就守住了。
6. 排障实录:那些让人抓头的现场
6.1 连不上与连上就断
连不上的问题,先看 HTTP 状态码,对照下面这张表基本能定位到八九成。
| 现象 | 常见原因 | 排查动作 |
|---|---|---|
| 400 Bad Request | 缺少 Sec-WebSocket-Key 或格式错误 | 抓包看握手请求头是否完整 |
| 401 Unauthorized | 握手鉴权失败 | 检查 token 是否过期、传递位置是否正确 |
| 403 Forbidden | Origin 不在白名单 | 对比请求 Origin 与服务端配置 |
| 404 Not Found | 路径不匹配或被接入层改写 | 检查 location 规则和 rewrite |
| 426 Upgrade Required | 协议版本不是 13 | 确认客户端库版本 |
| 502 Bad Gateway | 接入层没转发 Upgrade 头 | 检查 proxy_set_header 配置 |
| 504 Gateway Timeout | 后端握手处理太慢 | 看后端握手阶段是否有同步重操作 |
连上就断的情况,先看关闭码。1006 且间隔很规律,比如每次都是整 60 秒,基本可以断定是某一层的空闲超时在掐连接,把心跳间隔调到它的一半以下再观察。1002 是协议错误,通常是客户端发的帧不合法,比如该加掩码没加、RSV 位被置了、文本帧不是合法 UTF-8。1011 是服务端主动报错,去翻服务端日志找异常堆栈。
6.2 内存曲线一路上扬
连接数没涨但内存一直涨,第一个怀疑对象是连接表里的僵尸对象。打个堆快照,看连接对象和它引用的东西有没有被回收。常见原因是定时器没清、事件监听没解绑、闭包持有大对象。我遇到过一次是每个连接上挂了一个setInterval做业务轮询,连接关了定时器还在跑,回调里又引用了连接对象,等于每条断开的连接都留了一个永久泄漏点。
第二个怀疑对象是发送队列。慢客户端不断堆积消息,队列上限又设得很大,内存自然涨。把队列上限调小、加上超时关闭,曲线很快就平了。第三个是消息缓冲没有及时释放,比如把收到的二进制数据一直存在一个全局数组里做“最近消息”缓存,忘了设淘汰策略。这类问题看内存增长的斜率最直观:和连接数成正比的,查连接相关;和消息量成正比的,查缓冲相关。
6.3 延迟与“乱序”的真相
有人反馈消息延迟高,先确认延迟发生在哪一段。客户端打时间戳、服务端收到时打一个、发出时再打一个,三段一比就知道是上行慢、处理慢还是下行慢。上行慢常见于移动网络,属于物理限制;处理慢看事件循环有没有被同步任务阻塞,JSON 解析大对象、同步读文件都是嫌疑;下行慢看发送队列有没有积压。
至于“乱序”,WebSocket 在单条连接上是严格有序的,TCP 保证了字节顺序,帧也按序到达。用户感知到的乱序,一般是三种情况:消息经过了不同的实例或不同的队列,到达客户端的路径不一致;断线重连后旧消息和新消息混在一起,没有按序号排序;业务层用了多个并发请求,响应回来的顺序和发出顺序不同。解决思路统一是给消息加序号,客户端按序号排序和去重,重连后从最后序号处补齐。
最后分享一个我自己的习惯:在服务端加一个轻量的诊断接口,返回当前连接数、各关闭码的计数、平均心跳 RTT、发送队列超限次数。这几个数字平时不起眼,出问题时能省掉大量猜测。比如关闭码里 1006 突然飙升,说明链路层出了问题;队列超限次数上涨,说明有批客户端网络变差或者服务端发送变慢。指标先埋好,排障的时候才有据可依。