《Netty 高性能网络编程》12.3 Epoll 在 Linux 的配置

本节只写 Linux 部署侧:EpollEventLoopGroup 与 EpollServerSocketChannel 的配置、SO_REUSEPORT 与 TCP_FASTOPEN 的内核前提、classifier 与 glibc 兼容性,以及 Epoll.isAvailable() 为 false 时的回退。全部为部署说明,不是本机实测。

本节目标:把 linkd-v2 从 macOS 的 kqueue 部署到 Linux 的 epoll:装哪个构件、怎么确认生效、SO_REUSEPORT 和 TCP_FASTOPEN 各需要什么前提、失败时退到哪里。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-transport-classes-epoll + linux-x86_64 / linux-aarch_64 classifier)。

承接的问题

12.2 在本机 macOS 上确认了 kqueue:serverChannel 是 KQueueServerSocketChannel,eventLoop 是 KQueueEventLoopGroup$KQueueEventLoop。同一份 NativeEventLoops 部署到 Linux 后,detect() 应该返回 EPOLL,channel 类应该变成 EpollServerSocketChannel。

但从「代码能选」到「线上真的生效」之间还有四件事:镜像里有没有对应 classifier 的 native 库;Epoll.isAvailable() 为 false 时原因是什么;SO_REUSEPORT 与 TCP_FASTOPEN 需要哪些内核前提;容器的基础镜像能不能加载 glibc 的 .so。这些都不是 macOS 上能验证的。

本节没有在 Linux 上实测。 下面的代码按 Netty 4.2.19 的 API 编写,命令与输出块标注为「示意输出(非本机实测)」;上线前必须在预发机或目标镜像里跑一遍探针,以那台机器的结果为准。

核心机制一:classifier 与依赖

Linux 的 native 库按 CPU 架构选,部署里最常见的是 x86_64 和 aarch64 两个 classifier:

CPUartifactclassifierJVM 的 os.arch
x86_64netty-transport-native-epolllinux-x86_64amd64
aarch64netty-transport-native-epolllinux-aarch_64aarch64

注意最后一列:JVM 在 Linux x86_64 上报告的 os.arch 是 amd64,和 classifier 的 x86_64 不是同一个字符串。不要拿 os.arch 直接拼 artifact 名,也不要按 uname -m 的结果硬编码;classifier 是 Maven 的坐标,映射关系固定为这张表。

依赖声明如下,两个架构都写在父 POM 里,由构建参数决定镜像里保留哪一个:

<dependency>
  <groupId>io.netty</groupId>
  <artifactId>netty-transport-native-epoll</artifactId>
  <version>${netty.version}</version>
  <classifier>linux-x86_64</classifier>
</dependency>
<dependency>
  <groupId>io.netty</groupId>
  <artifactId>netty-transport-native-epoll</artifactId>
  <version>${netty.version}</version>
  <classifier>linux-aarch_64</classifier>
</dependency>

netty-transport-native-epoll 的 POM 会传递 netty-transport-classes-epoll,Java 类在后者,.so 在前者的 classifier jar 里。netty-all 是聚合 POM,不要假设它一定带了你需要的 classifier;显式声明才能让 mvn dependency:tree 给出可核对的坐标:

mvn -q dependency:tree -Dincludes=io.netty:netty-transport-native-epoll

示意输出(非本机实测):

[INFO] +- io.netty:netty-transport-native-epoll:jar:linux-x86_64:4.2.19.Final:compile

部署后在镜像里跑 12.1 的 TransportProbe,比在构建机上猜可靠:

java -cp "linkd-app/target/classes:..." com.plume.linkd.v2.server.TransportProbe

示意输出(非本机实测):

epoll=true
kqueue=false
os.name=Linux
os.arch=amd64

kqueue=false 是正常的:macOS 的 native 库不会在 Linux 上加载,KQueue.isAvailable() 返回 false,cause 链会指向平台不支持。只要 epoll=true,NativeEventLoops.detect() 就会选 EPOLL。

核心机制二:EpollEventLoopGroup 与监听 channel

跨平台路径已经由 NativeEventLoops 处理。如果这个部署包只面向 Linux,并且希望「不是 epoll 就启动失败」,可以直接钉死 epoll,把错误暴露在启动阶段:

package com.plume.linkd.v2.server;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.epoll.Epoll;
import io.netty.channel.epoll.EpollServerSocketChannel;

public final class LinuxEpollServer {

    private LinuxEpollServer() {
    }

