文件描述符上涨不是 GC 问题,是 Netty 连接没关干净

上个月帮一个做即时通信的项目排查内存问题。监控显示服务启动后内存一直缓慢上涨,起初以为是堆内存泄漏,用 JProfiler 抓了 Heap Dump 对比,对象分布看起来很正常——没有明显的大对象滞留,HashMap 和 List 的容量也和预期一致。

但进程 RSS 确实在涨,而且 CPU 占用率也慢慢上去了。把 lsof -p <PID> 跑了一下,才发现文件描述符数量从几百飙到了八万以上,而且大部分是 socket 类型的 FD。

这时候才意识到,问题不在堆内存,而在网络连接。

为什么 FD 会和连接挂钩

Netty 服务端文件描述符持续增长:连接泄漏的隐蔽信号与定位方法

Linux 系统里,文件描述符不只是用来打开文件的。套接字、管道、事件fd、定时器fd,甚至 Netty 用的 epoll,全部占用 FD 配额。一个进程的 FD 上限通常是 1024,生产环境一般会调到 65535 甚至更高,但这个上限是真实存在的。

Netty 服务端每接受一个新连接,就会分配一个 NioSocketChannel,底层对应一个 Java 的 SocketChannel,再往下是一个 epoll fd。这个 fd 的生命周期应该由 Netty 管理,连接关闭时自动释放。但如果某个环节没有正确触发关闭,fd 就会一直占着不走。

更隐蔽的是,Netty 的连接关闭涉及多个层次:TCP 层面的 close、Netty 层面的 channelInactive、业务层面的资源清理。任何一个环节出问题,都可能导致 fd 泄漏。

泄漏通常发生在哪里

最常见的场景是 handler 链里没有正确释放资源。比如自定义了一个 ChannelInboundHandlerAdapter,在 channelRead 里处理消息时,如果因为异常提前 return,而后续需要关闭的 channel 没有被关闭,或者分配的 DirectBuffer 没有释放,fd 就漏了。

public class MessageHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        try {
            // 处理消息
            process(msg);
        } catch (Exception e) {
            // 只记了日志,忘了关 channel
            log.error("处理失败", e);
        }
    }
}

这个例子看着眼熟,但在实际项目里,问题往往比这复杂得多。有时异常被上游捕获后吞掉,channel 的状态变得不明确;有时是心跳检测机制误判,把活跃连接当成不活跃关闭了,但实际上连接还在用。

另一个高发区是自定义的 ChannelPool 或连接复用逻辑。有些项目为了控制连接数,自己维护了一个连接池,复用的是 Bootstrap 或者 NioSocketChannel。如果池化逻辑在归还连接时没有调用 close() 而是直接放回队列,下次取出来使用时,底层的 fd 其实是旧的、可能已经处于半关闭状态的。

// 错误的连接池实现
public class BadChannelPool {
    private final Queue<Channel> freeChannels = new ArrayBlockingQueue<>(100);
    
    public Channel acquire() {
        Channel ch = freeChannels.poll();
        if (ch != null && ch.isActive()) {
            return ch; // fd 可能已经接近超时
        }
        return createNew();
    }
    
    public void release(Channel ch) {
        freeChannels.offer(ch); // 没有 reset 状态,fd 悬空
    }
}

还有一种容易被忽略的情况:自定义的 EventLoop 或者 BossGroup/WorkerGroup 没有被正确 shutdown。Netty 的服务端启动时,会创建两组线程池,Boss 负责接受连接,Worker 负责处理 IO。如果应用关闭时只调用了 bossGroup.shutdownGracefully() 而忘了 workerGroup,或者反过来,已接受的连接就会卡在 Worker 的队列里,fd 不会被释放。

怎么定位到底哪里漏了

光看 lsof 只能知道 FD 多了,不知道是谁开的。真正的定位需要结合多个工具。

第一步,确认泄漏的模式。是稳定上涨还是阶梯式上涨?稳定上涨通常是持续接受新连接但没关闭;阶梯式上涨则可能是批量建立连接后一次性没释放,比如某个定时任务每次触发都会创建一批连接但忘记清理。

第二步,抓取当前的 FD 列表,结合 Netty 的 Channel 对象进行关联。Netty 的 Channel 有一个 id() 方法,返回的是 ChannelId,底层对应一个随机的十六进制字符串。可以通过 JMX 或者 Actuator 暴露出当前活跃的 Channel 列表,看看数量和 FD 数量是否匹配。

如果项目用了 Micrometer 或者 Spring Boot Actuator,可以直接看 /actuator/metrics/jvm.threads 和自定义的 Netty Channel 指标。没有的话,可以写一个简单的 ChannelActivityListener,在 channelActive 和 channelInactive 里计数,和生产环境的 FD 数量做对比。

