03 Netty 双通道长连接层

6 阅读14分钟

03 Netty 双通道长连接层:三条义务,一条都不能少

项目地址:github.com/zhao-ru-jie…

系列第 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)
    }
}

两个边界条件必须说明:

  1. 上限是 maxPending + 一次 read 批次的帧数,不是 maxPending。因为「暂停」是让 socket 停止读,当前这一批已经解出来在内存里了。写文档时把这一点写清,否则容量估算会偏小;
  2. 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 机制

  1. 空闲达到 ping-after-ms(默认 45s,每条连接叠加 [0, jitter-ms](默认 5s)随机偏移) → 下发应用层 {"type":"ping"},并挂一个在 close-after-ms(默认 90s)关闭的任务;
  2. 期间收到任意上行帧(pong、msg.send 都算)→ 取消关闭任务;
  3. 到期仍无上行 → 断开(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-afterREADING/PINGED发 ping,挂 closeTask
PINGED收到任意上行READING取消 closeTask,pinged=false
PINGEDclose-after 到期CLOSING以 heartbeat-timeout 断开
READING积压 > 高水位PAUSEDsetAutoRead(false),取消 closeTask,挂 pauseWatchdog
PAUSED积压 < 低水位READINGsetAutoRead(true),取消 pauseWatchdog
PAUSEDpauseWatchdog 到期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/statusconnections 2/1 → 0,无空壳会话
背压不丢帧高水位配 2、同连接连发 5 帧出现「积压 3 帧超过高水位 2,暂停读取」;5 条 ack 全收到、连接未断
背压状态机SerialExecutorTest暂停只触发一次 / 降到低水位才恢复 / 一帧不丢 / 控制任务不参与背压 / 单任务异常不中断
传输分布/admin/status 的 transportsWS 与 TCP 各自的端口与路径可自证

为什么背压行为必须进单测:它的正确性体现在时序(只触发一次、只在低水位恢复), 人工点击只能看到「没断」,看不到「有没有多触发一次」——而多触发一次就意味着读循环被反复打断。


10. 反模式

反模式为什么诱人后果
在 channelRead0 里直接做业务少一层队列,代码短event loop 被阻塞,挂同线程的连接一起停摆
用 synchronized 写连接「反正只有一处写」群聊扇出时锁竞争 + 慢客户端把业务线程一起拖住
超限断连一行 close()客户端重连后重发同一批,活锁
全局扫表心跳实现简单精度受批次影响,开销随连接数线性增长
连接层自己重试写「提高成功率」与投递层重试叠加,重复推送淹没日志

11. 小结

义务机制代价
不阻塞 event loop每连接串行队列 + 虚拟线程执行单连接吞吐 = 1/单条耗时
可施加反压setAutoRead(false) + 高/低水位滞回 + 看门狗心跳判据必须修正;容量上限要按 maxPending + 一批 估算
可识别死连接两段式应用层心跳 + 每连接抖动 + 无全局扫描最长 90s 的「假在线」窗口

下一篇:04 seq 保序与幂等 —— 帧进入 IM 服务之后, 「顺序」与「重复」这两个最容易被含糊处理的语义,如何被收敛到两个可证明的点上。