    public static Channel bind(int port, ChannelHandler childHandler,
                               EventLoopGroup boss, EventLoopGroup worker)
            throws InterruptedException {
        if (!Epoll.isAvailable()) {
            throw new IllegalStateException("epoll unavailable: " + Epoll.unavailabilityCause());
        }
        ServerBootstrap bootstrap = new ServerBootstrap()
                .group(boss, worker)
                .channel(EpollServerSocketChannel.class)
                .option(ChannelOption.SO_BACKLOG, 1024)
                .option(ChannelOption.SO_REUSEADDR, true)
                .childOption(ChannelOption.TCP_NODELAY, true)
                .childOption(ChannelOption.SO_KEEPALIVE, true)
                .childHandler(childHandler);
        return bootstrap.bind(port).sync().channel();
    }
}

组由调用方创建和关闭,bind 只负责装配,这样 bind 失败时不会泄漏两个组:

EventLoopGroup boss = new EpollEventLoopGroup(1);
EventLoopGroup worker = new EpollEventLoopGroup();

EpollEventLoopGroup 和 KQueueEventLoopGroup 的构造函数形状一样:(int) 指定线程数,无参用默认值。boss 传 1,worker 不传,理由和 12.2 相同。传进来的两个组必须都是 epoll 实现;EpollServerSocketChannel 注册到 NioEventLoop 上会在 bind 阶段失败,而不是在编译期报错。

SO_REUSEADDR 与 SO_REUSEPORT 经常被混为一谈,实际语义不同:

选项作用典型用途
SO_REUSEADDR允许绑定处于 TIME_WAIT 的地址重启后快速重新监听
SO_REUSEPORT允许多个 socket 同时绑定同一地址和端口多进程/多 worker 分摊新连接

SO_REUSEPORT 在 Netty 4.2.19 里定义在 io.netty.channel.unix.UnixChannelOption,通过 EpollChannelOption 继承访问;所在的模块是 netty-transport-native-unix-common,由 epoll 的 classes jar 传递进来。代码里写 EpollChannelOption.SO_REUSEPORT 可以编译,但读源码时要知道字段不在 EpollChannelOption 自己身上。

打开它的写法:

bootstrap.option(EpollChannelOption.SO_REUSEPORT, true);

前提是 Linux 3.9 及以上,并且所有绑定同一端口的 socket 都设置了这个选项。只要有一个进程没设,后来的 bind 仍然得到 EADDRINUSE。它不能让一个已经独占端口的进程「让出」一半连接,也不是零停机发布的充分条件:旧进程仍然持有已经建立的连接,新连接按四元组哈希分配,长连接会继续落在旧进程上。

linkd-v2 还有一个应用层约束:WebSocketHub 的 ChannelGroup 是进程内的,10.3 的 /ws/broadcast 只广播给本进程持有的连接。用 SO_REUSEPORT 起两个 LinkdV2Main 共享 9090,广播就分裂成两个互不可见的集合。要么保持单进程,要么先有跨进程的消息总线,再谈多监听。

核心机制三:TCP_FASTOPEN 的前提

Netty 4.2.19 的 EpollChannelOption 提供两个相关选项:

选项类型作用
TCP_FASTOPENChannelOption<Integer>服务端 TFO 队列长度
TCP_FASTOPEN_CONNECTChannelOption<Boolean>客户端在首次写时携带数据完成连接

服务端启用:

if (Epoll.isTcpFastOpenServerSideAvailable()) {
    bootstrap.option(EpollChannelOption.TCP_FASTOPEN, 256);
}

这里的 256 是等待完成握手的 TFO 连接队列长度,不是字节数。前提有三层,缺一层都只会静默不生效或直接失败:

  • 内核 3.7 及以上支持 TCP Fast Open。
  • net.ipv4.tcp_fastopen 的服务端位(值为 2)已打开;客户端位是 1,两个都开是 3。
  • Netty 的 Epoll.isTcpFastOpenServerSideAvailable() 返回 true。它在运行时检查内核能力,比硬编码版本号可靠。

查看与设置:

sysctl net.ipv4.tcp_fastopen

示意输出(非本机实测):

net.ipv4.tcp_fastopen = 3

如果输出是 0 或 1,服务端 TFO 没有开。临时验证可以 sysctl -w net.ipv4.tcp_fastopen=3,持久化要写进 /etc/sysctl.d/;容器里还要确认该 sysctl 是否被允许修改。

TFO 的收益是短连接少一个 RTT:客户端在 SYN 里带首包数据,服务端在 SYN-ACK 里确认。它对 9093 的 HELLO 这类首包很小的场景有意义;对 9090 上 keep-alive 的 HTTP 长连接没有收益,因为连接早就建立了。中间设备可能丢弃 SYN 里的数据,跨公网部署要先在目标链路验证,不能只在同机回环上测。

客户端侧对应的是 TCP_FASTOPEN_CONNECT,前提是 Epoll.isTcpFastOpenClientSideAvailable() 为 true,并且 sysctl 的客户端位已开。linkd-client 如果部署在 Linux 上,可以在 Bootstrap 上设置。它是布尔选项,和上面的 TCP_FASTOPEN 队列长度不是同一个东西;两者的探测方法也分别是 isTcpFastOpenClientSideAvailable() 和 isTcpFastOpenServerSideAvailable()。

