SPARROW ZOO · Netty 踩坑

Netty 入站消息未释放导致 OOM

结合 SimpleChannelInboundHandler 的自动释放机制,定位自定义 handler 继承 ChannelInboundHandlerAdapter 却未 release 入站消息, 导致引用计数不归零、缓冲不断泄漏直至 OOM,并进一步拖垮客户端心跳、造成 IM 消息异常。

目录 CONTENTS
  1. 结论先行:OOM 的根因
  2. 问题背景:Netty 的引用计数模型
  3. 详细内容:两段代码对比
  4. OOM 是如何一步步形成的
  5. 心跳为何也被拖垮:IM 消息异常
  6. 泄漏检测报告解读
  7. 修复方案
  8. 总结归纳

01结论先行:OOM 的根因

OutOfMemoryHandler 继承的是 ChannelInboundHandlerAdapter(而非 SimpleChannelInboundHandler),它重写了 channelRead,处理完入站消息后 既没有调用 ReferenceCountUtil.release(msg) 释放引用,也没有 ctx.fireChannelRead(msg) 把消息传下去。

而 Netty 的 ByteBuf / BinaryWebSocketFrame 都是引用计数(Reference Counted)对象: 引用计数永远不归零,底层缓冲就永远不被回收。同时该处理器还用 PooledByteBufAllocator(preferDirect = true) 申请堆外直接内存, 泄漏的直接内存不受堆 GC 管理,最终触发 OutOfMemoryError。

⛔一句话结论

入站消息只消费、不释放——Netty 的约定是「消息要么往下传,要么自己释放」, 这个 handler 两头都没做,导致每条 WebSocket 帧都泄漏一个 ByteBuf,累积到内存耗尽。

💥同样致命的副作用

同一个 bug 还会吞掉心跳和 IM 消息:非 BinaryWebSocketFrame 的帧 (Ping/Pong/Text 等)因为少了 else 分支而不被 fireChannelRead 透传,客户端收不到心跳应答就会断线重连,消息丢、乱、重复。

02问题背景:Netty 的引用计数模型

Netty 高性能的关键之一是零拷贝 + 引用计数。它的缓冲区 ByteBuf 以及包装它的 WebSocketFrame 都实现了 ReferenceCounted 接口,遵循这样一套规则:

那么谁来负责 release?Netty 的通用约定是:

📌Netty 官方约定

在 channelRead 里拿到的消息,要么调用 ctx.fireChannelRead(msg) 继续传给下一个 handler(由下游负责释放),要么在当前 handler 用完后就地 release。 如果既不往下传、也不释放,就是内存泄漏。

还需要区分两类内存:

类型所在区域由谁回收耗尽表现
堆内存 heapBuffer JVM 堆内 堆 GC 回收 OutOfMemoryError: Java heap space
直接内存 directBuffer 堆外(native) GC 不直接管,受 -XX:MaxDirectMemorySize 限制 OutOfMemoryError: Direct buffer memory

本案例里两种泄漏叠加:入站消息本身泄漏(从注释堆栈看是堆内解压缓冲),处理器自己又申请了直接内存、异常时同样泄漏。

03详细内容:两段代码对比

3.1 第一段:SimpleChannelInboundHandler 的「正确姿势」

这是 Netty 框架源码 SimpleChannelInboundHandler.channelRead,它是释放逻辑的标准范式:

public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    boolean release = true;
    try {
        if (acceptInboundMessage(msg)) {
            I imsg = (I) msg;
            channelRead0(ctx, imsg);        // 业务逻辑
        } else {
            release = false;                  // 不匹配 → 交给下游,下游负责释放
            ctx.fireChannelRead(msg);
        }
    } finally {
        if (autoRelease && release) {
            ReferenceCountUtil.release(msg);  // 关键:自动释放
        }
    }
}

要点:

也就是说,只要你继承 SimpleChannelInboundHandler,用完消息后框架会自动帮你 release,无需手写。

3.2 第二段:OutOfMemoryHandler 的问题

再看出问题的自定义 handler(关键部分):

public class OutOfMemoryHandler extends ChannelInboundHandlerAdapter {
    PooledByteBufAllocator allocator = new PooledByteBufAllocator(true);  // preferDirect = true

