《Netty 高性能网络编程》7.2 IdleStateHandler 与心跳

连接空闲时双方都不知道对端是否还活着。本节拆开 IdleStateHandler 的三种空闲与 IdleStateEvent.state(),给出服务端 30 秒发 PING、客户端回 PONG、连续两轮无响应关闭的完整 handler,并说明心跳为什么替代不了业务超时。

本节目标:用 IdleStateHandler 把「连接还活着吗」变成可观测的事件,并让心跳的失败路径一定关闭连接。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-handler 模块)。

承接的问题

7.1 的帧能切出来了,但「连接还在不在」没有答案。正常关闭会走四次挥手,本端 channelInactive 能收到;拔网线、进程被 kill -9、NAT 表项过期、负载均衡静默丢包,这些都不会给本端任何通知。连接在服务端看来仍然是 ESTABLISHED,占据一个 Channel、一份 cumulation 和一条 EventLoop 上的注册关系。

内核的 SO_KEEPALIVE 默认 2 小时才发第一个探测,Linux 上 net.ipv4.tcp_keepalive_time 默认 7200 秒;它只探测内核层面的连通性,对端进程假死、代理层半开都探不到。卷一 7.3 用 ConnectionStateMachine 加一个全局定时扫描解决过这个问题,Netty 把同一件事做成了 IdleStateHandler:每个连接在自己的 EventLoop 上注册定时任务,触发时发一个用户事件。

这一节回答四个问题:三种空闲分别计什么、事件怎么变成 PING/PONG、连续两轮没响应怎么关闭、心跳为什么不能顺手替代业务超时。

核心机制一:三种空闲与 IdleStateEvent

IdleStateHandler 的构造器有四个参数:

new IdleStateHandler(30, 0, 0, TimeUnit.SECONDS);

四个参数依次是 readerIdleTime、writerIdleTime、allIdleTime 和时间单位,传 0 表示禁用对应检查。三个计时器的语义不一样:

参数计时起点重置条件触发的事件
readerIdleTime上一次读到字节任意入站字节到达IdleState.READER_IDLE
writerIdleTime上一次写出字节任意出站写完成IdleState.WRITER_IDLE
allIdleTime上一次读或写任一次读或写IdleState.ALL_IDLE

触发时不会调用 channelRead,而是调用 userEventTriggered(ctx, evt),evt 是 IdleStateEvent,state() 返回具体的 IdleState。所以处理心跳的 handler 必须覆写 userEventTriggered,而不是 channelRead。骨架如下:

@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
    if (evt instanceof IdleStateEvent event) {
        switch (event.state()) {
            case READER_IDLE -> { /* 对端很久没发东西 */ }
            case WRITER_IDLE -> { /* 本端很久没写东西 */ }
            case ALL_IDLE -> { /* 两边都没有动静 */ }
        }
        return;
    }
    ctx.fireUserEventTriggered(evt);
}

这段是形状示意,最终实现见下一节。两个点:IdleStateEvent 在 4.2 里是具体类,state() 返回 IdleState 枚举;自己的事件处理完就不要再往下传,否则下游 handler 会收到一个它不认识的空闲事件。

读空闲的计时器由「有字节读入」重置,不是「有完整帧」。这个区别很重要:一个慢速攻击者每 29 秒发 1 个字节,读空闲永远不会触发,但业务一帧也拼不出来。linkd-v2 的 1 MiB 上限让 cumulation 不会无限增长,但连接仍然被占着。心跳解决的是「完全没有字节」,这一类问题要靠帧级超时或前置限速,本节不假装它能覆盖。

三个计时器是三个独立的定时任务:读空闲任务、写空闲任务、全空闲任务各自调度。同时开启时,READER_IDLE 和 ALL_IDLE 可能在同一轮里先后触发,处理顺序取决于任务在 EventLoop 队列里的位置。linkd-v2 只设读空闲 30 秒,就是为了不依赖这个顺序——一个事件、一种处理,排障时不用解释「为什么这次先收到 ALL_IDLE」。

核心机制二:服务端 PING、客户端 PONG、两轮关闭

心跳的方向选服务端发、客户端回。服务端持有连接表 ConnectionRegistry,能统一节奏、统一日志、统一关闭;客户端不需要知道什么时候该探测,只回答。计数器只数「连续几轮读空闲」,时间线如下:

t=0    连接建立,认证完成
t=30   READER_IDLE  missed=1  -> 发 PING(requestId=0)
t=30   PONG 到达     missed=0
t=60   READER_IDLE  missed=1  -> 发 PING
t=90   READER_IDLE  missed=2  -> close

