本节目标:用
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() 存进 lastReadNanos | IdleStateHandler 内部 tick |
| 触发 | 扫描时比较差值,主动 close | IdleStateEvent 事件,由 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-requestDuration,两者不能合并。 userEventTriggered跑在 EventLoop 上,里面不能有 JDBC、文件 IO、Thread.sleep或sync()。- 与卷一 7.3 相比,Netty 把全局扫描换成了每连接的定时任务,业务状态机仍然要自己做。
连接能保活、失效能发现,下一个问题是「谁允许你连上来」。7.3 用 HELLO/token 把未认证连接挡在业务 handler 之前。
阅读导航:上一节:7.1 协议帧设计与魔数 · 下一节:7.3 握手、认证与连接绑定 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。