跳到正文

Netty 生产踩坑 ​

一句话:Netty 的线上问题大多不是「框架 bug」,而是「IO 线程被阻塞、ByteBuf 没释放、心跳只检测不发送、重连没有抖动」这四类。

适用版本:Netty 4.1.x。指标名取决于你使用的监控体系,以实际注册名为准。

为什么需要 ​

单机 demo 跑通,只证明协议能解析;长连接服务还要面对:

  • 连接持续数小时/数天,泄漏会累积。
  • 网络抖动、NAT 超时、服务端重启,会触发断连和重连。
  • 慢客户端写不出去,写缓冲会涨。
  • 一个 Handler 阻塞,会拖慢同一 EventLoop 上的所有连接。

这些问题不会在功能测试里暴露,只能在监控和故障演练里暴露。

核心机制 ​

四类故障的因果链 ​

现象因果链先看什么
堆外内存持续上涨,最终 OutOfDirectMemoryErrorByteBuf 被异步队列/缓存持有但没 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 清单,正文只讲因果和排障路径。

把一门技术讲透到既懂也会用