03 Netty 双通道长连接层:三条义务,一条都不能少
系列第 3 篇。长连接层只对上层承诺三件事:不阻塞 event loop、 能对慢客户端施加反压、能识别死连接。本篇把这三条拆到 pipeline、串行队列、 背压状态机与心跳状态机,并给出每条规则被违反时的具体故障形态。
1. 边界:长连接层做什么、不做什么
| 做 | 不做 |
|---|---|
| 解帧、认证(首帧或握手参数)、心跳、写帧、摘流 | 判离线、存消息、发号、路由决策 |
维护本机 userId → deviceId → session | 维护「这个账号一共几台设备」(跨实例事实归 IM 服务) |
上报在线路由 im:gateway:user:{u}(SET,TTL 30s) | 决定一条消息该投给谁(归 MessageRouter) |
| 上行转发(本地直调 / gRPC) | 消息体解析(帧只透传) |
最后一条容易被破:LocalSessionPusher 的类注释明确写着「帧内容只透传,不做任何业务解析」。 一旦网关开始理解消息体(例如为了做敏感词过滤),新增业务字段就要发布网关——那正是 「双通道 + 帧透传」想避免的成本。
2. pipeline:装配顺序本身就是正确性
WebSocket:
ws-flush-consolidate → http-codec → http-aggregator → ws-handshake-gate
→ IdleStateHandler(按原始字节计时) → WebSocketServerProtocolHandler
→ StringToWebSocketFrameEncoder → HeartbeatHandler → WebSocketFrameInboundHandler
TCP:
IdleStateHandler(按原始字节计时) → LengthFieldBasedFrameDecoder(4B)
→ LengthFieldPrepender(4B) → StringDecoder/Encoder(UTF-8)
→ HeartbeatHandler → TcpFrameInboundHandler(首帧 connect 认证)
// NettyGatewayServer#websocketInitializer(节选)
pipeline.addLast("ws-flush-consolidate", new FlushConsolidationHandler(256, true));
pipeline.addLast("http-codec", new HttpServerCodec());
pipeline.addLast("http-aggregator", new HttpObjectAggregator(MAX_HTTP_HANDSHAKE_BYTES));
pipeline.addLast("ws-handshake-gate", new WebSocketHandshakeGate(drainService, wsPath, authGuard));
addIdleTimer(pipeline, "ws", heartbeat);
pipeline.addLast("ws-protocol", new WebSocketServerProtocolHandler(wsPath, null, true, maxFrameBytes));
pipeline.addLast("ws-encoder", new StringToWebSocketFrameEncoder());
BackpressureState backpressure = new BackpressureState();
addHeartbeatHandler(pipeline, "ws", heartbeat, backpressure);
pipeline.addLast("ws-handler",
new WebSocketFrameInboundHandler(lifecycle, businessDispatcher.serial("ws"), backpressure));
三处位置是经验教训而不是风格(完整复盘见第 8 节):
| 位置 | 规则 | 违反后的现象 |
|---|---|---|
IdleStateHandler | 必须在协议处理器之前 | WebSocketServerProtocolHandler 会消费协议级 Ping 帧,放后面就看不到它,只用协议级 ping 的客户端被误判为死连接 |
HeartbeatHandler | 必须在出站编码器之后 | 它要往客户端写 ping;裸 String 会漏到 HTTP 层/字节层报错 |
| 入站 handler | 必须每连接 new | 它持有本连接的 session 字段;共享会让不同连接串用同一身份(它也不是 @Sharable) |
握手闸门(WebSocketHandshakeGate)在 101 之前决定三件事并给出明确状态码: 摘流中 → 503;路径不匹配 → 404;缺 userId → 400。 「静默丢弃」是这里最贵的失败模式——排查时间全花在「确认请求到底有没有到达」。
event loop 配置:boss 固定 1 线程,worker 走默认(2 × CPU);Linux 上 Epoll (Netty 4.2 的 MultiThreadIoEventLoopGroup + Epoll),其它平台回落 NIO。
3. 线程模型:event loop 只做「取数据 + 提交」
event loop(平台线程,固定 2×CPU 个):
解码 → serial.offer(帧) → 立刻返回,继续服务挂在本线程上的其它连接
虚拟线程(每条连接一个 SerialExecutor):
onOpen / onFrame / onClose 严格 FIFO 串行执行
这一层的设计目标是把「串行性」与「线程模型」解耦:串行由队列保证,执行交给任意虚拟线程。 于是「一条连接内的消息顺序」不再依赖「哪条线程先跑」,而依赖「队列的 FIFO」。
3.1 抽干循环:三个必须做对的细节
private void drain() {
for (; ; ) {
Runnable task = queue.poll();
if (task == null) {
draining.set(false);
resumeIfDrained();
// 复位与并发入队之间存在竞态:入队方看到 draining=true 就不会再提交,
// 所以这里必须复查一次队列,否则可能出现「任务已入队但没人执行」
if (queue.isEmpty() || !draining.compareAndSet(false, true)) {
return;
}
continue;
}
pending.decrementAndGet();
try {
task.run();
} catch (Throwable t) {
log.warn("[biz] {} 任务执行异常,继续处理后续帧", name, t);
}
resumeIfDrained();
}
}
| 细节 | 不做会怎样 | 故障形态 |
|---|---|---|
draining 标志(同一时刻最多一个执行者) | 多个虚拟线程并发执行同一连接的任务 | 顺序错乱,且 pending 计数失真(背压永不触发) |
| 退出前复查队列 + CAS | 入队方看到 draining=true 直接返回,抽干方刚好退出 | 帧永久滞留:连接还在、心跳还在、就是这条消息永远不处理 |
| 单任务异常不打断循环 | 异常逃出 drain,draining 永远为 true | 该连接后续所有帧卡死(一条坏消息毒死整条连接) |
第二条属于「教科书上的 check-then-act 竞态」,但它的表现形式不是崩溃,而是静默卡住单条连接—— 如果没有对应的并发测试,几乎不可能通过手工点击发现。
3.2 认证必须发生在任务体内
TCP 客户端常见行为:connect 帧与第一条 msg.send 连发(不等 connected 应答)
若在 channelRead0 里判「是否已认证」→ 第二帧被判未认证 → 断开
正确做法:把帧交给串行队列,在任务体内依据「前一个任务是否已完成认证」判断
这也是「串行队列」的一个隐藏收益:它把「有状态协议」的判定时机从「读到时」变成「执行时」。
3.3 吞吐的代价,必须写清楚
单连接吞吐 = 1 / 单条消息耗时。3ms 时约 300 QPS。
这个数字经常被质疑「太低」。但真实客户端一条连接的瓶颈在用户输入(每秒几条到几十条), 要更高并发应该多连接(多设备、多进程)。服务端不能为了单连接吞吐牺牲顺序语义—— 顺序是产品可见的行为,吞吐不是。
4. 背压:一个带滞回的控制闭环
4.1 三种做法的对比(只有一种同时满足「不丢」与「不断」)
| 做法 | 结果 |
|---|---|
| 超限就断连 | 客户端典型行为是「本地发件箱一次 flush」(离线期间攒了上千条)→ 重连后立刻重发同一批 → 越重试越糟的活锁 |
| 超限就丢帧 | 已读进内存的这一批丢掉会破坏顺序语义,而客户端并不知道哪些被丢了 |
暂停读取(setAutoRead(false)) | 数据停在客户端/内核缓冲区,TCP 窗口收缩,客户端被自然限速;不丢帧、不断连,内存有界 |
4.2 闭环与滞回
积压 > 高水位(maxPending = 1024) → paused=true → setAutoRead(false)
积压 < 低水位(resumeBelow = maxPending / 2) → paused=false → setAutoRead(true)
低水位取一半而不是「等于高水位」,是为了滞回(hysteresis) :若恢复阈值与暂停阈值相同, 积压会在阈值附近反复穿越,导致 setAutoRead 高频抖动,读循环被反复打断。
public void offer(Runnable task) {
int now = pending.incrementAndGet();
if (!enqueue(task)) return;
if (now > maxPending && paused.compareAndSet(false, true)) {
log.warn("[biz] {} 积压 {} 帧超过高水位 {},暂停读取(消化到 {} 帧以下自动恢复)",
name, now, maxPending, resumeBelow);
fire(onPause, "pause"); // 实现 = channel.config().setAutoRead(false)
}
}
两个边界条件必须说明:
- 上限是
maxPending + 一次 read 批次的帧数,不是maxPending。因为「暂停」是让 socket 停止读,当前这一批已经解出来在内存里了。写文档时把这一点写清,否则容量估算会偏小; maxPending是每连接独立的。5000 人群里 5000 个用户各发一条,是 5000 条连接各pending=1, 与群规模无关;真正会顶到高水位的是「单连接一次 flush 上千条」——也正好是背压要处理的场景。
4.3 控制类任务必须旁路背压
/** 无背压的控制类任务(登记会话、清理会话) */
public void submit(Runnable task) {
pending.incrementAndGet();
enqueue(task);
}
onClose 用 submit 而非 offer。理由不是性能,而是liveness 优先于 throughput: 清理任务若被推迟到队列末尾,会话表与 Redis 路由会留下脏数据——TTL 内消息会继续推到死连接上, 而客户端已经不会再收到任何东西。
4.4 指标必须成对,且由 dispatcher 统一安装
SerialExecutor serial = new SerialExecutor(name, virtualThreads, maxPendingFrames);
serial.onStateChange(paused -> paused ? metrics.bizBackpressurePaused()
: metrics.bizBackpressureResumed());
handler 只负责 setAutoRead,指标由 BusinessDispatcher 统一装:否则每个 handler 都要记得上报, 迟早漏掉一种传输(WS 有、TCP 没有,指标就永远对不上)。
判读规则(这两条是运维最常用的):
| 观察 | 含义 | 处置 |
|---|---|---|
paused_total ≈ resumed_total | 正常:进入背压后都消化完了 | — |
paused_total > resumed_total 且 paused_active > 0 持续 | 暂停了但一直没消化完 | 先查下游(Redis/DB/IM 服务),不要调大阈值 |
paused_total 恒为 0 但用户报「发不出去」 | 背压不是原因,去查上行错误码 | 看 gateway.ingress.failures{op,status} |
5. 心跳:两段式探测 + 每条连接自己的计时器
5.1 机制
- 空闲达到
ping-after-ms(默认 45s,每条连接叠加[0, jitter-ms](默认 5s)随机偏移) → 下发应用层{"type":"ping"},并挂一个在close-after-ms(默认 90s)关闭的任务; - 期间收到任意上行帧(
pong、msg.send都算)→ 取消关闭任务; - 到期仍无上行 → 断开(WS 1001 going away / TCP 断链)→
channelInactive→ 清本机会话 + Redis 路由 → 用户转离线、消息落离线队列。
三个设计点:
| 选择 | 理由 |
|---|---|
| 应用层 JSON ping | 浏览器 JS 发不了协议级 ping,TCP 也没有;只有统一到业务帧层,两种传输的心跳语义才一致。未认证连接不发 ping,第二次空闲直接断 |
| 每条连接加抖动 | 一批同时建立的连接会在同一毫秒发 ping,形成周期性尖峰;抖动把探测摊开 |
| 不做全局扫描任务 | 全局扫描的精度取决于扫描批次(最多差一个周期),且连接数增加会线性抬高单次开销;每连接自己计时则精确、无扫描 |
5.2 与背压的冲突:判据必须修正
这是本项目最值得讲的一处机制冲突。背压会 setAutoRead(false),此时本连接读不到任何入站字节, 客户端回的 pong 停在 socket 缓冲区里。若照原判据「没收到上行 ⇒ 客户端死了」, 90s 后会把一条完全健康的连接踢下线:
下游变慢 → 积压超水位 → 暂停读取 → 心跳收不到 pong → 判定超时 → 批量断连
→ 集中重连 → 上行洪峰 → 下游更慢 → 积压更多 → …(正反馈)
修正后的判据:没收到上行,且我们确实在读 ⇒ 客户端死了。
private void onBackpressurePaused() {
cancelPauseWatchdog();
cancelCloseTask();
pinged = false; // closeTask 被撤销时 pinged 仍为 true,必须复位
if (backpressureTimeoutMs <= 0 || ctx == null || !ctx.channel().isActive()) {
return;
}
pauseWatchdog = ctx.executor().schedule(() -> {
metrics.backpressureTimeout();
session.close(CLOSE_REASON_BACKPRESSURE); // 独立断连原因,便于排障
}, backpressureTimeoutMs, TimeUnit.MILLISECONDS);
}
看门狗不能省:背压若永不恢复(下游彻底 hang),抑制心跳后就再没有机制回收这条连接, 会留下「出站正常、入站永久卡死」的单向死连接——比直接断开更糟(客户端以为自己在线)。 默认 backpressure-timeout-ms = 30s。
另一个不显眼但致命的点:BackpressureState 是两条 handler 之间的共享物, 回调可能来自虚拟线程(抽干循环在虚拟线程里),而 pinged / closeTask 只允许在 event loop 上改, 所以回调必须统一切回 event loop(runOnEventLoop)。这类问题不会在单测里暴露, 只会在压测的偶发断连里出现。
5.3 状态机(把上面的叙述收敛成转移表)
| 当前态 | 事件 | 次态 | 动作 |
|---|---|---|---|
| READING | 空闲 ≥ ping-after | READING/PINGED | 发 ping,挂 closeTask |
| PINGED | 收到任意上行 | READING | 取消 closeTask,pinged=false |
| PINGED | close-after 到期 | CLOSING | 以 heartbeat-timeout 断开 |
| READING | 积压 > 高水位 | PAUSED | setAutoRead(false),取消 closeTask,挂 pauseWatchdog |
| PAUSED | 积压 < 低水位 | READING | setAutoRead(true),取消 pauseWatchdog |
| PAUSED | pauseWatchdog 到期 | CLOSING | 以 backpressure-timeout 断开 |
两种 CLOSING 的原因必须分开记录:前者查客户端/网络,后者查下游。
6. 出站:flush 合并与写失败判定
FlushConsolidationHandler(256, true)放在 pipeline 最靠 head(出站方向最后经过它): 群聊扇出时成百上千次writeAndFlush合并成少量系统调用;- 第二个参数
consolidateWhenNoReadInProgress = true保证「没有读进行时立刻 flush」, 不引入额外延迟——这也是它比「定时 flush」安全的地方; - 写失败/积压由
isWritable()与ChannelFuture判定,业务侧统一转向「记 failed → 落离线」, 而不是在连接层重试。
「连接层不重试」是一条纪律:重试策略必须只有一处(IM 服务的投递 + 离线兜底), 否则「连接层重试 3 次 + 投递层再重试 3 次」会让一条消息在最坏情况下被推 9 次, 而客户端只能靠 seq 去重,日志里全是「重复推送」的噪声。
7. 摘流:四步,缺一步就是重连风暴
"客户端""网关实例"K8s"客户端""网关实例"K8s"从 Endpoints 摘除(不再分配新连接)""preStop sleep 10s(等 LB / 客户端感知)""SIGTERM""① 注销 Nacos 注册""② 就绪探针翻 REFUSING_TRAFFIC(新握手 503)""③ 广播 session.migrate{retryAfterMs}""重连(退避 + 抖动,不计入失败次数)""④ 等 drain.timeout-ms 后强制关闭残余"
三个时间常量的关系是硬约束(第 10 篇给出清单写法):
preStop sleep(10s) + drain.timeout(45s) < terminationGracePeriodSeconds(90s)
若 grace 小于 drain 超时,pod 会被 SIGKILL 打断摘流——客户端集体硬断,进入重连风暴。 另一个隐含约束:retryAfterMs 只是基准值,抖动必须由客户端加, 否则被摘流实例上的几千条连接会在同一毫秒重连,把剩余实例打穿(第 11 篇)。
摘流入口有两个,共用同一套逻辑:
| 入口 | 触发 |
|---|---|
ContextClosedEvent | 进程收到 SIGTERM |
POST /admin/drain?reason=scale-in | 手动/自动化缩容,幂等可重复调用 |
8. 四个真实坑(按「现象 → 根因 → 修法」)
| 现象 | 根因 | 修法 |
|---|---|---|
| 浏览器握手一直超时,服务端无日志 | WebSocketServerProtocolHandler 用 uri().equals(websocketPath) 判断;/ws?userId=... 不等于 /ws,请求被转发到 pipeline 尾部静默丢弃 | 握手闸门校验后 request.setUri(wsPath) |
| 只用协议级 Ping 的客户端被误杀 | 计时器放在协议处理器之后,协议级 Ping 被消费 | IdleStateHandler 放协议处理器之前 |
| 同账号两台设备的帧发到对方连接上 | 入站 handler 被当成共享实例 | 每连接 new |
| 高负载时批量误杀健康连接 | 心跳判据没考虑「背压期间不读」 | 判据加「且我们在读」+ 看门狗 |
另外一条与 Netty 无关但同样致命:Windows 上老版本 Redis(3.2)的 Lua 不支持多字段 HSET, 报错是 Wrong number of args calling Redis command From Lua script(和真实原因完全无关)。 需要「只前进」语义时先 HGET/ZSCORE 比较再写(第 08 篇)。
9. 验证:状态机必须被单测钉住
| 验证项 | 方法 | 结果 |
|---|---|---|
| 跨实例投递 | 两个真实 WS 客户端 alice→bob | 双方拿到同一条 seq=1 的 ack / push |
| TCP 连发保序 | connect 与 msg.send 连发 | 依次 connected → msg.push → msg.ack |
| 断连清理 | 断开后查 /admin/status | connections 2/1 → 0,无空壳会话 |
| 背压不丢帧 | 高水位配 2、同连接连发 5 帧 | 出现「积压 3 帧超过高水位 2,暂停读取」;5 条 ack 全收到、连接未断 |
| 背压状态机 | SerialExecutorTest | 暂停只触发一次 / 降到低水位才恢复 / 一帧不丢 / 控制任务不参与背压 / 单任务异常不中断 |
| 传输分布 | /admin/status 的 transports | WS 与 TCP 各自的端口与路径可自证 |
为什么背压行为必须进单测:它的正确性体现在时序(只触发一次、只在低水位恢复), 人工点击只能看到「没断」,看不到「有没有多触发一次」——而多触发一次就意味着读循环被反复打断。
10. 反模式
| 反模式 | 为什么诱人 | 后果 |
|---|---|---|
在 channelRead0 里直接做业务 | 少一层队列,代码短 | event loop 被阻塞,挂同线程的连接一起停摆 |
用 synchronized 写连接 | 「反正只有一处写」 | 群聊扇出时锁竞争 + 慢客户端把业务线程一起拖住 |
| 超限断连 | 一行 close() | 客户端重连后重发同一批,活锁 |
| 全局扫表心跳 | 实现简单 | 精度受批次影响,开销随连接数线性增长 |
| 连接层自己重试写 | 「提高成功率」 | 与投递层重试叠加,重复推送淹没日志 |
11. 小结
| 义务 | 机制 | 代价 |
|---|---|---|
| 不阻塞 event loop | 每连接串行队列 + 虚拟线程执行 | 单连接吞吐 = 1/单条耗时 |
| 可施加反压 | setAutoRead(false) + 高/低水位滞回 + 看门狗 | 心跳判据必须修正;容量上限要按 maxPending + 一批 估算 |
| 可识别死连接 | 两段式应用层心跳 + 每连接抖动 + 无全局扫描 | 最长 90s 的「假在线」窗口 |
下一篇:04 seq 保序与幂等 —— 帧进入 IM 服务之后, 「顺序」与「重复」这两个最容易被含糊处理的语义,如何被收敛到两个可证明的点上。