    private Object getBinaryWebSocketFrame(BinaryWebSocketFrame msg) {
        ByteBuf byteBuf = null;
        try {
            byte[] serviceTimeBytes = ("_" + System.currentTimeMillis()).getBytes();
            int capacity = msg.content().readableBytes() + serviceTimeBytes.length;
            byteBuf = allocator.directBuffer(capacity);     // 申请堆外直接内存
            byteBuf.writeBytes(msg.content());
            byteBuf.writeBytes(serviceTimeBytes);
            msg.content().resetReaderIndex();
            return new BinaryWebSocketFrame(byteBuf);
        } catch (Exception e) {
            return null;                             // ← 异常时 byteBuf 没释放!
        }
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        if (msg instanceof BinaryWebSocketFrame) {
            BinaryWebSocketFrame frame = (BinaryWebSocketFrame) msg;
            BinaryUtils.toString(msg);
            ctx.channel().writeAndFlush(this.getBinaryWebSocketFrame(frame));
            // ← 既没有 ReferenceCountUtil.release(msg),也没有 ctx.fireChannelRead(msg)
        }
    }
}

对比第一段代码,能清楚地看到它缺了什么:

维度SimpleChannelInboundHandlerOutOfMemoryHandler
父类 SimpleChannelInboundHandler<I> ChannelInboundHandlerAdapter
是否自动释放入站消息 是(autoRelease=true,finally 里 release) 否,需手动释放
处理完消息后 框架自动 release 既不 release,也不 fireChannelRead → 泄漏

注意:ChannelInboundHandlerAdapter.channelRead 的默认实现是 ctx.fireChannelRead(msg)(把消息往下传)。一旦你重写了它又不主动下传, 就切断了消息往下游流动的路径,而它又不会自动释放——于是消息就「卡」在这个 handler 里,泄漏了。

04OOM 是如何一步步形成的

把上面的问题串起来,泄漏链是这样的:

每条入站 BinaryWebSocketFrame
  └─ 到达 OutOfMemoryHandler.channelRead
       ├─ 被处理(toString + 回显一个带时间戳的新帧)
       ├─ 新帧 writeAndFlush 出去(出站方向 Netty 会在写完后释放,正常)
       └─ 入站帧 msg:
            ├─ 没有 release()  →  refCnt 始终 ≥ 1,永不归零
            ├─ 没有 fireChannelRead()  →  下游也无法代为释放
            └─ 底层 ByteBuf 永远回不到池 / 不被 free

高频消息(例如 EmbeddedChannel 压测循环 writeInbound)
  └─ 每个帧都泄漏一份缓冲 → 内存只增不减 → OOM

具体有两个层面的后果:

⚠️为什么直接内存特别危险

直接内存分配在 JVM 堆外,堆 GC 并不直接回收它,其上限由 -XX:MaxDirectMemorySize 控制(默认约等于 -Xmx)。一旦泄漏, 会出现「堆内存还很充裕、进程却 OOM」的诡异现象,报错常是 OutOfMemoryError: Direct buffer memory。

05心跳为何也被拖垮:IM 消息异常

除了内存泄漏,这个 handler 还会直接影响客户端心跳,导致 IM 消息异常。 根子还是同一个:重写 channelRead 后,只处理了 BinaryWebSocketFrame, 却没有 else 分支把其他帧往下传。于是它成了 pipeline 里的一个「黑洞」:

PingWebSocketFrame / PongWebSocketFrame / TextWebSocketFrame / CloseWebSocketFrame
  └─ 进入 OutOfMemoryHandler.channelRead
       └─ 不是 BinaryWebSocketFrame → 什么都不做
            ├─ 不 fireChannelRead → 下游(心跳/业务)永远收不到
            └─ 不 release           → 这些帧同样泄漏,雪上加霜

BinaryWebSocketFrame(真实业务消息 / 应用层心跳)
  └─ 被截住 → 不回传给业务,而是回显一帧「原内容 + _时间戳」

IM 系统里常见的两类心跳,都被这条代码破坏:

心跳类型报文形态被破坏的机制后果
WebSocket 协议层心跳 PingWebSocketFrame / PongWebSocketFrame 非 BinaryWebSocketFrame,被 handler 丢弃,服务端永远不回 Pong 客户端收不到 pong → 判定连接断开 → 主动断开重连
应用层心跳(自定义消息) 二进制心跳消息(带 heartbeat 类型字段) 被 BinaryWebSocketFrame 分支截住,替换成「内容 + 时间戳」回显,心跳 ack 不发 客户端收不到心跳应答 → 判定离线 → 断开重连

心跳一断,紧接着就是一串 IM 消息异常:

⛔重点:别漏掉 else 分支

这个 handler 除了「该释放不释放」,更致命的是「该下传不下传」。正确写法里, 不处理的消息必须 ctx.fireChannelRead(msg) 透传给下一个 handler(心跳处理器、业务分发器)。 少了这一步,心跳和普通消息会一起被吞掉,整条 IM 链路异常。

06泄漏检测报告解读