missed=2 的含义不是「丢了两个 PONG」,而是「连续两个 30 秒周期没有任何入站字节」。PONG 只要到达,channelRead 就把计数清零;PONG 由心跳层消费,不再传给业务 handler——它只表示「对端有响应」,没有业务语义。

PING 不是空 TCP 段,也不是 SO_KEEPALIVE,而是一个完整的 LinkdFrame:16 字节头、type=3、length=0。它走和业务帧完全相同的编码器、解码器和帧校验,所以心跳顺带验证了「对端还能不能编码出一帧合法协议」。只靠 TCP keepalive,只能证明内核还能回 ACK,证明不了解码器和 handler 还在;16 字节的成本,换的是一个端到端探针。

客户端不主动发 PING,只回答。这是有意的非对称:两边都主动探测会让同一时刻有两个 PING 在飞,日志和计数都要乘二。客户端侧的死连接发现不在本节范围内:8.1 的 ReconnectHandler 处理连接失败后的重建,但客户端 pipeline 没有装读空闲;要做双向探活,客户端也要加一个 IdleStateHandler 和同样的 PING 发送逻辑。

服务端的完整实现:

package com.plume.linkd.v2.server;

import com.plume.linkd.v2.protocol.FrameType;
import com.plume.linkd.v2.protocol.LinkdFrame;
import io.netty.channel.ChannelDuplexHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;

import java.lang.System.Logger;
import java.lang.System.Logger.Level;

public final class HeartbeatHandler extends ChannelDuplexHandler {

    private static final Logger LOG = System.getLogger(HeartbeatHandler.class.getName());
    private static final int MAX_MISSES = 2;
    private static final byte[] EMPTY = new byte[0];

    private int missed;

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (!(evt instanceof IdleStateEvent event) || event.state() != IdleState.READER_IDLE) {
            ctx.fireUserEventTriggered(evt);
            return;
        }
        if (++missed >= MAX_MISSES) {
            LOG.log(Level.WARNING, "no PONG from {0}, closing after {1} idle rounds",
                    ctx.channel().remoteAddress(), missed);
            ctx.close();
            return;
        }
        ctx.writeAndFlush(new LinkdFrame(FrameType.PING, 0, 0, EMPTY));
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        missed = 0;
        if (msg instanceof LinkdFrame frame && frame.type() == FrameType.PONG) {
            return;
        }
        ctx.fireChannelRead(msg);
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        LOG.log(Level.INFO, "heartbeat stops, missed={0}", missed);
        ctx.fireChannelInactive();
    }
}

三个覆写各管一件事。userEventTriggered 只处理 READER_IDLE,其他事件原样往下传;channelRead 在转发之前把 missed 清零,所以任何入站帧都算「活着」,PONG 被消费、其余帧继续往下走;channelInactive 只打一行日志,关闭后的清理由注册表负责,心跳 handler 不越权。

missed 用普通 int 而不是 AtomicInteger:这个 handler 每连接新建,字段只在 EventLoop 线程读写,不存在并发;用原子类反而让人误以为它会被多线程访问。也正因为有 missed,HeartbeatHandler 不能标 @Sharable——共享后所有连接共用一个计数,一条连接空闲会把另一条关掉(4.3 讲过这个规则)。

客户端的回答更简单:

package com.plume.linkd.v2.client;

import com.plume.linkd.v2.protocol.FrameType;
import com.plume.linkd.v2.protocol.LinkdFrame;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;

public final class ClientPongHandler extends SimpleChannelInboundHandler<LinkdFrame> {

    private static final byte[] EMPTY = new byte[0];

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, LinkdFrame frame) {
        if (frame.type() == FrameType.PING) {
            ctx.writeAndFlush(new LinkdFrame(FrameType.PONG, 0, frame.requestId(), EMPTY));
            return;
        }
        ctx.fireChannelRead(frame);
    }
}

PING 回 PONG,requestId 原样回填;其他帧 fireChannelRead 给上层,8.1 的 RpcClient 会在那里按 requestId 找 CompletableFuture。PING 不再往下传,业务层不应该看到心跳帧。

用 EmbeddedChannel 加 advanceTimeBy 推进逻辑时间,本机输出:

