EventLoopGroup · EventLoop · Channel · Selector · 任务队列 · ChannelPipeline · ChannelHandler · ChannelHandlerContext · 入站 / 出站 · ByteBuf · readerIndex / writerIndex · 引用计数 · 池化 · 堆外内存 · LengthFieldBasedFrameDecoder
Netty:EventLoop、Pipeline、ByteBuf。一条连接一辈子归一个线程,字节沿一串处理器流过,缓冲区用完要自己还
Netty 把 JDK NIO 里最容易写错的三件事做成了固定结构:EventLoop 是一个线程守着一个 Selector,连接一建好就终身绑在某个 EventLoop 上,所以一条连接上的事件不用加锁、天然有序;Pipeline 是每条连接一串处理器,入站从头走到尾,出站从尾走回头,拆帧、解码、业务、编码各占一格;ByteBuf 是读写两个下标的字节缓冲,默认在堆外、从池子里借,用引用计数决定什么时候还。精华是一句话:EventLoop 线程只做快的事。在它上面做一次 200 ms 的查库,同一个线程上的所有连接都陪着等 200 ms;借了 ByteBuf 不还,堆外内存一点点漏光,堆的监控却看不出来。
全景:订单服务调库存服务的一次 RPC · 第 6、8、9 站地图:J04 · Netty:EventLoop、Pipeline、ByteBuf前置:网络 IO 模型(Reactor)· TCP 与粘包
①为什么学:gRPC 底下那一层,出事时你得看得懂
在全景第 6 站,订单服务的下单线程不自己写 socket,而是把两个 HTTP/2 帧交给一个叫 grpc-default-worker-ELG-3-2 的线程;第 8 站,库存服务那边也是这样一个线程被 epoll 叫醒,拆帧、解码,第 9 站再切到业务线程扣库存。这些线程、这串处理器、装字节的缓冲区,都是 Netty。gRPC Java、Dubbo、Spring WebFlux、Lettuce 和 Redisson(Redis 客户端),传输层都是它。线上那几类怪问题:一个慢接口拖慢所有接口、堆外内存涨到被容器杀掉、偶发解析乱码,根子都在这三样东西上。
需求(假设场景,和全景一致):工作里用 gRPC 调服务,平时不直接写 Netty;但要能看懂线程名、调对线程池、排查堆外内存,偶尔要给内部协议写一个 Netty 服务端。所以⑤给一个能抄的最小服务,⑥先讲最容易踩的两个坑。
②老办法:BIO 撑不住连接数,手写 NIO 又处处是坑
网络 IO 模型那页已经推出来:连接多、大多在发呆时,该让一个线程用 epoll 守着成千上万条连接(Reactor)。JDK 从 1.4 就有 Selector,那为什么还要 Netty?把⑤那个“库存预占”服务用 JDK NIO 手写一遍就知道了。
老办法 A:一条连接一个线程(BIO)
ServerSocket ss = new ServerSocket(9090);
while (true) {
Socket s = ss.accept();
pool.submit(() -> serve(s)); // 读、算、写都在这个线程
}
- 写起来最简单,
readInt+readFully就把帧拆好了 - 线程大多数时间在等对方发数据。1 万条长连接就要 1 万个线程,每个栈 1 MB 起,切换也贵
- gRPC、HTTP/2 都是长连接 + 多路复用,连接常驻,正好撞在这个短板上
老办法 B:直接用 JDK NIO 的 Selector
while (true) {
sel.select();
Iterator<SelectionKey> it = sel.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey k = it.next(); it.remove(); // 忘了 remove 就重复处理
ByteBuffer buf = (ByteBuffer) k.attachment();
int n = ((SocketChannel) k.channel()).read(buf);
buf.flip(); // 忘了 flip,一个字节都读不到
while (buf.remaining() >= 4) { … 自己拼帧、处理半条 … }
buf.compact();
reserve(…); // 慢的业务也在这?整个 selector 停住
// 一次没写完:挂 OP_WRITE、自己排队,下次接着写
}
}
- 粘包拆包自己拼,
ByteBuffer只有一个position,读写切换靠flip/compact,一错就是乱码 - 写不完要自己注册
OP_WRITE、维护待写队列;对方读得慢时这个队列还会无限长 - 线程怎么分:几个 Selector?业务放哪个线程?别的线程想往连接里写怎么办?全靠自己设计
- 还有 JDK 自己的坑:Linux 上
select()偶尔没有事件也立刻返回(著名的 epoll 空轮询),CPU 打满。Netty 数到连续 512 次空转就重建 Selector 绕开它
不懂精华时的典型误用:把 Netty 当成“更快的 Socket”,在 channelRead 里直接查库、调别的服务、加锁等待。单测一条连接毫无问题,上线后一个慢请求就让同一个 EventLoop 上的几十条连接一起变慢。⑥ 坑 1 实测:一个 200 ms 的慢请求,把另一条连接上 0.1 ms 的快请求拖成了 191 ms。
③选谁:大多数时候你用的是“建在 Netty 上的东西”
同样是“少量线程守很多连接”,Java 里有四条路。它们不是互相替代,而是一层叠一层。
手写 JDK NIO
只有 Selector、ByteBuffer、SocketChannel,其余全自己写。
适合:学原理、极简的单一用途工具。生产协议别这么干。
Netty
线程模型、处理器链、池化缓冲、几十种现成编解码器(长度帧、HTTP/1/2、WebSocket……),Linux 上还能换 epoll 本地传输。
适合:自定义协议、网关、IM、写中间件的客户端或服务端。
Reactor Netty · Vert.x
在 Netty 外面再包一层响应式或事件总线的编程模型。Spring WebFlux、Spring Cloud Gateway 用的是前者。
适合:整个应用都愿意写成非阻塞风格。
gRPC · Dubbo · Tomcat NIO
直接给你 RPC 或 Servlet。gRPC Java 和 Dubbo 的默认传输就是 Netty;Tomcat 的 NIO 连接器是自己写的同类结构。
适合:写业务服务。出事时看到的线程和内存,仍是下面那层的。
选择规则:写业务调用,用 gRPC 或 Dubbo,不要自己拿 Netty 造 RPC;要做的东西本身就是“协议”或“网络组件”,用 Netty。无论哪种,都要懂 Netty 的三样东西,因为 gRPC 的线程名、慢调用、堆外内存问题,全是 Netty 层的现象。
本页学 Netty 4.2(4.2.19.Final,当前主线,最低 JDK 8)。和 4.1 的差别放到 ⑥ 第三圈,API 绝大部分相同。
④原理:三样东西,各管一件事
EventLoop 管“哪个线程干活”,Pipeline 管“字节经过哪些步骤”,ByteBuf 管“字节放在哪、什么时候还”。先看各自怎么工作,再看为什么非这样设计不可。
④-1 EventLoop:一个线程、一个 Selector、一个任务队列,连接终身绑定
grpc-default-worker-ELG-3-2,不是下单线程。④-2 Pipeline:每条连接一串处理器,入站往尾走,出站往头走
ChannelInboundHandler)处理“读到了、连上了、断开了”,出站的(ChannelOutboundHandler)处理“要写、要关”。位置决定谁能看到谁:prepender 必须放在 reserve 前面(更靠 head),因为 ctx.write 只往 head 方向找。④-3 ByteBuf:两个下标,引用计数,从池子借
ctx.fireChannelRead(msg)),责任就跟着交出去;自己消化了,就自己 release。SimpleChannelInboundHandler 在 channelRead0 返回后替你 release;走到 tail 都没人处理的消息,tail 也会 release。出站时把 ByteBuf 交给 write,写完由 Netty release。④-4 为什么这样设计
选中:一条连接终身一个线程
- 解码器要在两次读之间记住“半条消息”。只有一个线程碰它,不用加锁
- 同一条连接上的事件严格按到达顺序处理,响应不会乱序
- 线程数固定(2 × 核),不随连接数涨;数据一直在同一个核的缓存里
- 代价:这个线程上任何一次慢操作,同一线程上的所有连接一起等。所以“EventLoop 只做快的事”是这个设计的使用条件
放弃:每个 IO 事件扔进一个线程池
- 连接 A 的第 1 次读和第 2 次读可能落在两个线程上,同时改同一份半包缓存,必须加锁
- 第 2 个帧可能比第 1 个先处理完,响应顺序乱掉,协议得自己再排序
- 每个事件都要跨线程交接一次,锁竞争和上下文切换吃掉大部分收益
- 好处只有一个:慢处理器不会挡住别人。Netty 让你按需把“慢的那一格”单独挪走(⑥ 坑 1),而不是让所有处理器都付这个代价
决定胜负的约束:协议解码是有状态的(半包),而且要有序。有状态加有序,最便宜的做法就是同一时刻只有一个线程碰它,于是一条连接归一个线程。
选中:池化的堆外缓冲 + 引用计数
- 内核读写 socket 要一个不会被 GC 挪动的地址。堆外内存直接给它,省掉一次拷贝
- 堆外内存申请、释放都慢,所以从池子里借、用完还,热路径上几乎不分配
- 还的时机由 release 精确决定,不等 GC。高吞吐下 GC 什么时候来根本没法指望
- 代价:内存管理回到了“手动”时代,漏一次 release 就漏一块(⑥ 坑 2)
放弃:每次 new byte[],交给 GC
- JDK 往 socket 写堆内数组时,要先把它拷进一块临时的堆外缓冲(GC 随时可能挪动堆对象)
- 每秒几十万条消息,就是每秒几十万个短命数组,年轻代 GC 更频繁
- 好处是不会漏。Netty 也保留了这条路:
-Dio.netty.allocator.type=unpooled、或Unpooled.buffer()分配堆内缓冲
④-5 最少的 API
EventLoopGroup boss = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory());
EventLoopGroup workers = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()); // 默认 2 × 核
new ServerBootstrap()
.group(boss, workers)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() { // 每条新连接调一次
protected void initChannel(SocketChannel ch) {
ch.pipeline()
.addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4)) // 入站:拆帧
.addLast(new LengthFieldPrepender(4)) // 出站:补长度
.addLast(new ReserveHandler()); // 入站:业务
}
})
.bind(9090).sync();
// 处理器:继承 SimpleChannelInboundHandler<ByteBuf>,实现 channelRead0,读完自动 release
// 写响应:ctx.writeAndFlush(ctx.alloc().buffer().writeByte(1).writeInt(left));
4.1 的写法是 new NioEventLoopGroup(),在 4.2 里还能用,只是标了 @Deprecated;网上大多数例子还是这种写法,看到别慌。
⑤跑起来:一个能用的库存预占服务
协议照着全景里 gRPC 的思路简化:每帧 4 字节长度 + 内容;请求是 skuId(long) + count(int),响应是 ok(byte) + 剩余库存(int)。客户端故意把 3 个请求塞进一次 write,看服务端能不能切开;顺便把每一行是哪个线程打的印出来。
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec-base</artifactId> <!-- 带上 transport、buffer、common;长度帧编解码器在这里 -->
<version>4.2.19.Final</version>
</dependency>
// 服务端 Pipeline 见 ④-5:frame → prepender → reserve。worker 线程 2 个。
static class ReserveHandler extends SimpleChannelInboundHandler<ByteBuf> {
public void channelActive(ChannelHandlerContext ctx) {
log("reserve", "新连接 " + ctx.channel().remoteAddress() + " 绑定到这个线程");
}
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf frame) { // frame 已去掉长度头
log("reserve", "readerIndex=" + frame.readerIndex() + " writerIndex=" + frame.writerIndex());
long sku = frame.readLong();
int count = frame.readInt();
boolean[] hit = {false}; // 多条连接可能在不同 worker 上,扣减要原子
int left = STOCK.compute(sku, (k, v) -> {
int cur = v == null ? 0 : v;
if (cur >= count) { hit[0] = true; return cur - count; }
return cur;
});
log("reserve", "预占 sku=" + sku + " x" + count + " → " + (hit[0] ? "成功" : "库存不够"));
ctx.writeAndFlush(ctx.alloc().buffer(5).writeByte(hit[0] ? 1 : 0).writeInt(left));
} // 返回后父类 release(frame)
}
// 客户端(订单服务):库存 5 件,连发 3 个“预占 2 件”,塞进同一个 ByteBuf 一次写出
ByteBuf out = ch.alloc().buffer();
for (int count : new int[]{2, 2, 2}) out.writeInt(12).writeLong(88L).writeInt(count);
ch.writeAndFlush(out); // main 线程调用 → 变成任务交给这条连接的 EventLoop
cd study-labs/java
mvn -q -pl network/netty-eventloop-pipeline-bytebuf compile exec:java \
-Dexec.mainClass=labs.network.netty_eventloop_pipeline_bytebuf.InventoryNetty
实测 本机 2 核 ARM,JDK 17.0.20,Netty 4.2.19,原样输出(第一列是线程名):
main main 库存服务监听 9090,worker 线程数 2 multiThreadIoEventLoopGroup-3-1 reserve 新连接 /127.0.0.1:47758 绑定到这个线程 main main 一次写出 48 字节(3 个请求),AdaptiveByteBuf direct=true multiThreadIoEventLoopGroup-3-1 reserve readerIndex=0 writerIndex=12 multiThreadIoEventLoopGroup-3-1 reserve 预占 sku=88 x2 → 成功 multiThreadIoEventLoopGroup-3-1 reserve readerIndex=0 writerIndex=12 multiThreadIoEventLoopGroup-3-1 reserve 预占 sku=88 x2 → 成功 multiThreadIoEventLoopGroup-3-1 reserve readerIndex=0 writerIndex=12 multiThreadIoEventLoopGroup-4-1 client 收到响应 ok=true 剩余=3 multiThreadIoEventLoopGroup-4-1 client 收到响应 ok=true 剩余=1 multiThreadIoEventLoopGroup-3-1 reserve 预占 sku=88 x2 → 库存不够 multiThreadIoEventLoopGroup-4-1 client 收到响应 ok=false 剩余=1
看三件事。① 线程:这条连接的所有事件都在 -3-1 上(④-1 终身绑定),客户端的回调全在自己的 -4-1 上,main 线程只负责交任务。② 拆帧:48 字节一次到达,LengthFieldBasedFrameDecoder 切成 3 帧,每帧去掉长度头后正好 12 字节(④-2)。③ ByteBuf:默认分配器在 4.2 是 AdaptiveByteBufAllocator,给的是堆外缓冲(④-3)。
⑥细节:线程别堵、内存要还、帧要切
每个坑旁边的徽标指回 ④ 里推出它的那条原理。标“实测”的是本机跑过的,代码在 study-labs 同一个目录里。
注意事项
- 现象
- 监控里某个接口慢(查库 200 ms),奇怪的是几个本该 1 ms 的接口也时不时 200 ms,而且只有部分连接受影响。
BlockingEventLoop:1 个 worker,A 连接发慢请求、10 ms 后 B 连接发快请求,B 的往返中位数 191 ms。 - 根源
- 处理器默认跑在 EventLoop 线程上。它在 sleep、等锁、查库时,这个线程上所有连接的读写都停着。
- 做法
- 慢的处理器单独放到业务线程上:
pipeline.addLast(bizGroup, "biz", handler)(bizGroup = new DefaultEventExecutorGroup(16)),或在channelRead0里pool.submit(...),做完在业务线程直接ctx.writeAndFlush(Netty 会转交回 EventLoop)。同一实验改成offload:B 的中位数 1 ms。gRPC 默认就把服务方法放到另一个线程池里跑,这就是全景第 9 站。
- 现象
LeakDemo leak:处理器继承ChannelInboundHandlerAdapter,读完不 release。每收 2000 条 1 KB 消息,分配器的堆外用量涨 2 MB(2,048 → 10,112 KB);GC 之后日志里出现LEAK: ByteBuf.release() was not called before it's garbage-collected,带最后几次访问的调用栈。- 根源
- 池化的堆外缓冲靠 refCnt 归零才还回池子。GC 只回收外面的小对象,池里那块一直“借出中”;它也不在堆里,堆的监控看不见。
- 做法
- 入站处理器继承
SimpleChannelInboundHandler(fixed模式:一直是 128 KB),或在finally里ReferenceCountUtil.release(msg);要往后传就ctx.fireChannelRead(msg),两者只能选一个。测试环境开-Dio.netty.leakDetection.level=paranoid;生产默认SIMPLE,每 128 个缓冲抽 1 个跟踪,漏得多迟早会报。容器内存为什么只看进程总数,见 JVM 在容器里。
- 现象
- 本地一切正常,压测时业务处理器偶尔拿到半条或两条拼在一起的数据;或者对端发错一个长度字段,服务端尝试攒满 2 GB。
- 根源
- EventLoop 每次 read 拿到的只是 TCP 字节流的一段(TCP 坑 1)。Pipeline 第一格不是拆帧器,后面每一格都在处理“随机切开的字节”。
- 做法
- 第一格放
LengthFieldBasedFrameDecoder(或行分隔、HTTP/2 等现成解码器),maxFrameLength按协议上限设小,超了它会抛TooLongFrameException并丢弃这一帧。⑤ 里设的是 1024。
- 现象
- 在业务处理器里
ctx.write(pojo),客户端一直等到超时;服务端没有任何报错。 - 根源
ctx.write只经过当前位置到 head 之间的出站处理器。编码器排在业务处理器后面(更靠 tail),就根本没经过它;对象一路到 head,被拒成unsupported message type。这个失败记在返回的ChannelFuture上,没人看就没人知道。- 做法
- 编码器放在业务处理器前面;不确定时用
ctx.channel().writeAndFlush(从 tail 走全程)。开发期给写操作挂addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE),让失败进入exceptionCaught。
- 现象
- 推送、下载、网关这类“写多读少”的服务,某个慢客户端连上来后内存直线上涨。
- 根源
write只是把 ByteBuf 放进这条连接的出站缓冲(ChannelOutboundBuffer),内核发送缓冲满了就一直堆在 Netty 这边,没有上限。- 做法
- 写之前看
channel.isWritable();出站缓冲超过高水位(默认 64 KB)它变成 false,降到低水位(32 KB)以下变回 true 并触发channelWritabilityChanged,在那里继续写。水位线用WRITE_BUFFER_WATER_MARK调。
- 现象
- 中间的 NAT、负载均衡器悄悄清掉了空闲连接,两端的 EventLoop 都没收到任何事件。
- 根源
- 不是 Netty 的机制问题:连接只是两端内核的账,没有包就没有事件,EventLoop 也就不会被叫醒。
- 做法
- Pipeline 里加
IdleStateHandler(读空闲, 写空闲, 全空闲)(在netty-handler里),在userEventTriggered收到空闲事件时发心跳或关掉连接。gRPC 对应的是 keepalive 参数,全景第 3 站讲过默认是关的。
实验台:慢请求到底堵住了谁
模型:C 条连接按轮询绑到 E 个 EventLoop;前 k 条连接在 0 ms 各发一个慢请求(处理器阻塞 S ms),其余每条在 10 ms 发一个 0.1 ms 的快请求,另加 0.5 ms 往返。业务处理器放在哪,有三种选法。数字是模拟,第一个预设对上了坑 1 的实测(191 ms / 1 ms)。
先猜:4 个 EventLoop、100 条连接,只有 1 条连接发了 200 ms 的慢请求,业务直接跑在 EventLoop 上。另外 99 个快请求里,有多少个被拖到 50 ms 以上?
先选一个答案再运行,也可以直接改参数。
使用规范
maxFrameLength粘包拆包,以及一个错长度字段让服务端攒几百 MB(坑 3)
SimpleChannelInboundHandler;要往后传就 fireChannelRead,不传就 release,二选一堆外内存泄漏(坑 2),以及 release 两次的 IllegalReferenceCountException
ctx.writeAndFlush一个慢请求拖慢一个线程上的所有连接(坑 1)
sync() / await()自己等自己:Netty 直接抛 BlockingOperationException,否则就是死锁
new 一个;要共享同一个实例,必须无状态并标 @Sharable多条连接串用同一份半包状态;没标就复用会报 is not a @Sharable handler
new MultiThreadIoEventLoopGroup(0, new DefaultThreadFactory("inv-io"), …)线程 dump 里一屏 multiThreadIoEventLoopGroup-3-1,分不清是哪个组件的
-Dio.netty.leakDetection.level=paranoid泄漏在上线前就被 LEAK 日志抓到,而不是靠容器 OOM 发现
shutdownGracefully()(默认静默 2 s、最多等 15 s)在途的请求和还没写出去的响应被直接丢掉
优化和深处
线程数为什么默认 2 × 核,连接为什么会“偏科”
- EventLoop 本该几乎不阻塞,理论上核数个就够;默认翻倍(
MultithreadEventLoopGroup,可用-Dio.netty.eventLoopThreads改)是给偶发的短停顿留余量。 - 新连接靠
group.next()分配:线程数是 2 的幂时用位与(idx & (n-1)),否则取模,都是注册时的轮询,不看负载。 - 所以长连接少、流量不均时(比如只有 3 条 gRPC 连接,其中一条特别忙),可能出现一个 EventLoop 打满、其他空闲,而且之后不会自动迁移。解决办法在上层:多建几条连接,或让客户端做连接级负载均衡。
4.1 → 4.2 改了什么
| 项 | 4.1 | 4.2 |
|---|---|---|
| 建线程组 | NioEventLoopGroup | MultiThreadIoEventLoopGroup + NioIoHandler;旧类还在,标 @Deprecated |
| 默认分配器 | pooled(PooledByteBufAllocator) | adaptive(AdaptiveByteBufAllocator);-Dio.netty.allocator.type=pooled 换回 |
| 编解码模块 | 一个 netty-codec | 拆成 netty-codec-base、-compression、-protobuf 等;netty-codec 仍在,依赖这些子模块 |
| io_uring | 孵化项目 io.netty.incubator | 正式模块 netty-transport-native-io_uring |
依据:4.2.19 源码 ByteBufUtil(默认 "adaptive")、NioEventLoopGroup(@Deprecated)、Maven Central 上 4.2.19 的 pom。
epoll 本地传输:gRPC 默认用的那个
- 把
NioIoHandler换成EpollIoHandler、channel 换成EpollSocketChannel,就绕过 JDK 的 Selector,直接用 JNI 调 epoll,默认边缘触发,每次唤醒产生的垃圾更少,还能设TCP_QUICKACK、SO_REUSEPORT这类 JDK 不暴露的选项。 - 全景里的线程名
grpc-default-worker-ELG就是 grpc-netty-shaded 自带的 epoll 版;拿不到本地库时退回 NIO,线程名会变成grpc-nio-worker-ELG。 - 线程模型、Pipeline、ByteBuf 完全不变,这一页讲的都照样成立。
少拷贝:slice、CompositeByteBuf、FileRegion
LengthFieldBasedFrameDecoder切出来的帧默认是从累积缓冲里retainedSlice出来的:共享底层内存,各有自己的下标和 refCnt 归属,不拷贝。CompositeByteBuf把“头 + 体”两个缓冲拼成逻辑上的一个,不用先 new 一个大数组再拷进去。- 发文件用
DefaultFileRegion,底层是FileChannel.transferTo(Linux 上是 sendfile),数据不经过用户态。
这一页没展开的
- EventLoop 的 run 循环源码:IO 与任务的时间配比、定时任务队列、
wakeup的处理 - PooledByteBufAllocator 的 jemalloc 式结构(Arena、Chunk、Page、线程缓存)和 4.2 的 Adaptive 分配器差在哪
- HTTP/2 在 Netty 里的样子:
Http2FrameCodec、流到子 Channel 的映射(gRPC 第 5 站) - gRPC 服务端的线程三层:boss、worker EventLoop、业务 executor,各自该怎么配(J04 第 9 站)
- 背压:
autoRead关掉读、配合水位线做端到端限流 - 用 Netty 写一个最小 RPC:序列化、请求 ID、超时(J04 知识点 5 的动手版)
↺回到 ⑤:带着 ⑥ 改一遍
三道改造都在 ⑤ 的 InventoryNetty 上做,study-labs 里同目录的 BlockingEventLoop、LeakDemo 就是前两道的参考答案。
channelRead0 里 Thread.sleep(200)worker 改成 1 个线程,再开第二个客户端同时发。先猜第二个客户端多慢,再用 addLast(bizGroup, "reserve", …) 修好,比较前后(坑 1)。
ReserveHandler 改成继承 ChannelInboundHandlerAdapter开 -Dio.netty.leakDetection.level=paranoid 发 1 万个请求,在日志里找 LEAK 和它指向的那一行;再补上 finally 里的 release(坑 2)。
先猜服务端打印什么、连接会不会断;再把 maxFrameLength 改成 Integer.MAX_VALUE 想想会发生什么(坑 3)。
1. 用一句话说出 Netty 这三样东西的精华。
一条连接终身归一个 EventLoop 线程,所以不用锁、天然有序,但这个线程只能做快的事;字节沿 Pipeline 按“入站往尾、出站往头”流过一串处理器;ByteBuf 是池化的堆外缓冲,靠引用计数精确归还,谁最后用谁 release。
2. 已经有 JDK 的 Selector,为什么还需要 Netty?
Selector 只解决“一个线程等很多连接”。拆帧、读写指针、写不完的排队、线程怎么分工、跨线程写、epoll 空轮询,全要自己写,而且每一项都容易错(②)。Netty 把它们做成了固定结构:EventLoop、Pipeline、ByteBuf,再配上现成的编解码器。
3. “一个慢接口把别的接口也拖慢”,根源是哪条原理?为什么只有一部分连接受影响?
④-1:连接终身绑定一个 EventLoop,处理器默认就跑在这个线程上。慢处理器占着线程,同一个 EventLoop 上的连接都等;别的 EventLoop 上的连接不受影响,所以是“一部分”。4 个 EventLoop 时大约是 1/4(实验台第二个预设:24 个)。
4. 预测:把 ⑤ 里的 addLast 顺序改成 frame → reserve → prepender,客户端会看到什么?
reserve 用的是 ctx.writeAndFlush,只往 head 方向找出站处理器,而 prepender 现在在它后面,经过不到(④-2)。响应 5 字节 01 00 00 00 03 不带长度头直接发出。客户端的拆帧器把前 4 字节 01 00 00 00 当成长度,等于 16777216,超过 1024 的上限,抛 TooLongFrameException,客户端一直等不到响应。把 reserve 里改成 ctx.channel().writeAndFlush 就又好了,因为它从 tail 走全程。
5. 预测:⑤ 的 worker 从 2 个线程改成 1 个,输出会变吗?改成 8 个、同时开 4 个客户端呢?
1 个时不变:⑤ 只有一条连接,本来就只用了 -3-1 一个线程。8 个线程、4 条连接时,按注册轮询,4 条连接分别落在 -3-1 到 -3-4 上,每条连接自己的日志仍然只出自一个线程(④-1)。扣库存因此会真的并发,这也是 ⑤ 里用 compute 做原子扣减的原因。
6. 预测:在 channelRead0 里写 ctx.writeAndFlush(resp).sync(),想等响应真的写出去再继续,会怎样?
当前线程就是 EventLoop,而“写出去”这件事要由同一个线程在后面完成,自己等自己。Netty 检查到在 EventLoop 里阻塞等待,直接抛 BlockingOperationException(DefaultPromise.checkDeadLock)。要知道写完没有,用 addListener。
7. 放回全景:第 6、8、9 站前后各发生了什么?这一层出问题,整条链路会怎样?
第 6 站前是第 5 站:gRPC 把请求装成 HEADERS 和 DATA 两个 HTTP/2 帧;第 6 站下单线程把它们交给连接所属的 EventLoop,经 Pipeline 编码进 ByteBuf,write + flush 给内核(第 7 站 TCP)。第 8 站库存服务的 EventLoop 被 epoll 叫醒,读进 ByteBuf,拆出 HTTP/2 帧和 gRPC 消息;第 9 站交给业务线程池扣库存,响应再由 EventLoop 写回。这一层出事:库存服务若把扣库存直接放在 EventLoop 上做,一个等行锁的请求就让同一线程上所有调用卡住,订单服务那边是成片的 300 ms deadline 超时;自己写的处理器漏 release,库存服务堆外内存慢慢涨到被容器杀掉,订单服务看到的是连接突然断开、重连、一批调用失败。