WebSocket 与 HTTP/2 多路复用在长连接中的线头阻塞(HOLB)与退化分析
问题从一次跨境长连接抖动开始
去年冬天排查一条上海到法兰克福的行情推送链路,客户端使用单个 WebSocket 连接承载 12 路订阅,服务端每路订阅独立推送。监控显示 RTT 稳定在 198ms 到 215ms 之间,丢包率 0.6%,但客户端感知的推送延迟 P99 从 40ms 跳到了 3.8s。抓包后看到的现象很典型:一个 1400 字节的推送帧在传输中丢了一个 TCP 段,内核触发快速重传,后续所有订阅的帧全部卡在接收缓冲区里等待那个缺失段补齐。12 路订阅里只有 1 路丢包,另外 11 路被一起拖慢。
这就是线头阻塞(Head-of-Line Blocking,HOLB)在长连接场景下的真实代价。它不发生在应用层,不在 WebSocket 帧层,也不在 HTTP/2 的流层,而是发生在 TCP 的字节流有序交付语义里。任何在单条 TCP 连接上做应用层多路复用的方案,都必须正面回答这个问题。
这篇文章按物理层、传输层、应用层的顺序拆解 HOLB,对比 WebSocket 单通道、HTTP/2 双向流、HTTP/3 独立流状态机三种模型在不稳定链路上的吞吐衰减,最后落到高并发代理转发中的内核接收窗口压迫、心跳保活机制,以及用 ConnProof 做长时间压力观测的具体命令。文中出现的 RFC 编号、sysctl 参数、Wireshark 过滤表达式都来自实际排障现场。
TCP 有序交付语义与 HOLB 的物理来源
字节流抽象与接收窗口
TCP 向应用层承诺的是一个有序、可靠、无重复的字节流(RFC 9293 定义的 byte stream service)。这个承诺的实现依赖三个机制协同:序列号(Sequence Number)、累积确认(Cumulative ACK)、接收窗口(Receive Window)。累积确认的含义是接收方只能确认“到某个序列号为止全部收到”,中间缺一个段,后面的段即使已经进入内核缓冲区,也不能交付给应用层。
发送方 网络 接收方内核
seq=1000 len=1400 ──────────────────────────────► [1000..2399] 已收
seq=2400 len=1400 ──────── X 丢失 ──────────────► (空洞)
seq=3800 len=1400 ──────────────────────────────► [3800..5199] 缓存但不可交付
seq=5200 len=1400 ──────────────────────────────► [5200..6599] 缓存但不可交付
接收方回 ACK=2400 (SACK 携带 3800-6599)
应用层 read() 只能拿到 [1000..2399],后续全部阻塞2
3
4
5
6
7
8
从内核视角看,接收方维护 tp->rcv_nxt(下一个期望序列号),只有序列号等于 rcv_nxt 的段才能推进它并把数据搬进 sk_receive_queue 供应用读取。乱序到达的段进入 out_of_order_queue(红黑树组织),在 tcp_data_queue 里等待空洞填补。空洞填补之前,应用层的 recv() 调用返回的字节数被卡在 rcv_nxt 处。
这就是 HOLB 的物理来源。它跟应用层用了什么协议无关,跟 WebSocket 还是 HTTP/2 无关,只跟“是否共用同一条 TCP 连接”有关。
丢包检测与恢复时延
丢包恢复的时延直接决定了 HOLB 的持续时间。Linux 内核的丢包检测有三条路径:
- 快速重传(Fast Retransmit):收到 3 个重复 ACK(dup ACK)后立即重传。前提是后续段还能到达接收方并触发 dup ACK。如果丢的是窗口尾部最后一个段,后面没有段能触发 dup ACK,快速重传失效。
- RACK-TLP(RFC 8985):基于时间与 SACK 信息推断丢包,比传统 dup ACK 更快,现代内核默认开启(
net.ipv4.tcp_recovery=1)。 - RTO 超时重传:最坏情况,等待重传超时定时器。RTO 最小值受
net.ipv4.tcp_rto_min约束,默认 200ms,实际值按 RFC 6298 的 SRTT/RTTVAR 公式计算,通常为SRTT + 4*RTTVAR,跨境链路常在 400ms 到 1.2s 之间。
跨境链路上 RTT 200ms,一个段丢失后,即使快速重传成功,从丢包发生到缺失段到达接收方,至少需要 1 个 RTT 的重传往返,加上 dup ACK 传播的半个 RTT,实测恢复时间在 1.5 到 2.5 个 RTT,即 300ms 到 500ms。如果触发 RTO,恢复时间乘以 2 到 3 倍。
在这 300ms 到 500ms 内,接收方 rcv_nxt 无法推进,应用层所有依赖这条连接的读取全部停摆。对于 12 路订阅复用一条连接的情况,12 路同时停摆。
带宽时延积与缓冲区放大
更隐蔽的问题在缓冲区。接收窗口大小决定了“能缓存多少乱序段”,窗口越大,单个丢包能阻塞的数据量越大。带宽时延积(BDP)公式:
BDP = Bandwidth × RTT一条 100Mbps、RTT 200ms 的跨境链路,BDP = 100e6/8 × 0.2 = 2.5MB。要让这条链路跑满,接收窗口至少 2.5MB。Linux 默认 net.ipv4.tcp_rmem = 4096 131072 6291456,最大 6MB,自动调优(tcp_moderate_rcvbuf=1)会按 BDP 放大窗口。
窗口放大到 2.5MB 意味着单个丢包发生时,最多有 2.5MB 的乱序数据堆在 out_of_order_queue 里等待一个段。如果这是一个 1kbps 的推送流,2.5MB 数据相当于 5 小时的内容量被一起卡住。HOLB 的阻塞深度与窗口大小成正比,与丢包位置无关。
WebSocket 单通道模型:多路复用的最坏形态
帧层无多路复用语义
WebSocket 协议(RFC 6455)定义了帧结构:FIN、RSV1-3、Opcode、Mask、Payload Length、Masking Key、Payload Data。Opcode 区分 text(0x1)、binary(0x2)、close(0x8)、ping(0x9)、pong(0xA)。协议层没有流 ID、没有优先级、没有独立的流状态机。
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +2
3
4
5
6
7
8
9
10
11
12
13
14
应用层要在一条 WebSocket 连接上承载多路订阅,只能自己在 Payload 里封装多路复用头(比如 4 字节订阅 ID + 4 字节长度 + 数据)。这个封装对 TCP 完全透明,TCP 看到的仍然是一个字节流。任何一段丢失,后续所有订阅的帧全部阻塞。
退化曲线实测
我在一条 RTT 200ms、丢包率 0.8% 的链路上做过对比。12 路订阅,每路每秒推送 20 帧,每帧 800 字节,总吞吐约 1.5Mbps。单条 WebSocket 连接承载。
| 丢包率 | 平均推送延迟 | P99 延迟 | 有效吞吐 |
|---|---|---|---|
| 0.0% | 12ms | 28ms | 1.52Mbps |
| 0.3% | 45ms | 620ms | 1.48Mbps |
| 0.8% | 180ms | 3.8s | 1.31Mbps |
| 2.0% | 720ms | 12s | 0.94Mbps |
| 5.0% | 2.4s | 超时重连 | 0.41Mbps |
丢包率从 0.3% 升到 0.8%,P99 延迟从 620ms 跳到 3.8s,翻了 6 倍。有效吞吐只掉了 12%,说明链路本身没坏,坏的是延迟分布。这就是 HOLB 的典型特征:吞吐看起来还行,延迟尾部爆炸。用户感知的是“消息偶尔卡住好几秒”,而不是“整体变慢”。
拆成 12 条独立 WebSocket 连接后,同样的链路条件:
| 丢包率 | 平均推送延迟 | P99 延迟 | 有效吞吐 |
|---|---|---|---|
| 0.8% | 38ms | 410ms | 1.46Mbps |
| 2.0% | 95ms | 1.2s | 1.38Mbps |
P99 从 3.8s 降到 410ms。代价是 12 条连接各自的握手、TLS 开销、心跳包、内核 socket 内存。12 条连接在服务端意味着 12 个 struct sock、12 份接收缓冲区、12 套拥塞控制状态。这就是权衡。
为什么“多路复用省连接”在弱网下是负优化
单连接多路复用的收益在稳定链路上很明显:省握手、省 TLS、省文件描述符、省内核内存。但在丢包率超过 0.5% 的链路上,收益被 HOLB 的延迟放大吃掉。判断标准可以量化:
单连接 HOLB 期望延迟 ≈ 丢包率 × 平均恢复时间 × 复用路数
多连接独立期望延迟 ≈ 丢包率 × 平均恢复时间2
复用路数 12,丢包率 0.8%,恢复时间 400ms,单连接模型的额外延迟期望约 38ms 一次,但 P99 会放大到秒级。多连接模型下,单路丢包只影响该路,其他 11 路不受牵连。
HTTP/2 流层多路复用:优先级能解决什么,不能解决什么
帧结构与流状态机
HTTP/2(RFC 9113)在二进制帧层引入 Stream Identifier,一条 TCP 连接上可以并发多条流。帧头 9 字节:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Length (24) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Type (8) | Flags (8) |
+-+-+-+-+-+---------------------+
|R| Stream Identifier (31) |
+=+=============================================================+
| Frame Payload (0...) ...
+---------------------------------------------------------------+2
3
4
5
6
7
8
9
10
11
流状态机(RFC 9113 §5.1)定义 idle、reserved、open、half-closed、closed 五种状态,每条流独立维护。应用层看到的是多条逻辑流并发,但底层仍然是单条 TCP 字节流。
优先级与依赖树
RFC 9218 定义了 Extensible Prioritization Scheme,用 urgency(0-7)和 incremental 两个参数表达优先级。之前的 RFC 7540 优先级树(依赖权重)在 RFC 9113 里被废弃,因为实现复杂且效果不佳。
优先级能做什么:当连接有可用发送窗口时,调度器优先发送高 urgency 流的帧。它能缓解发送侧的排队延迟。
优先级不能做什么:它无法改变接收侧 rcv_nxt 的推进条件。一个低优先级流的段丢失,高优先级流的段即使已经到达接收方,也卡在 out_of_order_queue 里无法交付。优先级只管发送调度,不管接收交付。
这是 HTTP/2 在弱网下最容易被误解的地方。很多团队以为配了优先级就能让关键请求不被拖累,实测下来在丢包链路上优先级对 P99 的改善不到 15%。
实测退化对比
同样的 12 路订阅场景,改用 HTTP/2 双向流承载(每路订阅一条流),链路条件 RTT 200ms、丢包率 0.8%:
| 模型 | 平均延迟 | P99 延迟 | 备注 |
|---|---|---|---|
| WebSocket 单连接 | 180ms | 3.8s | 无流隔离 |
| HTTP/2 单连接 + 优先级 | 165ms | 3.2s | 优先级改善约 15% |
| HTTP/2 单连接 + 无优先级 | 178ms | 3.7s | 与 WebSocket 接近 |
| HTTP/2 多连接(每流一连接) | 42ms | 460ms | 退化为多连接模型 |
HTTP/2 的流隔离是逻辑隔离,不是传输隔离。丢包发生在 TCP 层,流层状态机再独立也无法绕过 rcv_nxt。
一个反直觉的细节:HPACK 动态表与 HOLB 的耦合
HTTP/2 的头部压缩 HPACK(RFC 7541)维护一个动态表,编码端和解码端同步更新。动态表的更新依赖头部块的到达顺序。如果一个头部块丢失,后续头部块的解码依赖前一个块的动态表状态,必须等待重传。这引入了第二层 HOLB:头部块层的顺序依赖。
HEADERS (stream 1) ──► 动态表更新 [a, b, c]
HEADERS (stream 3) ──► 依赖动态表 [a, b, c],引用索引
HEADERS (stream 5) ──► 依赖动态表 [a, b, c, d],引用索引2
3
如果 stream 1 的 HEADERS 帧丢失,stream 3 和 5 的 HEADERS 即使到达也无法解码,因为它们的动态表引用指向尚未到达的条目。这个阻塞发生在应用层协议栈内部,比 TCP 层的 HOLB 更隐蔽,抓包时看到的是“帧都到了但解析不出来”。
规避手段是设置 SETTINGS_HEADER_TABLE_SIZE=0 禁用动态表,代价是头部无法压缩,每个请求都带完整头部。在长连接推送场景下,头部占比小,禁用动态表是合理的取舍。
HTTP/3 独立流状态机:把 HOLB 从传输层移除
QUIC 流与包的双层结构
HTTP/3(RFC 9114)跑在 QUIC(RFC 9000)之上。QUIC 的核心设计是把“流”下沉到传输层:每条流有独立的序列号和重传状态,一个流的包丢失只阻塞该流,不影响其他流。
QUIC 包(UDP 载荷)
┌─────────────────────────────────────────┐
│ Header (短头/长头) │
├─────────────────────────────────────────┤
│ Frame 1: STREAM[stream_id=0, offset=0] │
│ Frame 2: STREAM[stream_id=4, offset=0] │
│ Frame 3: ACK │
└─────────────────────────────────────────┘2
3
4
5
6
7
8
一个 UDP 包可以携带多个流的 STREAM 帧。包丢失时,包内所有帧一起丢失,只有这些帧所属的流被阻塞。其他流的帧在后续包里独立到达,独立交付。
QUIC 的 ACK 帧携带包号范围,重传以包为单位。流层的 offset 独立推进,stream_id=0 的空洞不影响 stream_id=4 的交付。这是传输层级的流隔离。
丢包恢复与退化曲线
同样的 12 路订阅、RTT 200ms、丢包率 0.8% 条件,HTTP/3 单连接:
| 模型 | 平均延迟 | P99 延迟 | 有效吞吐 |
|---|---|---|---|
| HTTP/3 单连接 | 48ms | 520ms | 1.44Mbps |
| HTTP/2 多连接 | 42ms | 460ms | 1.46Mbps |
HTTP/3 单连接的 P99 接近 HTTP/2 多连接模型,同时保留了单连接的开销优势。这是 QUIC 设计目标的直接体现。
但 HTTP/3 不是银弹,退化边界在三个地方:
- 包粒度丢包:一个 UDP 包携带多个流的帧,包丢失时这些流一起阻塞。如果调度器把 12 路订阅的帧塞进同一个包,丢一个包 12 路一起卡。QUIC 实现需要控制每包携带的流数量,避免包级 HOLB。
- 队头阻塞转移到拥塞控制:QUIC 的拥塞控制是连接级的(RFC 9002 定义 NewReno 和 CUBIC),丢包触发拥塞窗口减半,所有流的发送速率一起下降。流隔离解决了交付阻塞,没解决速率耦合。
- UDP 中间盒干扰:部分跨境链路的 NAT/防火墙对 UDP 有额外限速或丢包策略,QUIC 的实际丢包率可能高于 TCP。实测中有些链路的 QUIC 丢包率是 TCP 的 2 到 3 倍。
0-RTT 与连接迁移的副作用
QUIC 的 0-RTT(RFC 9001 §4.6)允许客户端在首次飞行就发送应用数据,减少一个 RTT 的握手延迟。代价是 0-RTT 数据没有前向保密,且可能被重放。对于行情推送这类幂等场景可以接受,对于交易指令类场景必须禁用。
连接迁移(RFC 9000 §9)允许客户端 IP 变化时保持连接。移动网络切换 Wi-Fi 时,连接不中断。这个特性对长连接稳定性有实际价值,但服务端需要处理路径验证(PATH_CHALLENGE/PATH_RESPONSE),增加了实现复杂度。
高并发代理转发中的内核接收窗口压迫
代理转发的双缓冲结构
反向代理(Nginx、Envoy、HAProxy)转发 WebSocket 时,数据路径是:
客户端 ──► 代理 upstream socket ──► 代理缓冲区 ──► 代理 downstream socket ──► 服务端
(recv) (用户态) (send)2
代理在两侧各有一个 socket,数据从一侧读进用户态缓冲区,再写到另一侧。两侧的接收窗口独立,但代理的用户态缓冲区是共享资源。
高并发场景下,代理要同时处理数千条连接,每条连接的用户态缓冲区占用内存。当上游发送速率超过下游消费速率时,代理缓冲区积压,触发背压。背压通过 recv() 停止读取传导到上游 socket,上游接收窗口填满,发送方停止发送。
接收窗口压迫的连锁反应
问题出在窗口填满的那一刻。上游 socket 的接收窗口填满后,发送方进入零窗口探测(Zero Window Probe,ZWP)状态,定期发送 1 字节探测包。如果代理的处理延迟抖动,窗口反复开合,发送方的拥塞窗口会误判为拥塞,触发不必要的减窗。
代理上游 socket 接收窗口: 128KB
服务端发送速率: 2Mbps
代理下游消费速率: 1.8Mbps(抖动)
窗口填满时间: 128KB / (2Mbps - 1.8Mbps) ≈ 5.1s
窗口填满后: 服务端进入 ZWP,每 500ms 探测一次
代理恢复消费: 窗口重新打开,服务端恢复发送2
3
4
5
6
窗口开合周期约 5 秒一次,每次开合服务端的拥塞窗口都会波动。实测中这种周期性压迫会让有效吞吐比理论值低 20% 到 30%。
调优方向是增大代理上游 socket 的接收缓冲区,让窗口不轻易填满,同时提高代理的消费速率稳定性。net.ipv4.tcp_rmem 的第三个值控制接收缓冲区上限,跨境链路建议设为 4MB 到 8MB:
net.ipv4.tcp_rmem = 4096 262144 8388608
net.ipv4.tcp_wmem = 4096 262144 8388608
net.core.rmem_max = 8388608
net.core.wmem_max = 83886082
3
4
注意 tcp_rmem 的最大值受 net.core.rmem_max 约束,两者要一起调。自动调优开启时(tcp_moderate_rcvbuf=1),内核会按 BDP 自动放大缓冲区,但上限是 tcp_rmem 的第三个值。
代理缓冲区与 HOLB 的叠加
代理的用户态缓冲区如果是单队列,所有连接的数据混在一起,一个连接的慢消费会拖累其他连接。Envoy 的 per-connection buffer 和 Nginx 的 proxy_buffering off 都是为了隔离。WebSocket 场景下应该关闭代理缓冲,让数据直通,避免代理层引入额外的 HOLB。
Nginx 配置示例:
location /ws {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}2
3
4
5
6
7
8
9
proxy_buffering off 让 Nginx 不在用户态缓冲响应,直接透传。代价是 Nginx 无法对慢客户端做缓冲保护,上游会被慢客户端拖慢。权衡点在于客户端质量:如果客户端网络稳定,关闭缓冲收益更大;如果客户端网络差,需要开启缓冲并设置合理的 proxy_buffers。
心跳保活机制与 HOLB 的交互
心跳的本质是探测而非保活
WebSocket 的 ping/pong(RFC 6455 §5.5.2)和 TCP keepalive(RFC 1122 §4.2.3.6)常被当成“保活”手段,实际作用是探测连接是否还活着。连接的空闲超时由中间设备(NAT、防火墙、负载均衡)决定,心跳的作用是在超时前产生流量,刷新中间设备的状态表。
心跳间隔的选择是个权衡:
- 间隔太短(< 10s):增加无效流量,移动端耗电,服务端 CPU 开销。
- 间隔太长(> 60s):中间设备可能已经超时,心跳发出时连接已断。
- 常见取值:25s 到 30s,覆盖大多数 NAT 的 60s 超时。
心跳与 HOLB 的耦合陷阱
心跳包和业务数据共用同一条 TCP 连接,心跳也会被 HOLB 阻塞。如果业务数据丢包导致 rcv_nxt 卡住,心跳包即使到达也交付不了,应用层收不到 pong,误判连接已断,触发重连。
t=0 业务帧 seq=1000 丢失
t=0 心跳 ping seq=2400 到达接收方,缓存但不可交付
t=25s 应用层等待 pong 超时,判定连接断开
t=25s 触发重连,业务中断2
3
4
这条链路上连接其实是好的,只是被一个丢失的段卡住了。重连后新连接重新握手,旧连接的恢复过程被浪费。更糟的是重连风暴:如果丢包是链路级的,所有连接同时超时重连,服务端瞬间承受数倍握手压力。
规避手段有三个:
- 心跳超时阈值大于 RTO 上限:心跳超时设为 2 到 3 个 RTO,让 TCP 有机会完成重传。跨境链路 RTO 上限约 1.2s,心跳超时设 5s 以上。
- 应用层心跳独立于业务流:用单独的连接承载心跳,或者用 UDP 做带外探测。代价是增加连接数。
- 服务端主动探测:服务端定期发 ping,客户端回 pong。服务端掌握探测节奏,避免客户端误判。
内核 keepalive 参数
TCP keepalive 由三个 sysctl 控制:
net.ipv4.tcp_keepalive_time = 600 # 空闲多久开始探测
net.ipv4.tcp_keepalive_intvl = 75 # 探测间隔
net.ipv4.tcp_keepalive_probes = 9 # 探测次数2
3
默认值 600s + 75s × 9 = 1275s 才判定断开,对长连接场景太长。跨境链路建议调到:
net.ipv4.tcp_keepalive_time = 120
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 32
3
120s + 30s × 3 = 210s 判定断开。应用层心跳(25s 到 30s)比内核 keepalive 更早发现问题,两者配合使用。
注意 keepalive 探测包不携带数据,不会推进 rcv_nxt,所以它无法绕过 HOLB。如果 rcv_nxt 卡住,keepalive 探测包到达接收方也无法被应用层感知,探测会失败,连接被误判为断开。这是 keepalive 在 HOLB 场景下的盲区。
ConnProof 长时间压力观测方法
websocket 子命令的持续观测
ConnProof 的 websocket 子命令支持对 WebSocket 连接做握手、帧收发、延迟测量。长时间压力观测的命令:
connproof websocket wss://push.example.com/stream --timeout 3600 --verbose--timeout 3600 让观测持续 1 小时,--verbose 输出每一帧的收发时间戳、帧类型、载荷长度。输出格式可以用 --format json 转成结构化数据,便于后续分析:
connproof websocket wss://push.example.com/stream --timeout 3600 --format json > ws_observe.jsonlJSONL 每行一条记录,包含 ts(毫秒时间戳)、direction(send/recv)、opcode、payload_len、rtt_ms。用 jq 分析延迟分布:
jq -r 'select(.direction=="recv") | .rtt_ms' ws_observe.jsonl | sort -n | awk '{a[NR]=$1} END {print "P50:", a[int(NR*0.5)], "P99:", a[int(NR*0.99)]}'结合 tcp 子命令定位 HOLB
websocket 子命令测的是应用层延迟,要定位 HOLB 需要看传输层。tcp 子命令可以观测连接的 RTT、重传、窗口:
connproof tcp push.example.com --port 443 --timeout 600 --verbose输出包含 retrans(重传次数)、rtt_avg、rtt_var、rcv_wnd(接收窗口)、snd_cwnd(拥塞窗口)。HOLB 的特征是 retrans 上升、rtt_var 增大、rcv_wnd 波动。
两个命令同时跑,用时间戳对齐,可以建立“重传发生 → 应用层延迟上升”的因果链:
connproof tcp push.example.com --port 443 --timeout 3600 --format json > tcp_observe.jsonl &
connproof websocket wss://push.example.com/stream --timeout 3600 --format json > ws_observe.jsonl &
wait2
3
分析时按秒聚合,看重传秒的下一到两秒内应用层延迟是否跳升。这个相关性是 HOLB 的直接证据。
diagnose 与 doctor 的全局体检
diagnose 子命令做端到端诊断,覆盖 DNS、TCP、TLS、HTTP 各层:
connproof diagnose push.example.com --port 443 --verbose输出各阶段的耗时和状态。跨境链路重点看 TLS 握手耗时(超过 3 个 RTT 说明有额外往返)和 TCP 建连耗时(超过 1 个 RTT 说明有 SYN 重传)。
doctor 子命令检查本地环境的网络配置,包括 sysctl 参数、MTU、路由:
connproof doctor --verbose它会列出 tcp_rmem、tcp_wmem、tcp_congestion_control、tcp_notsent_lowat 等关键参数,并给出与当前链路 BDP 匹配的建议值。跨境链路上 tcp_congestion_control 建议用 bbr,比 cubic 在高丢包链路上吞吐高 30% 到 50%。
Wireshark 过滤表达式
ConnProof 的观测是黑盒视角,要看到包级细节需要抓包。HOLB 排查常用的 Wireshark 过滤:
## 只看重传
tcp.analysis.retransmission
## 只看乱序
tcp.analysis.out_of_order
## 只看零窗口
tcp.analysis.zero_window
## 只看 SACK 块
tcp.options.sack
## 组合:某条连接的重传与乱序
tcp.port == 443 && (tcp.analysis.retransmission || tcp.analysis.out_of_order)2
3
4
5
6
7
8
9
10
11
12
13
14
tcp.analysis.out_of_order 是 HOLB 的直接证据:接收方收到乱序段,说明前面有空洞。配合 tcp.analysis.retransmission 看空洞何时被填补,两者时间差就是 HOLB 持续时间。
长时间观测的采样策略
1 小时的观测数据量很大,全量分析成本高。采样策略:
- 应用层延迟:全量记录,数据量小。
- TCP 层指标:每 5 秒采样一次,记录瞬时值。
- 抓包:只在延迟异常时触发,用
tcpdump -w环形缓冲,保留最近 60 秒。
tcpdump -i eth0 -s 128 -w /tmp/cap.pcap -C 100 -W 5 'tcp port 443'-C 100 每 100MB 切一个文件,-W 5 保留 5 个文件循环覆盖。延迟异常时停止抓包,分析最近 5 个文件。
应用层架构建议
按业务语义分组连接
不是所有多路复用都要拆成多连接,也不是所有场景都要单连接。分组依据是业务语义的耦合度:
- 强耦合:同一笔订单的下单、成交、撤单推送,必须有序,放同一条连接。丢包时一起阻塞可接受,因为业务本身要求顺序。
- 弱耦合:不同股票的行情推送,互相独立,拆多连接。一路丢包不影响其他路。
- 心跳与业务分离:心跳走独立连接或带外探测,避免被业务 HOLB 拖累。
分组数量不是越多越好。每条连接有握手、TLS、内核内存开销。经验值是单客户端 4 到 8 条连接,超过 8 条收益递减。
应用层帧级重传
在 WebSocket 帧层之上加一层应用层确认与重传,可以绕过 TCP 的累积确认限制。每个业务帧带序列号,接收方按序列号重组,缺失的帧请求重传。
发送方: [seq=1][seq=2][seq=3][seq=4]
接收方: 收到 1,2,4,缺 3
接收方: 发送 NACK(seq=3)
发送方: 重传 seq=3
接收方: 重组 1,2,3,4,交付应用层2
3
4
5
这层重传的代价是增加一个 RTT 的恢复延迟,但避免了 TCP 层 rcv_nxt 卡住导致的全连接阻塞。适合对延迟敏感、对吞吐不敏感的场景。
拥塞控制算法选择
跨境链路丢包率高,CUBIC 把丢包一律当拥塞信号,减窗激进。BBR 基于带宽和 RTT 建模,对随机丢包不敏感,高丢包链路上吞吐优势明显。
net.ipv4.tcp_congestion_control = bbr
net.core.default_qdisc = fq2
BBR 需要 fq 队列配合,否则效果打折扣。开启后跨境链路吞吐通常提升 30% 到 50%,重传率下降。
BBR 的边界:在浅缓冲链路上 BBR 可能过度占用带宽,影响同链路其他流。多租户环境需要评估。
接收窗口与 notsent_lowat
net.ipv4.tcp_notsent_lowat 控制发送缓冲区中未发送数据的水位。默认值很大,意味着发送缓冲区可以积压大量未发送数据,增加延迟。长连接推送场景建议调小:
net.ipv4.tcp_notsent_lowat = 131072128KB 的水位让发送方及时把数据推出去,减少发送侧排队延迟。代价是吞吐可能略降,因为发送窗口利用率降低。
失败边界与不适用场景
HTTP/3 不是所有链路的解
前面提到 QUIC 在部分跨境链路上丢包率高于 TCP,原因是中间盒对 UDP 的处理策略不同。部署 HTTP/3 前必须实测目标链路的 QUIC 可达性和丢包率。ConnProof 的 diagnose 子命令可以测 QUIC 握手:
connproof diagnose push.example.com --port 443 --verbose输出里如果有 QUIC 阶段,会显示 QUIC 握手耗时和版本协商结果。如果 QUIC 握手失败或耗时超过 TCP 的 2 倍,说明该链路不适合 HTTP/3。
多连接模型的资源上限
拆多连接解决了 HOLB,但每条连接占用一个文件描述符、一份内核 socket 内存(约 4KB 到 16KB)、一份 TLS 状态。单机 10 万连接时,内存占用约 1GB 到 1.6GB,文件描述符需要调到 20 万以上:
fs.file-max = 1000000
net.core.somaxconn = 655352
超过这个量级,单机扛不住,需要水平扩展。多连接