== heartbeat, idle=30s ==
t=30s outbound=PING requestId=0 active=true
t=30s inbound PONG active=true
t=60s outbound=PING active=true
10月 28, 2026 7:17:59 下午 com.plume.linkd.v2.server.HeartbeatHandler userEventTriggered
警告: no PONG from embedded, closing after 2 idle rounds
10月 28, 2026 7:17:59 下午 com.plume.linkd.v2.server.HeartbeatHandler channelInactive
信息: heartbeat stops, missed=2
t=90s active=false

这是逻辑时间,不是墙钟等待;t=90s 由 advanceTimeBy 累加得到。embedded 是 EmbeddedChannel 的 remoteAddress,JDK 日志的日期格式随 locale 变化,消息行是固定的。

核心机制三:心跳不能代替业务超时

心跳证明的是「TCP 连接还在、对端进程还能调度、解码器和心跳 handler 还能跑」。它不证明「请求被处理了」。三个具体场景:

第一,对端 EventLoop 上的业务 handler 死循环或长 GC。ClientPongHandler 在 pipeline 里位于业务 handler 之前,PONG 仍然能回;服务端看到连接健康,但那个请求永远不返回。第二,下游数据库慢,连接完全正常,业务超时是另一回事。第三,本端写队列积压,PING 写不出去——这时 writerIdleTime 会告诉你,但读空闲不会有任何反应。

所以 linkd-v2 有两层超时:心跳 30 秒发现死连接,RpcClient.call(request, Duration timeout) 控制单个请求(8.1)。心跳超时是连接级,业务超时是请求级;一个连接健康但请求超时,应该回 ERROR 而不是关连接。把两者合成一个定时器,要么误杀「网络慢但活着」的连接,要么让请求等满心跳周期。

还有一个容易混的:ReadTimeoutHandler 和 IdleStateHandler 都基于同一套读空闲,区别是前者直接抛 ReadTimeoutException 并关闭,后者只发事件。需要「超时就断」用前者,需要「先探活再决定」用后者。linkd-v2 用心跳,因为关闭之前要先给对端一次 PING 的机会。

核心机制四:空闲检查不能阻塞 EventLoop

IdleStateHandler 的定时任务跑在连接所属的 EventLoop 上,任务本身只比较时间戳、触发事件,是 O(1) 的。但 userEventTriggered 是用户代码,如果在里面做阻塞操作,卡住的是这个 EventLoop 上的所有连接:

// 反例:在空闲事件里同步查库,卡住整个 EventLoop
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
    if (evt instanceof IdleStateEvent) {
        boolean online = userRepository.isOnline(userId);   // JDBC,几十毫秒到几秒
        if (!online) {
            ctx.close();
        }
    }
}

一个 EventLoop 通常服务几百到几千条连接;一次 50 ms 的 JDBC 查询,会让同一 EventLoop 上所有连接的读写都停 50 ms。压测时这表现为 p99 抖动,而不是报错,很难和「心跳」联系起来。

正确做法是事件里只做 O(1) 判断和写;需要外部状态的,把任务丢给业务线程池或虚拟线程(3.3),回调里再 ctx.close()。回调跨线程时用 ctx.channel().eventLoop().execute(...) 回到 EventLoop,或者直接用 ChannelFuture 的 listener。另一个反例是 Thread.sleep:想「等 30 秒再检查」直接睡在 EventLoop 上,定时应该用 ctx.executor().schedule(...)。

在 linkd-v2 里落地:pipeline 与卷一状态机对照

9093 的 pipeline 顺序:

ch.pipeline().addLast(new IdleStateHandler(30, 0, 0, TimeUnit.SECONDS));
ch.pipeline().addLast(new LinkdFrameDecoder());
ch.pipeline().addLast(new LinkdFrameEncoder());
ch.pipeline().addLast(new AuthHandler(token));
ch.pipeline().addLast(new HeartbeatHandler());
ch.pipeline().addLast(new LinkdFrameHandler(registry));

IdleStateHandler 放最前,它只看 socket 的读和写,不需要解析帧。放在解码器之后也能工作,但那样它看到的是「解码后的帧」,语义从「字节空闲」变成「消息空闲」;linkd-v2 选择前者,因为心跳要回答的是 TCP 连接是否还活着。

心跳放在 AuthHandler 之后:未认证连接不参与 PING/PONG,10 秒认证超时先把它关掉(7.3)。心跳放在业务 handler 之前:它在 channelRead 里清零计数后,除 PONG 外的帧都必须 fireChannelRead,否则业务收不到。LinkdFrameEncoder 必须在 HeartbeatHandler 之前(靠 head 方向),否则 ctx.writeAndFlush 写出的 LinkdFrame 不会被编码——出站事件从 tail 往 head 走,只会经过当前 handler 前面的出站处理器。