public class ChannelCountListener extends ChannelDuplexHandler {
    private final AtomicLong activeCount = new AtomicLong();
    
    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        activeCount.incrementAndGet();
        log.info("活跃连接: {}", activeCount.get());
    }
    
    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
        activeCount.decrementAndGet();
        log.info("活跃连接: {}", activeCount.get());
    }
    
    public long getActiveCount() {
        return activeCount.get();
    }
}

把这个 listener 加到 pipeline 的最前面,定期打印 activeCount。如果这个数字持续增长,而 FD 数量也在增长,就能确认是连接没关干净。

第三步,找到具体是哪个 handler 或者哪段代码导致的问题。这时候需要借助 netstat 或者 ss 命令,看 socket 的状态。CLOSE_WAIT 状态过多通常意味着本地应用没有主动关闭连接,对端发了 FIN 但本地没有回 ACK;TIME_WAIT 过多则是正常现象,说明连接已经关闭但内核还在等待超时;如果是 ESTABLISHED 持续增长,那就是连接一直没断开。

# 查看各状态的连接数
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn

# 查看特定进程的 socket
lsof -p <PID> -i

如果 CLOSE_WAIT 很多,需要排查是不是对端(客户端)关闭了连接,而服务端的 handler 没有调用 ctx.close() 或者 channel.close()。这种情况常见于客户端异常断开,而服务端还在等数据。

几个容易踩的坑

第一个坑是误以为 channel.close() 就够了。 Netty 的 close() 是异步的,调用之后要等 closeFuture().sync() 才能真正释放资源。如果在 close() 之后立刻返回,后续的代码可能还在用这个 channel,而底层的 fd 已经处于关闭流程中,造成状态混乱。

第二个坑是 ChannelFutureListener 里忘记处理异常。 很多代码在关闭 channel 时会加一个 listener:

channel.close().addListener(future -> {
    if (!future.isSuccess()) {
        log.error("关闭失败");
    }
});

看起来没问题,但如果 log.error 本身抛了异常,或者 listener 的执行上下文有问题,可能导致整个 close 流程卡住。更稳妥的做法是把 listener 的逻辑尽量简化,异常处理放在外层。

第三个坑是 Bootstrap 的重复使用。 有些项目喜欢把 Bootstrap 或者 ServerBootstrap 当成单例复用,每次接受连接都调 bind()。实际上 bind() 只是绑定端口,不会创建新的 channel;但如果绑定成功后没有正确 unbind(),再绑定时可能会复用旧的 channel,导致 fd 累积。正确的做法是在应用启动时 bind 一次,关闭时 unbind 并 shutdown group。

第四个坑是自定义 IdleStateHandler 的参数配置不当。 IdleStateHandler 会在读空闲、写空闲或读写空闲时触发 userEventTriggered。如果配置的空闲时间太短,会把还在正常通信的连接误判为空闲并关闭,导致连接反复建立,fd 数量震荡;如果配置太长,又起不到检测泄漏的作用。一般建议根据业务的最小心跳间隔来设置,留有一定的余量。

预防比排查更重要

文件描述符泄漏最麻烦的地方在于,它不会立刻让你服务挂掉,但会慢慢蚕食系统的资源。等到 lsof 显示八万多个 fd 时,往往已经积累了好几天甚至几周。

所以比较好的做法是在服务启动时就设定一个基准值,记录当前的 FD 数量和活跃连接数,然后每隔一段时间采样对比。如果发现 FD 数量持续增长而活跃连接数没有相应增长,就说明有泄漏。这个检查可以放在一个定时任务里,也可以接入 Prometheus 之类的监控系统,设置告警阈值。

另外,代码审查时要把 channelInactive 和 channelUnregistered 这两个生命周期方法当成重点。凡是涉及网络连接关闭的逻辑,都应该有对应的资源释放。如果看到某个 handler 重写了 channelRead 但没有处理异常情况下的 channel 关闭,就要警惕了。

还有一个实用的习惯:在测试环境故意制造连接异常断开的场景,比如用 kill -9 杀掉客户端进程,或者用 tcpkill 切断连接,然后观察服务端的 FD 变化。如果 FD 没有回收,说明关闭逻辑有问题。这种压力测试成本低,但能暴露很多平时注意不到的问题。

Netty 本身的设计是健壮的,绝大多数情况下只要按照标准模式使用,不会出问题。但如果要在复杂场景下做连接管理、自定义 handler 链、或者复用连接池,就需要格外注意资源的生命周期。文件描述符的持续增长就是一个明确的信号,告诉你有什么东西没有关干净。抓住这个信号,顺着渠道往下查,通常能找到根因。

posted @ 2026-11-06 06:00  框架师  阅读(4)  评论(0)    收藏  举报