核心机制四:容器与 glibc

官方 classifier 里的 .so 面向 glibc 构建。基于 Debian/Ubuntu 的 eclipse-temurin:25-jre 一类镜像可以直接加载;Alpine 用的是 musl,加载会失败,表现不是编译错误,而是 Epoll.isAvailable() 返回 false,cause 里提到 libc 或动态加载器。

判断方法只有一个:在最终镜像里跑 TransportProbe,看 epoll=true。在构建机上 ldd 通过不代表镜像里通过,因为 .so 可能来自另一个基础镜像。Alpine 上如果找不到可用的 musl 构件,换 glibc 基础镜像比在镜像里补兼容层更可控。

多架构构建要准备两份 classifier:x86_64 节点用 linux-x86_64,ARM 节点用 linux-aarch_64。只打一份就在另一种架构上得到 epoll=false,而 JVM 的 os.arch 可能仍然显示为 amd64 或 aarch64,看起来「架构没错」。镜像标签里写清架构,CI 里对每个架构各跑一次探针。

文件描述符上限也要一起看。epoll 让一个线程管理大量连接,但每条连接仍然占一个 fd:

ulimit -n

示意输出(非本机实测):

1048576

这个值要大于预期的并发连接数加上文件、管道等开销。容器里 ulimit 可能被 runtime 限制,部署前确认,不要只在宿主机上调。

核心机制五:isAvailable 为 false 时的回退

Linux 上 Epoll.isAvailable() 为 false,原因通常落在三类:

现象cause 的关键信息处理
非 Linux 环境(本地 macOS)ExceptionInInitializerError → Only supported on Linux预期行为,走 kqueue 或 NIO
Linux 缺 classifier 或架构错ExceptionInInitializerError → UnsatisfiedLinkError: cannot load ...补对应 classifier,核对架构
Alpine / musllibc 或动态加载器相关换 glibc 基础镜像,或验证可用的 musl 构件

回退策略分环境。开发机允许回退,打一条 WARNING;生产要求 epoll 时,启动即失败:

NativeEventLoops.Transport transport = NativeEventLoops.detect();
if (Boolean.getBoolean("linkd.require.native")
        && transport != NativeEventLoops.Transport.EPOLL) {
    throw new IllegalStateException("epoll required, got " + transport
            + ": " + Epoll.unavailabilityCause());
}

为什么生产要 fail-fast,而不是安静地退回 NIO:SO_REUSEPORT 和 TCP_FASTOPEN 都是 epoll 通道的选项,设在 NIO 通道上不会生效。bootstrap.option(...) 不会因为选项不认识就抛异常,只在日志里留一条 Unknown channel option 警告,bind 仍然成功。最坏的情况是进程正常服务,但你以为开了 REUSEPORT,实际只有一个进程在监听。性能问题可以后补,配置语义被静默改变很难发现。

linkd.require.native 通过 -D 传入,开发机不传或传 false,Linux 部署传 true。不要把默认值设成 true:macOS 开发机也会启动失败,12.2 的 kqueue 路径就没法用了。

在 linkd-v2 里落地

把 epoll 专属选项收进 NativeEventLoops,默认关闭,由配置显式打开:

public static void applyServerOptions(ServerBootstrap bootstrap,
                                      Transport transport) {
    bootstrap.option(ChannelOption.SO_BACKLOG, 1024);
    if (transport != Transport.EPOLL) {
        return;
    }
    if (Boolean.getBoolean("linkd.reuseport")) {
        bootstrap.option(EpollChannelOption.SO_REUSEPORT, true);
    }
    if (Boolean.getBoolean("linkd.tfo")
            && Epoll.isTcpFastOpenServerSideAvailable()) {
        bootstrap.option(EpollChannelOption.TCP_FASTOPEN, 256);
    }
}

这段方法要补 io.netty.channel.ChannelOption 和 io.netty.channel.epoll.EpollChannelOption 两个 import;NativeEventLoops 已有的 Netty import 里没有它们。LinkdServer.bind() 在 applyChannel 之后、bind 之前调用它:

NativeEventLoops.applyChannel(bootstrap, transport);
NativeEventLoops.applyServerOptions(bootstrap, transport);
Channel channel = bootstrap.bind(port).sync().channel();

这两个开关的默认值都是关,理由是它们都依赖部署环境:linkd.reuseport 要求多进程和跨进程广播方案同时到位,linkd.tfo 要求内核 sysctl 已开且链路允许 SYN 带数据。默认打开会让一个普通单进程部署莫名其妙地多出监听语义,排查时很难想到是配置层加的。

