Netty / 网络编程基础
先判断业务是否真的需要直接使用 Netty
Netty 不是每个业务系统都要直接写的框架。普通 HTTP 接口交给 Spring MVC 或 WebFlux,常规 RPC 交给成熟框架,消息通信交给 MQ 客户端。你需要理解和使用 Netty 的场景,通常是自定义协议、长连接、网关、推送服务、设备接入、内部通信框架、代理转发和 RPC 底层能力。
确认需要直接控制网络链路后,再从 Socket、NIO、Netty 线程模型、Pipeline 和 ByteBuf 走到粘包拆包、心跳、编解码、RPC、背压、零拷贝、Native transport、连接数、超时、协议兼容、故障注入和线上排查。操作系统内核调优、四层负载均衡、证书体系、监控平台和完整 RPC 框架源码各有独立的部署或架构主线。
如果项目仍在 Netty 4.1,先升级到同一维护线的目标补丁,确认依赖冲突、TLS 行为和内存分配器没有异常,再按官方迁移指南评估 4.2。无论选择哪条线,都不要让 4.1 和 4.2 同时出现在同一个运行时 classpath。
跑网络实验前准备协议、诊断和故障注入工具
语言环境:Java 17 或 Java 21 都可以完成下面的实验,生产项目按团队 JDK 基线决定。依赖管理:用 netty-bom 统一 Netty 版本,避免 netty-buffer、netty-transport、netty-codec 被不同组件拉成多个版本。实验工具:nc、ss、tcpdump、jcmd、tc、压测脚本和应用日志。tc 这类网络故障注入命令只在测试环境执行。
Maven 不通时:优先使用企业 Maven 私服或构建缓存,不在项目里写死临时镜像地址。
完整知识树
Socket
-> ServerSocketChannel / SocketChannel
-> Selector / SelectionKey
-> EventLoopGroup
-> EventLoop
-> Channel
-> ChannelPipeline
-> ChannelHandler
-> ByteBuf / ByteBufAllocator
-> 编解码
-> 粘包拆包
-> 心跳
-> 读写超时
-> 写缓冲水位 / AutoRead
-> 业务线程池边界
-> RPC requestId / inflight / 超时预算
-> 线上排查和上线检查相邻问题各有自己的排障主线
不写完整 Dubbo、gRPC、HTTP/2 或 MQTT 框架源码。不展开机房网络、四层负载均衡、证书签发、内核参数大表。不把 Netty 当万能性能药。业务慢 SQL、线程池阻塞、下游超时、错误重试策略,不是换 Netty 就能解决。
关键结论
NIO 的价值是少量线程复用大量连接,但非阻塞 IO 不等于业务处理也能随便阻塞。Netty 的 EventLoop 要保持轻,耗时业务放到业务线程池,队列必须有上限。TCP 是字节流,必须设计协议边界,不能指望一次 read 就是一条完整消息。
ByteBuf 有引用计数,跨线程、切片、聚合和异常路径都要明确谁释放。背压要靠写缓冲水位、isWritable()、AUTO_READ、队列上限、限速和超时共同完成。心跳不是“定时刷存在感”,而是识别假连接、释放资源和控制重连风暴。
先用 NIO 看清底层模型
场景说明:如果只会用 Netty API,不理解 NIO,遇到连接很多、读写不均匀、CPU 空转、线程卡住时会很难排查。Netty 把这些细节封装得很好,但线上问题仍然会从 Selector、Channel、ByteBuffer 和线程栈里露出来。
直接做法:先看一个最小 NIO 服务端骨架。
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(9000));
server.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select();
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isAcceptable()) {
SocketChannel client = server.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(4096));
continue;
}
if (key.isReadable()) {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
int read = client.read(buffer);
if (read < 0) {
client.close();
continue;
}
buffer.flip();
client.write(buffer);
buffer.clear();
}
}
}验证结果:用 nc 127.0.0.1 9000 连上后输入文本,能收到回显。再开多个连接,服务端仍然是一个 selector 线程在处理多个连接事件。
常见坑:上面代码只能演示 NIO 模型,不能直接上生产。它没有协议边界、没有半包处理、没有异常隔离、没有心跳、没有业务线程池、没有背压。更隐蔽的问题是 client.write(buffer) 并不保证一次把全部数据写完,生产代码必须处理部分写出。
生产建议:NIO 的核心不是“线程少”,而是“连接事件和业务处理要解耦”。事件循环一旦被业务阻塞,这个 EventLoop 绑定的一批连接都会变慢。排查时先看 EventLoop 线程是否停在 select、读写系统调用、编解码、业务方法还是锁等待上。
Netty 4.2 和 4.1 怎么选
场景说明:很多项目已经在 Netty 4.1 跑了多年,直接把版本号改到 4.2 不一定会编译失败,但可能在 TLS、内存分配器、native transport 和废弃 API 上留下隐患。
直接做法:用 BOM 固定版本,并在构建中检查 Netty 依赖树。
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-bom</artifactId>
<version>${netty.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-transport</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-handler</artifactId>
</dependency>
</dependencies>mvn -q dependency:tree -Dincludes=io.netty验证结果:依赖树里 Netty 模块应该都在同一个版本线上,不能一部分 4.1、一部分 4.2。新项目可以直接用当前 4.2.x;老项目建议先升到最新 4.1.x,压测和灰度通过后再升 4.2.x。
常见坑:
只升级直接依赖,没有处理 Redis 客户端、网关、RPC 框架带来的传递依赖。仍按 4.1 习惯创建 NioEventLoopGroup,在 4.2 中它已经是废弃入口。TLS 客户端没有显式配置主机名校验,升级后行为变化导致连接失败或安全策略不一致。
4.2 默认 allocator 变为 adaptive,只看吞吐,没有观察堆内存、直接内存、GC 和分配器差异。
生产建议:Netty 4.2 新代码优先使用 MultiThreadIoEventLoopGroup 搭配具体 transport 的 IoHandlerFactory。升级第一阶段可以显式加 -Dio.netty.allocator.type=pooled 降低 allocator 变化风险;压测确认后,再评估是否切回 4.2 默认的 adaptive。如果业务仍在 Netty 4.1,代码可以继续用 NioEventLoopGroup,但要把迁移风险写进技术债清单。
// Netty 4.2 推荐入口
EventLoopGroup worker = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory());
// Netty 4.1 常见入口。迁移 4.2 时再替换。
// EventLoopGroup worker = new NioEventLoopGroup();Native transport:NIO、Epoll、KQueue 和 io_uring 怎么选
场景说明:native transport 不是“打开就更快”的开关。它把事件通知、socket 选项和部分系统调用路径贴近目标操作系统,适合高连接数、高吞吐、低延迟或需要 Linux/macOS 特定能力的服务;但它也引入 native 依赖、平台 classifier、容器基础镜像和回滚复杂度。
选择边界:
| transport | 适用环境 | 适合场景 | 风险点 |
|---|---|---|---|
| NIO | 所有主流平台 | 默认基线、跨平台服务、功能验证 | 性能不是所有场景最优,但最稳妥 |
| Epoll | Linux | 大量连接、Linux socket 选项、生产 Linux 服务 | native 依赖 classifier、glibc/musl、容器镜像兼容 |
| KQueue | macOS / BSD | macOS 开发验证、BSD 部署环境 | 不能拿 macOS 压测结论直接替代 Linux 生产 |
| io_uring | 较新 Linux 内核,Netty incubator 生态 | 明确要验证新内核 I/O 能力的专项压测 | incubator 属性更强,要准备 Epoll/NIO 回退 |
直接做法:代码里显式检测可用性,启动日志打印最终选择和不可用原因。
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-transport-native-epoll</artifactId>
<classifier>linux-x86_64</classifier>
</dependency>IoHandlerFactory factory;
Class<? extends ServerChannel> serverChannel;
if (Epoll.isAvailable()) {
factory = EpollIoHandler.newFactory();
serverChannel = EpollServerSocketChannel.class;
} else {
factory = NioIoHandler.newFactory();
serverChannel = NioServerSocketChannel.class;
log.warn("Epoll unavailable, fallback to NIO", Epoll.unavailabilityCause());
}
EventLoopGroup boss = new MultiThreadIoEventLoopGroup(1, factory);
EventLoopGroup worker = new MultiThreadIoEventLoopGroup(factory);
new ServerBootstrap()
.group(boss, worker)
.channel(serverChannel);验证结果:
mvn -q dependency:tree -Dincludes=io.netty
uname -a
java -jar app.jar | grep -E "Epoll|KQueue|NIO|transport"压测报告要同时给出 transport、内核版本、容器基础镜像、JDK、Netty 版本、连接数、吞吐、P99、EventLoop 延迟、CPU、直接内存和错误率。只说“换成 epoll 更快”没有可复现价值。
常见坑:
依赖树里有 native transport,但运行平台 classifier 不匹配,启动后悄悄回退 NIO。在 Alpine/musl 镜像里直接套用 glibc 环境下的 native 依赖结论。只压测吞吐,不看 EventLoop 延迟、GC、直接内存和连接抖动。
看到 io_uring 就默认替换 Epoll。它需要单独压测、灰度和回退策略。
生产建议:新项目先用 NIO 跑通协议和稳定性,再用 Epoll/KQueue 做同条件压测对照。transport 选择必须可配置、可回退、可观测;灰度时把 fallback 原因打到启动日志和指标里。
Netty 最小 TCP 服务端
场景说明:项目里要做设备接入或内部 RPC,可以先跑一个长度字段协议的 TCP 服务端。不要一开始就手写复杂二进制协议,也不要先追求零拷贝。先把连接、协议边界、线程隔离、心跳、背压和释放规则跑通。
直接做法:Boss 负责接连接,Worker 负责读写事件,业务线程池负责耗时业务。下面示例按 Netty 4.2 写;如果项目仍是 4.1,把 MultiThreadIoEventLoopGroup(...NioIoHandler...) 替换成 NioEventLoopGroup。
先把线程模型看成三层,后面的代码就不容易乱:
这张图也是排障顺序:连接建不起来先看 Boss 和端口;读写慢看 Worker EventLoop 栈;业务慢看业务线程池;回写堆积看 Channel 写缓冲和 isWritable()。
public final class RpcServer {
public static void main(String[] args) throws InterruptedException {
EventLoopGroup boss = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory());
EventLoopGroup worker = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory());
DefaultEventExecutorGroup businessGroup = new DefaultEventExecutorGroup(32);
try {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(boss, worker)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childOption(ChannelOption.TCP_NODELAY, true)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childOption(ChannelOption.WRITE_BUFFER_WATER_MARK,
new WriteBufferWaterMark(64 * 1024, 256 * 1024))
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline p = ch.pipeline();
p.addLast(new LengthFieldBasedFrameDecoder(
1024 * 1024, 0, 4, 0, 4));
p.addLast(new LengthFieldPrepender(4));
p.addLast(new IdleStateHandler(60, 30, 0, TimeUnit.SECONDS));
p.addLast(new RpcFrameDecoder());
p.addLast(new RpcFrameEncoder());
p.addLast(new HeartbeatHandler());
p.addLast(businessGroup, new RpcServerHandler());
}
});
Channel channel = bootstrap.bind("0.0.0.0", 9000).sync().channel();
channel.closeFuture().sync();
} finally {
boss.shutdownGracefully();
worker.shutdownGracefully();
businessGroup.shutdownGracefully();
}
}
}验证结果:
ss -lntp | grep 9000应看到服务监听 0.0.0.0:9000 或指定地址。用客户端发送一条完整长度字段帧,服务端能进入业务 handler。
常见坑:
每个连接创建一个 EventLoopGroup,很快出现 selector、线程、端口或文件句柄资源耗尽。在 channelRead0 里查数据库、调 HTTP、做复杂计算,导致 IO 线程被拖住。没有 LengthFieldBasedFrameDecoder 这类边界处理器,半包时 JSON 解析直接报错。
Spring Boot 里用普通 Bean 启动 Netty,但应用关闭时没有优雅关闭 EventLoopGroup,重启后端口仍被占用。
生产建议:EventLoopGroup 是进程级资源,通常复用一组。客户端大量连接也不要每个连接一个 EventLoopGroup。Spring Boot 集成时,把 Netty 启停挂到应用生命周期里,关闭时先停止接收新连接,再关闭业务线程池和 IO 线程池。
Pipeline 和 Handler:顺序就是协议
场景说明:Netty 的 ChannelPipeline 很像一条流水线,但它不是随便堆 handler 的地方。入站从前往后走,出站从后往前走。编解码、认证、心跳、业务处理、异常处理的顺序错了,现象会很奇怪:心跳包被业务 handler 当成业务消息、半包直接进 JSON 解析、异常没有释放 ByteBuf。
入站和出站方向要分开记:
入站 handler 放错,半包和非法包会进业务;出站 handler 放错,长度字段、加密、压缩和写出顺序会错。协议兼容时优先收敛在 frame、message type 和 codec 层,不要把同一份兼容逻辑散落到多个业务 handler。
直接做法:按“边界 -> 反序列化 -> 协议语义 -> 业务 -> 编码”的顺序组织。
入站:Socket 字节
-> LengthFieldBasedFrameDecoder
-> RpcFrameDecoder
-> AuthHandler
-> HeartbeatHandler
-> RpcServerHandler
出站:业务对象
-> RpcFrameEncoder
-> LengthFieldPrepender
-> Socket 字节验证结果:打开 LoggingHandler 或在关键 handler 记录 message type,心跳、认证、业务消息应该进入不同分支。异常包、半包、大包都不应该进入业务 handler。
常见坑:
把 LengthFieldPrepender 放错方向,以为它会处理入站半包。@Sharable 乱用,handler 里有连接级状态,多个 Channel 共享后互相污染。认证成功后不移除一次性认证 handler,后续每条业务消息都走重复校验。
在 handler 中捕获异常后吞掉,不关闭异常连接,也不释放对象。
生产建议:Pipeline 应该像协议文档一样稳定。协议升级时先改 frame 和 message type,再改业务 handler;不要把兼容逻辑散落在多个业务 handler 里。
粘包拆包:TCP 只认字节流
场景说明:你发送两次 writeAndFlush,对端可能一次 read 到两条消息,也可能一条消息被拆成多次 read。这不是 Netty bug,而是 TCP 字节流的正常行为。
直接做法:协议先定帧格式。最小可用帧可以是长度字段加 body:
| length: 4 bytes | body: length bytes |生产协议建议至少保留这些字段:
| magic:2 | version:1 | type:1 | flags:1 | requestId:8 | bodyLength:4 | body |Netty 里用长度字段解码器:
new LengthFieldBasedFrameDecoder(
1024 * 1024, // 单帧最大 1MB
0, // 长度字段从第 0 字节开始
4, // 长度字段 4 字节
0, // length 不额外调整
4 // 解码后剥掉 length 字段
);验证结果:连续快速发送 1000 条消息,消费者收到的消息数量应等于发送数量,且每条 body 都完整。
tcpdump -i any port 9000 -w netty-demo.pcap抓包只用来观察 TCP 分段和重传,不要用抓包里的分段推断业务消息边界。业务边界只能由协议字段和解码器保证。
常见坑:
用换行符做分隔,但业务内容里也可能出现换行。不限制最大帧长度,被异常大包打爆内存。协议只写 body,不写版本、类型和 requestId,后续无法兼容升级。
bodyLength 表示的是 body 长度还是整帧长度没有写清,客户端和服务端各理解一套。
生产建议:版本字段不是装饰,它决定灰度期间新老客户端能不能共存。错误码也要进入协议语义,例如 SERVER_BUSY、TOO_MANY_REQUESTS、BAD_FRAME、UNSUPPORTED_VERSION,否则客户端只能看到断连,容易触发重连风暴。
编解码:不要把大对象塞进 EventLoop
场景说明:编解码看起来只是 JSON 转对象,流量上来后会变成 CPU 和内存热点。特别是大消息、复杂反序列化、压缩解压、加解密和校验摘要,都会拖慢 EventLoop。
直接做法:轻量 frame 边界处理放 pipeline;复杂编解码、业务校验、数据库查询、HTTP 调用放业务线程池。
public final class RpcFrameDecoder extends MessageToMessageDecoder<ByteBuf> {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf msg, List<Object> out) {
if (msg.readableBytes() < 17) {
throw new CorruptedFrameException("frame header is incomplete");
}
int magic = msg.readUnsignedShort();
if (magic != 0xCAFE) {
throw new CorruptedFrameException("bad magic: " + magic);
}
byte version = msg.readByte();
byte type = msg.readByte();
byte flags = msg.readByte();
long requestId = msg.readLong();
int bodyLength = msg.readInt();
if (bodyLength != msg.readableBytes()) {
throw new CorruptedFrameException("body length mismatch");
}
byte[] body = ByteBufUtil.getBytes(msg, msg.readerIndex(), bodyLength, false);
out.add(new RpcFrame(version, type, flags, requestId, body));
}
}验证结果:压测时观察 EventLoop 线程堆栈。如果大量停在 JSON 解析、数据库调用、HTTP 调用、加解密或锁等待,说明工作放错地方。
jcmd <pid> Thread.print | grep -A 30 "nio"常见坑:
ByteBuf 转 byte 数组会有复制成本,简单可靠但不是极致性能。反序列化对象没有大小限制,单个异常包就能制造内存尖峰。压缩包先解压再校验大小,容易被压缩炸弹打穿。
用业务线程池后以为就安全了,但业务池队列无上限,最终只是把 OOM 从 IO 线程换到业务队列。
生产建议:先把协议边界、最大帧、超时、幂等、错误码和可观测做好,再优化序列化。很多网络系统不是慢在序列化,而是慢在下游阻塞和无背压。
ByteBuf 生命周期:谁最后用,谁释放
场景说明:Netty 4.x 的部分对象使用引用计数。该释放不释放,堆内存可能看起来正常,直接内存却持续上涨,最后 OOM。更麻烦的是泄漏点通常不在 OOM 现场,而在很早之前某个异常分支、异步分支或测试代码里。
引用计数的规则可以压成一句:谁把对象带出当前调用栈,谁就要把所有权说清楚。
slice()、duplicate()、CompositeByteBuf 和异步任务都是泄漏高发点。只要对象离开当前 handler 的生命周期,就要在代码审查里明确 retain 和 release 的配对关系。
直接做法:谁最后持有 ByteBuf,谁负责释放。使用 SimpleChannelInboundHandler 可以减少手工释放负担;手动处理时用 try/finally。
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
try {
// process msg
} finally {
ReferenceCountUtil.release(msg);
}
}跨线程时要特别小心:
ByteBuf retained = ((ByteBuf) msg).retain();
businessPool.execute(() -> {
try {
process(retained);
} finally {
retained.release();
}
});验证结果:测试环境打开泄漏检测,压测后日志没有 LEAK: ByteBuf.release() 相关告警,直接内存水位稳定。
java -Dio.netty.leakDetection.level=advanced -jar app.jar
jcmd <pid> VM.native_memory summary常见坑:
把 ByteBuf 放进异步任务,当前 handler 返回后对象生命周期已经不清晰。slice()、duplicate() 得到的是共享引用计数的派生 buffer,传给别的组件前没有 retain()。readBytes(int) 返回新 buffer,需要独立释放。
异常路径里直接 return,没有走释放逻辑。
生产建议:泄漏检测高级模式有开销,适合测试和灰度定位。单元测试可以用更严格的泄漏检测;生产长期治理靠代码规范、压测、直接内存指标和发布前异常压测。发现泄漏后不要全量上线“碰碰运气”,先灰度复现并定位到最后处理该对象的 handler。
零拷贝边界:少拷贝,不是没有拷贝
场景说明:Netty 里常被叫作零拷贝的能力分几类:文件发送走 FileRegion / sendfile,多个 buffer 组合用 CompositeByteBuf,切片视图用 slice() / retainedSlice(),直接内存减少堆到 native 的复制。这些都不是“绝对没有拷贝”,更不是可以跳过生命周期管理。
直接做法:静态大文件、协议附件或落盘后的响应体,可以在明文 TCP 场景下用 DefaultFileRegion 验证 sendfile 路径。
Path path = Path.of(config.exportFile());
FileChannel file = FileChannel.open(path, StandardOpenOption.READ);
long size = file.size();
ChannelFuture future = ctx.writeAndFlush(new DefaultFileRegion(file, 0, size));
future.addListener(closeFuture -> file.close());如果 Pipeline 里有 SslHandler、压缩、加密、动态变换或内容审计,sendfile 的内核直发优势通常就不存在了。这时要改用 ChunkedWriteHandler 加 ChunkedNioFile 这类分块写法,并把背压和文件句柄关闭做好。
验证结果:
# 测试环境观察是否走 sendfile 系统调用。
strace -f -e sendfile -p <pid>
# 同时观察直接内存、RSS、写缓冲和慢客户端。
jcmd <pid> VM.native_memory summary
ss -tin sport = :9000常见坑:
把 CompositeByteBuf 当成免费拼接。它减少立即复制,但组件 buffer 的引用计数仍要管理。把 slice() 当成新内存。切片共享底层存储,异步传递前要用 retainedSlice() 或显式 retain()。TLS 链路里仍宣传 sendfile 零拷贝收益,忽略了加密必须读取数据并转换。
只看用户态复制次数,不看文件句柄、直接内存、发送队列和慢客户端背压。
生产建议:零拷贝优化只在大包、大文件、高吞吐且协议路径稳定时值得做。小包 RPC、JSON 编解码和业务下游慢,通常先优化协议、批量、背压和线程隔离;否则只是把复杂度加在错误位置。
心跳:识别假连接,不是刷存在感
场景说明:长连接最怕假连接。设备断电、NAT 回收、防火墙中断、客户端进程卡死、服务端长时间 GC,都可能让一端以为连接还在。
直接做法:用 IdleStateHandler 触发空闲事件,再由业务 handler 发 ping、校验 pong 或关闭连接。
public final class HeartbeatHandler extends ChannelInboundHandlerAdapter {
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent idle) {
if (idle.state() == IdleState.READER_IDLE) {
ctx.close();
return;
}
if (idle.state() == IdleState.WRITER_IDLE) {
ctx.writeAndFlush(RpcFrame.ping());
return;
}
}
super.userEventTriggered(ctx, evt);
}
}验证结果:客户端停止发送数据 60 秒后,服务端关闭连接;服务端空闲 30 秒时发送 ping。客户端连续 N 次没有收到 pong,应主动关闭连接并按退避策略重连。
常见坑:
只发 ping,不校验 pong,假连接仍然存在。每个连接自己创建定时任务,连接数上来后定时任务堆积。心跳间隔太短,心跳流量本身变成负载。
把 TCP KeepAlive 当成应用心跳。TCP KeepAlive 适合底层探活,不知道业务协议是否仍能处理请求。
生产建议:心跳周期要和网络链路、NAT 超时、业务实时性配合。百万连接场景下,小小的心跳周期调整都会带来明显流量和 GC 差异。重连必须有指数退避和随机抖动,避免服务恢复时所有客户端同时打进来。
RPC 最小模型:requestId、超时和异步响应
场景说明:RPC 不是“把对象写到 socket”。它至少要解决请求响应匹配、超时、并发、异常、连接复用、版本兼容和幂等边界。
直接做法:每个请求带 requestId,客户端用 CompletableFuture 等响应,超时后清理。
class RpcClient {
private final Channel channel;
private final ConcurrentMap<Long, CompletableFuture<RpcFrame>> inflight = new ConcurrentHashMap<>();
private final ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor();
CompletableFuture<RpcFrame> invoke(String service, byte[] body, Duration timeout) {
long requestId = RequestIds.next();
RpcFrame request = RpcFrame.request(requestId, service, body);
CompletableFuture<RpcFrame> future = new CompletableFuture<>();
inflight.put(requestId, future);
timer.schedule(() -> {
CompletableFuture<RpcFrame> removed = inflight.remove(requestId);
if (removed != null) {
removed.completeExceptionally(new TimeoutException("rpc timeout: " + requestId));
}
}, timeout.toMillis(), TimeUnit.MILLISECONDS);
channel.writeAndFlush(request).addListener(writeFuture -> {
if (!writeFuture.isSuccess()) {
CompletableFuture<RpcFrame> removed = inflight.remove(requestId);
if (removed != null) {
removed.completeExceptionally(writeFuture.cause());
}
}
});
return future;
}
void onResponse(RpcFrame response) {
CompletableFuture<RpcFrame> future = inflight.remove(response.requestId());
if (future != null) {
future.complete(response);
}
}
}验证结果:服务端正常响应时 future 完成;服务端不响应时 future 在超时时间后失败,inflight 不持续增长。
常见坑:
超时后不清理 inflight,压测一段时间内存持续上涨。客户端已经超时重试,服务端后来仍完成写操作,导致重复提交。一个连接上无限并发请求,没有 inflight 上限,慢下游时内存被占满。
超时配置只在客户端,服务端业务已经无意义但仍继续执行。
生产建议:写类 RPC 默认不要自动重试;必须重试时带业务幂等键。读类 RPC 可以重试,但要加超时预算,不要入口 2 秒超时,下游每层也各等 2 秒。服务端繁忙时要返回明确错误码,让客户端退避,而不是粗暴断连接。
背压:写不出去时要停下来
场景说明:下游客户端慢、网络抖动或对端读不动时,服务端还一直 writeAndFlush,最终会把发送队列和内存打爆。
写缓冲水位是 Netty 给应用层的过载信号:
这不是“性能优化小参数”,而是保护服务端内存、EventLoop 和慢客户端的边界。业务必须定义不可写时的策略,否则技术信号没有意义。
直接做法:设置写缓冲水位,并在不可写时暂停生产或关闭自动读。
bootstrap.childOption(ChannelOption.WRITE_BUFFER_WATER_MARK,
new WriteBufferWaterMark(64 * 1024, 256 * 1024));@Override
public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {
if (!ctx.channel().isWritable()) {
ctx.channel().config().setAutoRead(false);
} else {
ctx.channel().config().setAutoRead(true);
}
super.channelWritabilityChanged(ctx);
}验证结果:模拟慢客户端时,服务端内存不应无限上涨,连接要么限速恢复,要么超时关闭。
ss -tin sport = :9000
jcmd <pid> VM.native_memory summary常见坑:
只设置业务线程池队列,不管 Netty channel 写队列。大批量 writeAndFlush 每条都 flush,系统调用变多。对不可写连接继续投递消息,导致延迟越来越高。
AUTO_READ 关闭后没有恢复路径,连接进入“活着但不读”的状态。
生产建议:背压要有业务策略。对在线推送可以丢弃低优先级消息;对 RPC 要快速失败;对设备上报要限速或断开异常连接。isWritable() 是技术信号,最终怎么处理要写进业务协议和调用方约定。
业务线程边界:不要把阻塞传染给 IO 线程
场景说明:Netty 的 IO 线程适合短小、确定、无阻塞的工作。真正拖垮系统的常常不是 read/write,而是 handler 里做了数据库查询、外部 HTTP、复杂计算或等待锁。
直接做法:耗时业务通过 DefaultEventExecutorGroup 或独立业务线程池下沉,同时给队列上限和拒绝策略。
DefaultEventExecutorGroup queryGroup = new DefaultEventExecutorGroup(16);
DefaultEventExecutorGroup writeGroup = new DefaultEventExecutorGroup(8);
pipeline.addLast(queryGroup, new QueryHandler());
pipeline.addLast(writeGroup, new WriteCommandHandler());验证结果:压测时 EventLoop 线程不应长期停在业务方法。业务线程池队列、拒绝次数和响应错误码要能观测。
jcmd <pid> Thread.print | grep -E "nio|defaultEventExecutor|business"常见坑:
所有业务都丢到一个大线程池,慢查询把认证、心跳、写入都拖住。线程池队列无上限,短时间看起来没有拒绝,实际上延迟和内存一路上涨。业务线程回写 Channel 时没有检查连接是否仍然可用。
为了减少线程切换,把复杂业务放回 IO 线程,压测低流量没问题,高流量就雪崩。
生产建议:业务线程池按动作隔离,例如认证、查询、写入、推送分开。拒绝策略要映射为协议错误码。对已超时或已关闭的请求,业务线程要能停止无意义工作,至少不要继续写大响应。
故障注入实验:上线前把坏情况演一遍
场景说明:Netty 服务在功能测试里经常表现正常,一到生产就暴露半包、慢客户端、重连风暴、心跳误判、直接内存泄漏和 EventLoop 阻塞。上线前要把坏情况做成固定实验,而不是等事故当天第一次见。
实验矩阵:
| 故障 | 注入方式 | 观察指标 | 通过标准 |
|---|---|---|---|
| 半包 / 粘包 | 客户端分段写入长度字段和 body | 解码异常、连接关闭、业务处理数 | 半包不进业务 handler,异常包被关闭 |
| 超大帧 | 发送超过 LengthFieldBasedFrameDecoder 上限的包 | 异常日志、连接数、内存 | 触发拒绝或关闭,直接内存不尖峰 |
| 慢客户端 | 限速读取或暂停读取响应 | isWritable=false、发送队列、水位恢复 | 服务端能限速或断开,不拖垮 EventLoop |
| EventLoop 阻塞 | 在测试 handler 中 sleep 或打慢日志 | EventLoop 延迟、同线程连接 RT | 告警触发,业务逻辑不应留在 IO 线程 |
| ByteBuf 泄漏 | 测试分支故意漏 release | leak detector、直接内存 | 测试能抓到泄漏并定位 handler |
| 心跳误判 | 注入网络延迟、丢包、长 GC 暂停 | 心跳超时、重连数 | 连续失败才断开,客户端有退避 |
| TLS 迁移 | 开启 4.2 默认主机名校验 | 握手失败原因、证书 SAN | 证书和 endpoint identification 明确配置 |
| native fallback | 移除 native 依赖或换不匹配镜像 | 启动日志、transport 指标 | 回退路径可见,性能告警阈值有区分 |
常用命令:
# 测试环境注入网络延迟和丢包。
tc qdisc add dev eth0 root netem delay 200ms 50ms loss 1%
tc qdisc del dev eth0 root
# 抓包确认帧边界和异常关闭。
tcpdump -i any port 9000 -w netty-fault.pcap
# 泄漏检测和线程栈。
java -Dio.netty.leakDetection.level=advanced -jar app.jar
jcmd <pid> Thread.print生产建议:故障注入实验要写进发布评审。每次协议变更、Netty 升级、transport 切换、TLS 策略变化和背压参数调整,都至少回归半包、超大帧、慢客户端、EventLoop 阻塞、ByteBuf 泄漏和重连风暴。
连接数不是一个数字,是一组资源账
连接数上来后,文件句柄、线程、直接内存、发送队列、心跳流量、业务会话表都会增长。
ss -ant state established '( sport = :9000 )' | wc -l
lsof -p <pid> | wc -l
ulimit -n判断标准:连接数达到压测目标时,文件句柄不接近上限,直接内存不持续上涨,EventLoop 延迟不抖,心跳包不把带宽打满,业务会话表能随断连释放。
协议兼容决定长期维护成本
协议字段要支持版本、可选字段、未知字段忽略、错误码扩展。删除字段要有兼容期。灰度期间,新客户端和老服务端、老客户端和新服务端都要压测。不要在 body 里偷偷改字段语义,否则排障时只能靠猜。
连接安全和认证不能靠“内网可信”
内网也会有错误客户端、压测脚本和误连。自定义协议至少要有 magic、版本、认证或握手、最大帧限制、异常包关闭策略。需要 TLS 时,证书、主机名校验和版本迁移要单独评审。
可观测要覆盖连接、协议、线程、队列、内存
生产指标至少包括在线连接数、连接建立速率、断连原因、读写吞吐、发送队列长度、isWritable=false 次数、EventLoop 延迟、业务线程池队列、直接内存、ByteBuf 泄漏告警、异常包计数、心跳超时次数。
升级和回滚先看协议,再看代码
Netty 升级不是只改版本。要先确认依赖树、native transport、TLS 行为、内存分配器、废弃 API 和压测结果。协议字段升级要先保证老版本能忽略未知字段,再灰度客户端和服务端。回滚时也要能让新客户端继续访问老服务端,或者在网关层明确拒绝并提示升级。
排障手册
| 现象 | 优先检查 | 常见原因 | 修复方式 |
|---|---|---|---|
| 连接不上 | ss -lntp、防火墙、安全组、绑定地址 | 端口未监听、地址绑定错、网络不通 | 修正监听地址和网络策略 |
failed to create a child event loop | EventLoopGroup 创建位置、线程数、文件句柄 | 每连接创建线程组、系统资源耗尽 | 复用 EventLoopGroup,降低线程组数量 |
| 连接很多但无响应 | EventLoop 线程栈、业务线程池 | IO 线程阻塞、业务池满 | 业务下沉线程池,限流或快速失败 |
| JSON 半截报错 | 抓包、解码器配置、最大帧 | 粘包半包未处理 | 加长度字段或分隔符协议 |
| 直接内存 OOM | NMT、泄漏日志、ByteBuf 释放路径 | ByteBuf 未释放、跨线程未 retain/release | 补释放规则,压测验证 |
| 延迟越来越高 | 写缓冲水位、慢客户端、业务队列 | 下游读慢,无背压 | 水位控制、限速、断开或返回忙 |
| 心跳误判 | GC 日志、网络抖动、NAT、防火墙 | 心跳周期过短或只发不验 | 连续 N 次失败再关闭,增加退避 |
| 重连风暴 | 连接建立速率、客户端日志 | 服务恢复后同时重连 | 指数退避、随机抖动、限流 |
| RPC 重复提交 | requestId、业务幂等键、服务端日志 | 超时后重试,旧请求仍完成 | 写请求幂等,服务端状态校验 |
| 升级后 TLS 失败 | 依赖树、TLS 配置、证书主机名 | 4.2 主机名校验行为变化 | 显式配置 endpoint identification |
| native 未生效 | 启动日志、Epoll.isAvailable()、依赖树 | classifier 不匹配、镜像不兼容 | 打印 fallback 原因,修正依赖或回退 NIO |
命令速查
# 端口监听
ss -lntp | grep 9000
# 连接数
ss -ant state established '( sport = :9000 )' | wc -l
# 查看 TCP 细节
ss -tin sport = :9000
# 抓包
tcpdump -i any port 9000 -w netty-demo.pcap
# 线程栈
jcmd <pid> Thread.print
# 直接内存,需要启动时开启 Native Memory Tracking
jcmd <pid> VM.native_memory summary
# 文件句柄
lsof -p <pid> | wc -l
# Netty 依赖树
mvn -q dependency:tree -Dincludes=io.netty
# 测试环境网络故障注入
tc qdisc add dev eth0 root netem delay 200ms 50ms loss 1%
tc qdisc del dev eth0 root协议是否有 magic、version、type、requestId、length。是否限制最大帧长度、最大请求体和最大 inflight 数。是否有心跳、读超时、写超时和业务超时。
EventLoop 是否没有阻塞业务逻辑。业务线程池是否隔离且队列有上限。ByteBuf 是否有明确释放规则,跨线程是否 retain() / release()。
写缓冲水位和 isWritable() 是否接入背压。AUTO_READ 关闭后是否有恢复路径。客户端重连是否有指数退避和随机抖动。
RPC 写请求是否有幂等键。新老协议是否做过双向兼容压测。慢客户端、大消息、半包、异常包、服务端重启、客户端批量重连是否都压测过。
Netty 版本是否由 BOM 统一,运行时是否没有混入多个 Netty 主线版本。升级 4.2 时是否评估 TLS endpoint identification、分配器、废弃 EventLoopGroup API 和 native transport。Native transport 是否有可见的 fallback 日志和 NIO 回退方案。
零拷贝路径是否确认 TLS、压缩、加密和文件句柄关闭边界。故障注入矩阵是否在发布前跑过,失败标准是否能触发告警。
Netty News。Netty 4.x User Guide。Netty 4.2 Migration Guide
Netty Native transports。Netty MultiThreadIoEventLoopGroup API
Netty LengthFieldBasedFrameDecoder API。Netty IdleStateHandler API
Netty Reference Counted Objects。Netty ChannelConfig Write Buffer Water Mark。Java 21 NIO