与卷一 7.3 的手写实现对照:

维度卷一 7.3 手写Netty 本节
空闲计时全局 ScheduledExecutorService 每秒扫所有连接每连接在 EventLoop 上注册定时任务
时钟System.nanoTime() 存进 lastReadNanosIdleStateHandler 内部 tick
触发扫描时比较差值,主动 closeIdleStateEvent 事件,由 handler 决定
并发扫描线程与 IO 线程共享状态,要 volatile 或锁任务与连接同 EventLoop,无锁
连接状态ConnectionStateMachine 四态Channel.isActive() 加业务状态
复杂度每秒 O(N) 扫描每连接 O(1),但有 N 个定时任务

Netty 没有把 ConnectionStateMachine 搬进来:Channel 只覆盖传输层的 open/active,业务状态(已认证、正在关闭、排空中)仍然要自己维护。7.3 用 AttributeKey<Boolean> 记认证状态,8.3 讲关闭语义,两者合起来才是完整的连接状态。

ConnectionRegistry 是第二个落地点:HeartbeatHandler 调 ctx.close() 之后,channelInactive 是统一删除连接的地方,registry.remove(ch) 在那里调用;心跳 handler 不需要自己从表里删,也不应该删——删除的职责集中在一处,重复删会让 size() 偏小。

常见错误与边界

错误一:三参构造器的单位。 new IdleStateHandler(30, 0, 0) 是 30 秒,不是毫秒;要毫秒必须用四参 new IdleStateHandler(30, 0, 0, TimeUnit.MILLISECONDS),那是 30 毫秒。这两个构造器同时存在,参数个数只差一个,写错时不会报错,只会让心跳频率差 1000 倍。

错误二:只配 allIdleTime 却判断 READER_IDLE。 state() 永远返回 ALL_IDLE,event.state() != IdleState.READER_IDLE 永远为真,心跳永远不发。反过来只配读空闲却按 ALL_IDLE 判断,也一样。

错误三:覆写 userEventTriggered 后吞掉事件。 空闲事件被当前 handler 消费掉,下游再也收不到。AuthHandler 如果为了「未认证时拦截」覆写了 userEventTriggered 却没有 fireUserEventTriggered,HeartbeatHandler 就永远不会触发。默认实现会自动转发,覆写时记得保留。

错误四:把 HeartbeatHandler 标成 @Sharable。 见核心机制二,missed 会串台。Netty 不会阻止你标错,只会在运行时表现成「随机连接被关」。

错误五:用 SO_KEEPALIVE 代替应用层心跳。 ChannelOption.SO_KEEPALIVE 只打开内核探测,默认 2 小时;它探不到对端进程假死、探不到代理层半开,也不给你一个可观测的事件。

边界:读空闲挡不住慢速滴灌。 每来 1 个字节就重置计时器,攻击者可以用极低速率长期占用连接。这一类要靠帧级组装超时、连接总时长限制或前置限速,心跳不是万能药。

边界:定时精度不是 30 秒整。 IdleStateHandler 的任务排在 EventLoop 队列里,前面有长任务时会推迟;30 秒是「至少 30 秒没有读」的下限,不是精确周期。选间隔时要小于中间设备的最短空闲超时(NAT、负载均衡常见 60 秒或 300 秒),30 秒是保守值。

小结

  • IdleStateHandler 的三个参数分别是读空闲、写空闲、全空闲,事件通过 userEventTriggered 送达,IdleStateEvent.state() 区分类型。
  • 读空闲由「有字节读入」重置,不是「有完整帧」,所以挡不住慢速滴灌。
  • 服务端在 READER_IDLE 时发 PING,客户端回 PONG;连续两轮没有入站字节才关闭,missed 必须每连接一份。
  • 心跳只证明连接和线程还活着,业务超时要用 RpcClient 的 per-request Duration,两者不能合并。
  • userEventTriggered 跑在 EventLoop 上,里面不能有 JDBC、文件 IO、Thread.sleep 或 sync()。
  • 与卷一 7.3 相比,Netty 把全局扫描换成了每连接的定时任务,业务状态机仍然要自己做。

连接能保活、失效能发现,下一个问题是「谁允许你连上来」。7.3 用 HELLO/token 把未认证连接挡在业务 handler 之前。

阅读导航:上一节:7.1 协议帧设计与魔数 · 下一节:7.3 握手、认证与连接绑定 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发