本节目标:把 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_64classifier)。
承接的问题
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:
| CPU | artifact | classifier | JVM 的 os.arch |
|---|---|---|---|
| x86_64 | netty-transport-native-epoll | linux-x86_64 | amd64 |
| aarch64 | netty-transport-native-epoll | linux-aarch_64 | aarch64 |
注意最后一列: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_FASTOPEN | ChannelOption<Integer> | 服务端 TFO 队列长度 |
TCP_FASTOPEN_CONNECT | ChannelOption<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 / musl | libc 或动态加载器相关 | 换 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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。