主题
Netty 生产踩坑
一句话:Netty 的线上问题大多不是「框架 bug」,而是「IO 线程被阻塞、ByteBuf 没释放、心跳只检测不发送、重连没有抖动」这四类。
适用版本:Netty 4.1.x。指标名取决于你使用的监控体系,以实际注册名为准。
为什么需要
单机 demo 跑通,只证明协议能解析;长连接服务还要面对:
- 连接持续数小时/数天,泄漏会累积。
- 网络抖动、NAT 超时、服务端重启,会触发断连和重连。
- 慢客户端写不出去,写缓冲会涨。
- 一个 Handler 阻塞,会拖慢同一 EventLoop 上的所有连接。
这些问题不会在功能测试里暴露,只能在监控和故障演练里暴露。
核心机制
四类故障的因果链
| 现象 | 因果链 | 先看什么 |
|---|---|---|
堆外内存持续上涨,最终 OutOfDirectMemoryError | ByteBuf 被异步队列/缓存持有但没 retain,或 Handler 没 release → 池无法回收 → 直接内存耗尽 | RSS、usedDirectMemory、LEAK: 日志 |
| 客户端频繁断连重连,网络正常 | IdleStateHandler 阈值低于 RTT×3 → 正常抖动被判空闲 → 关闭连接 | IdleStateEvent 日志、实测 RTT P99 |
| 服务端重启后瞬时 CPU/连接打满 | 客户端同时重连,无 jitter → accept 队列打满 | 重连时间分布、accept 队列 |
| P99 飙升但 CPU 不高 | Handler 在 IO 线程做同步 DB/RPC → 该 EventLoop 上所有连接排队 | pendingTasks、单 EventLoop 的 Channel 数 |
坑一:ByteBuf 泄漏
java
// 错误:把 ByteBuf 放进异步队列,方法返回后引用计数可能归零
public void channelRead(ChannelHandlerContext ctx, Object msg) {
queue.offer((ByteBuf) msg);
}
// 正确:显式 retain,消费者 release
public void channelRead(ChannelHandlerContext ctx, Object msg) {
queue.offer(((ByteBuf) msg).retain());
}定位:
bash
-Dio.netty.leakDetection.level=PARANOID # 仅预发;线上用 SIMPLE日志出现 LEAK: ByteBuf.release() was not called before it's garbage-collected,按后面的分配栈定位。PARANOID 有明显性能开销,比例随负载变化,不要当成固定值。
坑二:IdleStateHandler 只发事件,不发心跳
IdleStateHandler(readerIdleTime, writerIdleTime, allIdleTime) 到时间后触发 IdleStateEvent,它不会替你写任何字节。心跳必须自己写:
java
public class HeartbeatHandler extends ChannelInboundHandlerAdapter {
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
if (evt instanceof IdleStateEvent e) {
if (e.state() == IdleState.WRITER_IDLE) {
ctx.writeAndFlush(
ctx.alloc().buffer(5).writeBytes("PING\n".getBytes(StandardCharsets.UTF_8)));
} else if (e.state() == IdleState.READER_IDLE) {
ctx.close(); // 对端长时间没数据,判死
}
} else {
ctx.fireUserEventTriggered(evt);
}
}
}Pipeline:
java
pipeline.addLast(new IdleStateHandler(60, 30, 0));
pipeline.addLast(new HeartbeatHandler());服务端 readerIdleTime 取心跳间隔 × 3,客户端 writerIdleTime 取心跳间隔;30–60s 心跳对应 60–180s 判死,不要用 10s。
坑三:重连风暴
断线后立即重连,几千个客户端会在同一秒冲击服务端。正确做法是指数退避 + 随机抖动:
java
private void scheduleReconnect(Channel ch, int attempt) {
long base = Math.min(60, 1L << Math.min(attempt, 6)); // 1,2,4,...,64 → 封顶 60
long jitter = ThreadLocalRandom.current().nextInt(1000);
ch.eventLoop().schedule(() -> doConnect(ch, attempt + 1),
base * 1000 + jitter, TimeUnit.MILLISECONDS);
}只有退避、没有 jitter,客户端仍会在封顶时刻同步重连;jitter 把重连散布到一个窗口内。
坑四:IO 线程阻塞
java
// 错误:在 EventLoop 上做同步 HTTP
String result = httpClient.get("http://auth/validate");
// 正确:丢业务线程池,处理完切回 EventLoop 写
businessExecutor.execute(() -> {
String result = httpClient.get("http://auth/validate");
ctx.executor().execute(() -> ctx.writeAndFlush(build(result)));
});判断信号:io.netty.eventLoop.pendingTasks 持续 > 1000 五分钟,且 P99 高、CPU 不高,基本可以确定有阻塞。
最小上手
把上面的 HeartbeatHandler 接进 Pipeline,用 nc 连上后不发数据,观察 30s 后是否收到 PING;再让服务端 60s 不回,观察连接是否关闭。这个实验能同时验证「事件触发」和「心跳发送」两件事。
生产动作
- 堆外上限:
-XX:MaxDirectMemorySize显式设置(默认等于-Xmx,长连接服务通常不够);同时看容器 cgroup 限制,RSS 超限会被 OOM kill。 - 泄漏检测:线上
SIMPLE,预发PARANOID;不要在生产长期开PARANOID。 - 心跳:
IdleStateHandler+ 自写心跳;readerIdleTime取心跳间隔 × 3,客户端writerIdleTime取心跳间隔。 - 重连:指数退避封顶 60s + 0–1000ms jitter;连接成功后重置 attempt。
- 背压:
WriteBufferWaterMark(32KB, 64KB);isWritable=false时暂停生产,不要继续往 Channel 里堆。 - 连接上限:accept 后按
channelGroup.size()拒绝超限连接,保护进程。 - 优雅停机:先停止接收新连接,再
shutdownGracefully,给在途请求留时间。 - 监控:活跃连接数、写字节速率、
pendingTasks、堆内/堆外使用量。指标名以实际注册名为准:io.netty.allocator.usedDirectMemory需要你把PooledByteBufAllocator.metric()注册到指标系统后才存在;没有注册时用 Micrometer 的 Netty binder 或自己暴露。
排障路径:
- 堆外 OOM → 看 RSS 与
usedDirectMemory→ 预发PARANOID复现 → 按分配栈修 release/retain。 - P99 高、CPU 低 → 看
pendingTasks→ 定位阻塞 Handler → 移到业务线程池。 - 频繁断连 → 对比
IdleStateHandler阈值与 RTT P99 → 调大阈值,确认心跳真的在发。 - 重启后打满 → 看重连时间分布 → 补 jitter、调退避封顶、加连接上限。
边界
- 不要用 TCP keepalive 代替应用心跳:keepalive 只探测链路,不能判断业务线程是否卡死。
- 心跳不能太短:10s 心跳在内网抖动时容易误判;30–60s 是常见起点,按 RTT 调整。
PARANOID不是常态:性能开销明显,只用于预发定位。- 池化内存不归还 OS:
usedDirectMemory下降不代表 RSS 立刻下降;判断泄漏要看长时间趋势。 - jitter 不能替代限流:重连风暴还要配合服务端 accept 限流/连接上限。
讲清楚
Netty 的线上问题集中在四类:ByteBuf 没 release/retain 导致堆外内存泄漏;IdleStateHandler 只触发事件、不发心跳,导致误判或「以为有心跳其实没有」;断线后立即重连导致重连风暴;在 IO 线程做同步调用导致 P99 飙升但 CPU 不高。解法分别是:预发开 PARANOID 定位泄漏、自己写心跳并让 readerIdle 取心跳间隔 ×3、指数退避 + jitter、耗时逻辑丢业务线程池。监控上不要迷信固定指标名,io.netty.allocator.* 需要先注册;活跃连接、写字节、pendingTasks 更直接。最后,生产执行项看 99 清单,正文只讲因果和排障路径。