代码注释里贴的那段「Recent access records: / Created at:」堆栈, 正是 Netty ResourceLeakDetector(资源泄漏检测器)的典型报告。它会在某个 ByteBuf 被 GC 回收、却从未 release 时,打印出这个缓冲的创建点,帮助定位泄漏来源。

这段堆栈的关键几行:

PooledByteBufAllocator.newHeapBuffer(...)            // 泄漏的缓冲是「堆内」池化缓冲
AbstractByteBufAllocator.heapBuffer(...)
ZlibDecoder.prepareDecompressBuffer(...)       // 解压时申请的解压缓冲
JdkZlibDecoder.decode(...)
PerMessageDeflateDecoder.decode(...)           // WebSocket PerMessageDeflate 扩展
MessageToMessageDecoder.channelRead(...)
...
EmbeddedChannel.writeInbound(...)              // 测试/压测里写入入站消息

它说明了三件事:

🧭为什么报的是堆内缓冲

这份报告定位的是「入站帧内容」这个堆内解压缓冲;而 getBinaryWebSocketFrame 里的 directBuffer 属于另一处潜在泄漏。两处泄漏共同加速了内存耗尽——一个撑爆堆,一个撑爆直接内存。

07修复方案

按「改动最小、最稳妥」排序,推荐下面三种,任选其一:

方案一:改成继承 SimpleChannelInboundHandler(最推荐)

public class OutOfMemoryHandler extends SimpleChannelInboundHandler<BinaryWebSocketFrame> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, BinaryWebSocketFrame msg) throws Exception {
        BinaryUtils.toString(msg);
        ctx.channel().writeAndFlush(this.getBinaryWebSocketFrame(msg));
        // 框架会在 channelRead0 返回后自动 release(msg),无需手写
    }
}

方案二:手动 release(仍继承 Adapter)

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    boolean release = true;
    try {
        if (msg instanceof BinaryWebSocketFrame) {
            BinaryWebSocketFrame frame = (BinaryWebSocketFrame) msg;
            BinaryUtils.toString(msg);
            ctx.channel().writeAndFlush(this.getBinaryWebSocketFrame(frame));
        } else {
            release = false;
            ctx.fireChannelRead(msg);
        }
    } finally {
        if (release) {
            ReferenceCountUtil.release(msg);
        }
    }
}

方案三:修复 getBinaryWebSocketFrame 的异常泄漏

private Object getBinaryWebSocketFrame(BinaryWebSocketFrame msg) {
    ByteBuf byteBuf = null;
    try {
        byte[] serviceTimeBytes = ("_" + System.currentTimeMillis()).getBytes();
        int capacity = msg.content().readableBytes() + serviceTimeBytes.length;
        byteBuf = allocator.directBuffer(capacity);
        byteBuf.writeBytes(msg.content());
        byteBuf.writeBytes(serviceTimeBytes);
        msg.content().resetReaderIndex();
        return new BinaryWebSocketFrame(byteBuf);
    } catch (Exception e) {
        ReferenceCountUtil.release(byteBuf);   // 异常时也要释放已申请的直接内存
        return null;
    }
}
✅最省事的做法

直接采用方案一:继承 SimpleChannelInboundHandler<BinaryWebSocketFrame>, 把逻辑写进 channelRead0,自动释放交给框架,从根上杜绝「忘了 release」。 更重要的是,SimpleChannelInboundHandler 对不匹配的消息会自动 fireChannelRead 透传, 心跳和业务消息不会再被吞掉——一处改动同时解决 OOM 与 IM 消息异常。

08总结归纳

问题根因修复
入站消息泄漏(主因) 继承 ChannelInboundHandlerAdapter,重写 channelRead 后既不 release 也不 fireChannelRead 改用 SimpleChannelInboundHandler,或手动 ReferenceCountUtil.release
直接内存泄漏(次因) directBuffer 申请后,异常路径 catch 返回 null 前未释放 catch 里 ReferenceCountUtil.release(byteBuf)
心跳/消息被吞(连带) 重写 channelRead 只处理 BinaryWebSocketFrame,无 else 分支 fireChannelRead,ping/pong 与业务消息被丢弃 非目标类型消息统一 ctx.fireChannelRead(msg) 透传
✅一句话记住

Netty 的消息要么往下传、要么自己释放,两头都占就是泄漏。 不释放会 OOM,不往下传还会吞掉心跳、拖垮 IM 消息链路。需要「处理完自动释放」的场景, 优先继承 SimpleChannelInboundHandler,别手写 ChannelInboundHandlerAdapter.channelRead 还忘了 release 和 fireChannelRead。