启动日志除了 12.2 的 channel/eventLoop 类名,再把两个开关的实际状态打出来:

LOG.log(System.Logger.Level.INFO,
        "transport={0} reuseport={1} tfo={2}",
        transport,
        Boolean.getBoolean("linkd.reuseport"),
        Boolean.getBoolean("linkd.tfo") && Epoll.isTcpFastOpenServerSideAvailable());

日志里 tfo=true 只代表「配置想开且内核报告支持」,不代表握手真的走了 TFO;后者要用抓包或服务端计数器确认,不在本节展开。reuseport=true 也只代表选项已设置,是否真的多进程分担连接,要看进程数和 ss -lntp 的监听项。

常见错误与边界

错误一:在 macOS 开发机上设置 linkd.require.native=true。 启动直接抛 IllegalStateException: epoll required, got KQUEUE,看起来像代码坏了,其实是环境策略用错。这个参数只应出现在 Linux 的启动脚本或容器环境变量里。

错误二:以为 SO_REUSEPORT 能抢占已被占用的端口。

java.net.BindException: Address already in use

只有所有绑定同一端口的 socket 都设置 SO_REUSEPORT,内核才允许多个监听;已经独占端口的进程如果没设,新进程仍然失败。排查用 ss -lntp(macOS 用 lsof -nP -iTCP:9090),先确认端口被谁持有。

错误三:只开了 sysctl 的客户端位。 net.ipv4.tcp_fastopen=1 只允许客户端发起 TFO,服务端仍然不接受 SYN 里的数据;Epoll.isTcpFastOpenServerSideAvailable() 返回 false,TCP_FASTOPEN 不会生效。服务端要 2 或 3。

错误四:把 TCP_FASTOPEN 设在 childOption 上。 它是监听 socket 的选项,必须用 bootstrap.option(...);放在 childOption 上,accept 出来的连接不认识这个选项,配置被忽略。SO_REUSEPORT 同理,它作用于监听 socket。

错误五:镜像里只记录 epoll=false,不记录 cause。 Alpine、缺 classifier、架构错三种情况的处理完全不同,只看布尔值无法区分。启动时打一次 Epoll.unavailabilityCause(),把根因留在日志里。

边界一:os.arch 不是 classifier。 Linux x86_64 的 JVM 报 amd64,artifact 要 linux-x86_64;aarch64 报 aarch64,artifact 要 linux-aarch_64。按表映射,不要字符串拼接。

边界二:Epoll.isAvailable() 检测的是 Netty 的 native 库,不是内核。 Linux 内核一直有 epoll;false 说明库没加载成功,不是「内核不支持」。反过来,true 也不代表你设置了 REUSEPORT 或 TFO,那些是独立的选项。

边界三:REUSEPORT 与 WebSocket 广播冲突。 10.3 的 WebSocketHub 按进程持有 ChannelGroup;多进程共享 9090 后,/ws/broadcast 只覆盖本进程的连接。要么单进程部署,要么先补跨进程总线,不能只改一个 socket 选项就上线。

边界四:ulimit -n 小于连接目标时,epoll 也救不了。 文件描述符耗尽的表现是 accept 失败或新连接被拒绝,不是 CPU 或带宽问题。容量评估时把 fd 上限和连接数一起算。

小结

  • Linux 的 epoll 构件按 CPU 选 linux-x86_64 或 linux-aarch_64;JVM 的 os.arch 是 amd64 或 aarch64,不能直接当 classifier 用。
  • EpollEventLoopGroup、EpollServerSocketChannel 与 NIO/kqueue 的构造形状一致,业务 pipeline 不变;Linux-only 部署可以在启动时钉死 epoll 并 fail-fast。
  • SO_REUSEPORT 要求 Linux 3.9+ 且所有同端口 socket 都设置;它解决多监听分摊,不解决端口被独占,也不自动带来跨进程广播。
  • TCP_FASTOPEN 需要内核 3.7+、sysctl 服务端位已开(值为 2 或 3)、以及 Epoll.isTcpFastOpenServerSideAvailable() 为 true;它省的是短连接的一个 RTT,不是吞吐。
  • Alpine/musl 加载不了 glibc 的 .so;在最终镜像里跑 TransportProbe 是唯一可靠的验证方式,失败时记录 unavailabilityCause()。
  • 生产建议用 linkd.require.native=true 阻止静默回退,因为 SO_REUSEPORT / TCP_FASTOPEN 在 NIO 通道上会被忽略而不报错。

平台选择、macOS 验证和 Linux 部署三条线到这里闭合。下一章回到资源本身:13.1 用 ResourceLeakDetector 抓未释放的 ByteBuf,把「连接能跑」推进到「内存不涨」。

阅读导航:上一节:12.2 KQueue 在 macOS 的实测 · 下一节:13.1 ResourceLeakDetector 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发