Netty / 网络编程基础
本文范围
Netty 不是每个业务系统都要直接写的框架。普通 HTTP 接口交给 Spring MVC 或 WebFlux,常规 RPC 交给成熟框架,消息通信交给 MQ 客户端。你需要理解和使用 Netty 的场景,通常是自定义协议、长连接、网关、推送服务、设备接入、内部通信框架、代理转发和 RPC 底层能力。
本文讲开发侧:Socket 到 NIO 的基本模型、Netty 线程模型、Pipeline 编排、ByteBuf 生命周期、粘包拆包、心跳、编解码、RPC 请求响应、背压、零拷贝边界、Native transport、连接数、超时、协议兼容、故障注入和线上排查。本文不展开操作系统内核调优、四层负载均衡部署、证书体系建设、监控平台搭建和完整 RPC 框架源码,这些归部署运维和架构治理主线。
版本上要先对齐:官方新闻在本轮校准时列出 Netty 4.2.16.Final 和 4.1.136.Final,说明 4.2.x 已经是当前 4.x 主线,4.1.x 也仍有维护版本。新项目优先按 4.2 的迁移建议写,老项目如果仍在 4.1,先升级到最新 4.1.x,确认没有依赖冲突、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、压缩、加密和文件句柄关闭边界。
- 故障注入矩阵是否在发布前跑过,失败标准是否能触发告警。
