前三篇把 Netty 的骨架拆完了:开篇讲 Reactor 与 EventLoop、第二篇讲 ByteBuf 与堆外内存、第三篇讲 Pipeline 与编解码。骨架能跑起来,不代表能扛住——真正把 Netty 推上生产时,翻车的往往不是功能,而是写不出去的数据往哪堆、EventLoop 被谁阻塞了、堆外内存悄悄涨到 OOM。
这一篇只讲一件事:怎么让 Netty 在压力下不崩。性能上限不在「能写多快」,而在「写不动时会不会把自己撑死」——背压与水位线是保命阀,参数与百万连接调优是把这条阀门用到极致。
TL;DR
- 写缓冲是背压的根源:
write()只是把数据塞进ChannelOutboundBuffer(一条堆外链表),真正发出去要靠flush()+ 内核 socket 可写。下游/对端一慢,写不出去的数据就在这里无限堆积——这是 Direct OOM 最常见的入口。 - 高低水位线是 Netty 的背压开关:
WRITE_BUFFER_WATER_MARK(默认低 32KB / 高 64KB)。堆积越过高水位,Channel.isWritable()变false;回落到低水位才变回true。它只是个信号,Netty 不会替你停写——你得自己听。 - 两个动作把背压落地:写前判断
isWritable(),或在channelWritabilityChanged()回调里setAutoRead(false)/true——写不动就暂停从上游读,写得动了再恢复。这就是把 TCP 的背压反压到应用层。 - 背压有三种策略:暂停读上游(最通用)、丢弃低优先级消息(实时性优先,如竞价可丢旧 bid)、反馈上游限速(呼应限流)。选哪种取决于”数据能不能丢”。
- EventLoop 绝不能阻塞(承接开篇):一个 EventLoop 一个线程复用成千上万个 Channel,任何阻塞 IO / 慢计算 /
sync()都会拖垮它上面的所有连接。阻塞活儿交给业务线程池(DefaultEventExecutorGroup或自定义Executor)。 - 关键参数分两类:
ServerBootstrap.option(作用于 accept,SO_BACKLOG/SO_REUSEADDR)和.childOption(作用于每条连接,TCP_NODELAY/SO_RCVBUF/SO_SNDBUF/ALLOCATOR/RCVBUF_ALLOCATOR)。 TCP_NODELAY几乎总要开:关掉 Nagle 算法,避免小包被攒着延迟发送——低延迟服务(竞价)必须开。- 池化 + 堆外是默认:
PooledByteBufAllocator.DEFAULT复用内存、降 GC;AdaptiveRecvByteBufAllocator按实际读入量动态调整接收缓冲,既不浪费也不频繁扩容。 - 百万级连接是资源约束题:fd(
ulimit -n)、堆外内存(-XX:MaxDirectMemorySize)、线程(EventLoop ≈ 2×CPU)、GC 四座大山,一座塌就全塌。(社区常说的 C10M 是千万连接量级,工程上先把百万级约束吃透。) - Linux 上用 Epoll native transport:
EpollEventLoopGroup比 JDK NIO 的select/poll更省 GC、支持SO_REUSEPORT/边缘触发,百万连接场景优势明显。 - 监控盯五个指标:连接数、EventLoop 任务队列延迟、写缓冲
outboundBuffer堆积、GC 停顿、堆外 Direct 内存——写缓冲和堆外内存是背压事故的最早信号。 - AdTech 实践:竞价 Server 面对慢下游/慢客户端(CTV 弱网),若不设水位线背压,写缓冲会无限堆积、堆外内存暴涨到 Direct OOM 进程被杀——水位线 +
isWritable背压 + 堆外告警是必配。
Table of contents
Open Table of contents
1. 写缓冲与背压的根源:数据写不出去往哪堆
要理解背压,先得看清 write() 到底做了什么。很多人以为 ctx.write(msg) 就是「把数据发到网络上」——其实不然。
在 Netty 里,一次出站写要过两关:
write():把消息经过 Pipeline 的出站编码后,塞进这条 Channel 的ChannelOutboundBuffer——一个挂在 Channel 上的待发送队列(内部是一条单向链表,节点持有编码后的ByteBuf,多为堆外内存)。此刻数据还在你的进程里。flush():把ChannelOutboundBuffer里的数据真正写进内核的 socket 发送缓冲区(SO_SNDBUF),由内核负责发到对端。
关键在于第 2 步可能写不动。内核 socket 发送缓冲是有限的,当它被填满(对端 TCP 接收窗口满、对端处理慢、网络拥塞),write 系统调用会返回”暂时写不了”。这时 Netty 只能把没写出去的数据继续留在 ChannelOutboundBuffer 里,等 socket 重新可写(OP_WRITE 事件就绪)再续写。
于是问题来了:如果你写入的速度持续快于对端消费的速度,ChannelOutboundBuffer 就会不断增长。它没有默认上限——链表可以一直加节点,每个节点持有的 ByteBuf 大多是堆外 Direct 内存。堆外内存不受 JVM 堆大小约束、不被普通 GC 及时回收,于是:
- 堆外内存一路上涨,普通监控(堆内存)还一片绿;
- 涨到
-XX:MaxDirectMemorySize(或物理内存耗尽)时,抛OutOfMemoryError: Direct buffer memory,或者进程直接被 OOM Killer 杀掉。
这就是背压(backpressure)要解决的问题:生产者(你的应用写)快于消费者(对端收)时,得有一条反馈通道让生产者慢下来,而不是把差额一股脑堆进内存。TCP 本身有背压(滑动窗口),但 Netty 的
write()是异步的、不阻塞——TCP 的”写不动”默认不会传导到你的应用代码。把 TCP 的背压接回应用层,就是水位线 +isWritable要做的事。
顺带说清一个高频误区:write() 返回的 ChannelFuture 完成,只代表数据进了 ChannelOutboundBuffer(或写进了内核),不代表对端收到了。想确认”发出去了”,看的是 flush 后 future 的完成;想确认”对端收到并处理了”,那是应用层 ACK 的事,TCP 层给不了。
2. 高低水位线与 isWritable():Netty 的背压开关
Netty 不会替你决定”堆到多少就该停”,但它给了你一个信号:写缓冲水位线(write buffer water mark)。
每条 Channel 有一对水位线,通过 WriteBufferWaterMark 配置,默认 低水位 32KB、高水位 64KB:
- 当
ChannelOutboundBuffer的待发送字节数越过高水位(默认 64KB),Channel.isWritable()变为false; - 当它回落到低水位以下(默认 32KB),
isWritable()变回true。
用两条水位线而不是一条阈值,是为了避免抖动(hysteresis,滞回):如果只有一条线,缓冲量在阈值附近反复横跳会导致可写状态疯狂翻转。高低水位拉开一段区间,越过高位才停、跌破低位才恢复,状态切换就平稳了。
写缓冲越过高水位 → isWritable() 转 false → 暂停读上游;回落到低水位 → 转 true → 恢复读。两条水位线拉开区间避免状态抖动,这就是 Netty 把 TCP 背压反压到应用层的开关。
2.1 配置水位线
ServerBootstrap b = new ServerBootstrap();
b.childOption(ChannelOption.WRITE_BUFFER_WATER_MARK,
new WriteBufferWaterMark(32 * 1024, 64 * 1024)); // 低 32KB / 高 64KB
具体值要按业务定:单条消息大、突发多的场景可以调高(如 64KB/256KB)给点缓冲空间;对内存极敏感、连接数巨大的场景要调低,因为水位线是”每条连接”的——高水位 64KB × 100 万连接 = 最坏 64GB 堆外内存的潜在敞口。
2.2 关键:isWritable() 只是信号,Netty 不替你停写
这是最容易踩的坑:即使 isWritable() 已经是 false,你调用 write() 依然会成功地把数据塞进 ChannelOutboundBuffer。水位线只是把状态告诉你,不会自动拦截写入。如果你无视它继续猛写,缓冲照样无限堆积、照样 OOM。
所以背压要靠你主动听信号,有两种落地方式。
方式一:写前判断(适合应用自己产数据往外推)
if (ctx.channel().isWritable()) {
ctx.writeAndFlush(msg);
} else {
// 写不动了:丢弃 / 排队 / 反馈上游限速(见 §3),别硬写
dropOrQueue(msg);
}
方式二:监听可写状态变化,联动 autoRead(适合转发/代理,数据从上游读进来再写出去)
public class BackpressureHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelWritabilityChanged(ChannelHandlerContext ctx) {
Channel ch = ctx.channel();
// 写缓冲状态一变,就联动"要不要继续从上游读"
ch.config().setAutoRead(ch.isWritable());
ctx.fireChannelWritabilityChanged();
}
}
setAutoRead(false) 会让 Netty 停止向该 Channel 的 selector 注册 OP_READ——也就是不再从这条连接的内核接收缓冲往上读数据。对代理/网关这类”从 A 读、往 B 写”的场景尤其关键:B(下游)写不动时,就该停止从 A(上游)读,让 A 的内核接收缓冲填满,进而 A 的 TCP 窗口收窄、A 的上游也慢下来——背压就这样一跳一跳沿着链路往回传导,最终压到真正的数据源头。这正是”用 TCP 流控替你做限速”的精髓。
背压的因果链:下游慢 → B 的 socket 写不动 → ChannelOutboundBuffer 涨过高水位 → isWritable()=false → setAutoRead(false) 停读上游 A → A 的接收缓冲满 → A 的 TCP 窗口收窄 → A 的上游被迫减速。 一环扣一环,把压力反向传播回源头,而不是自己扛着无限堆积。
3. 背压策略:写不动时到底怎么办
isWritable()=false 告诉你”写不动了”,但具体怎么应对取决于你的数据能不能丢、能不能等。三种典型策略:
3.1 暂停读上游(最通用,数据不能丢)
就是 §2.2 的 setAutoRead(false)。适用于数据不能丢、且上游可以被反压的场景(文件传输、消息转发、代理)。代价是延迟会随背压增大,但一条数据都不会少。这是首选,因为它借的是 TCP 自身的流控,几乎零成本。
3.2 丢弃低优先级消息(实时性优先,数据可丢)
有些数据过时即无用,堆着发出去反而有害。这时正确的做法是主动丢,而不是背压等待:
if (!ctx.channel().isWritable()) {
if (msg.isDroppable()) { // 低优先级 / 会过期的数据
ReferenceCountUtil.release(msg); // 记得释放,否则引用计数泄漏
metrics.incDropped();
return;
}
// 高优先级数据仍走排队 / 背压
}
竞价场景就是典型:一个 bid response 如果因为写缓冲堆积延迟了几十毫秒才发出,对端(ADX)的 tmax 早就超时了,这条响应发出去也是废的——与其堆着占内存,不如直接丢、记一个 no-bid 指标。实时系统里,“丢掉过期数据”往往比”保证不丢”更正确。
3.3 反馈上游限速(主动降速)
当上游是你能控制的(比如内部服务间调用),可以把”写不动”这个信号显式反馈给上游,让它降低发送速率——这就接上了限流:把 Netty 的 isWritable 状态作为一个动态信号,喂给上游的令牌桶/漏桶,写缓冲吃紧就调低速率、缓解就调回。相比 §3.1 的隐式 TCP 背压,这是一条显式的应用层背压通道,能做更精细的优先级和公平性控制。
三选一的判据很简单:数据不能丢 → 暂停读(§3.1);数据会过期、丢了更好 → 丢弃(§3.2);上游可控、要精细控速 → 显式反馈限速(§3.3)。 竞价 Server 常常是”响应可丢(§3.2)+ 对慢下游连接暂停读(§3.1)“的组合。
4. EventLoop 绝不能阻塞:线程模型与业务线程池
背压解决了「数据往哪堆」,还有一个能瞬间放大一切问题的点:在 EventLoop 线程里阻塞。开篇讲过线程模型,这里从性能视角再钉一次。
回顾线程模型:Netty 用 boss EventLoopGroup 处理 accept、worker EventLoopGroup 处理已建立连接的读写。关键在于——一个 EventLoop = 一个线程 + 一个 Selector + 一个任务队列,它复用(M:N)成千上万个 Channel。
boss 只管 accept,worker 用少量线程 M:N 复用海量连接,阻塞/重计算必须外包给业务线程池。一个 worker EventLoop 被阻塞,它承载的所有 Channel 一起卡死。
4.1 为什么一次阻塞就是灾难
假设一个 worker EventLoop 承载了 5000 条连接。某个 handler 在 channelRead 里做了一次同步 DB 查询(阻塞 200ms):
- 这 200ms 里,该 EventLoop 线程被独占,它负责的另外 4999 条连接的所有读写事件全部得不到处理;
- 这些连接的
ChannelOutboundBuffer只进不出(没人 flush)、接收缓冲只积不读; - 表现出来就是大面积、周期性的延迟毛刺——而且很难定位,因为”慢”的连接和”阻塞”的连接根本不是同一批。
同样致命的还有:在 EventLoop 里调用 future.sync() / future.get()(等待自己发出的异步操作,可能死锁)、Thread.sleep、复杂的 JSON 序列化 / 加解密 / 压缩等 CPU 密集计算。
4.2 把阻塞/重活外包给业务线程池
正确做法是把阻塞或重计算的 handler 绑定到独立的业务线程池,让它在 EventLoop 之外执行:
// 独立的业务线程池(EventExecutorGroup),与 IO 线程解耦
EventExecutorGroup bizGroup = new DefaultEventExecutorGroup(64);
ChannelPipeline p = ch.pipeline();
p.addLast(new HttpServerCodec()); // 编解码:轻,留在 EventLoop
p.addLast(bizGroup, new BizLogicHandler()); // 传入 group:本 handler 在业务线程池跑
给 addLast 传一个 EventExecutorGroup,Netty 就会把这个 handler 的执行切换到该线程池,IO 线程(EventLoop)只负责把事件投递过去,不被阻塞。也可以在 handler 内部手动 ctx.executor() / 自定义线程池提交任务,效果类似。
要点:
- 编解码这类轻量、纯计算且快的 handler 留在 EventLoop(切线程本身有开销和跨线程可见性成本);
- 只有真正阻塞或耗时的 handler 才外包给业务线程池;
- worker EventLoop 数量默认 2×CPU 核心数(
Runtime.getRuntime().availableProcessors() * 2),一般不用改——EventLoop 只做非阻塞 IO,多了反而增加上下文切换。真正需要堆线程的是业务线程池。
EventLoop 是流水线的传送带,业务线程池才是干重活的工位。 让传送带停下来去拧螺丝,整条线都得停。
5. 关键参数调优:SO_BACKLOG、TCP_NODELAY 与缓冲区
Netty 的参数分两层,别配错地方:
ServerBootstrap.option(...):作用于监听 socket(accept 阶段),如SO_BACKLOG、SO_REUSEADDR。ServerBootstrap.childOption(...):作用于每一条被 accept 的连接(child channel),如TCP_NODELAY、SO_RCVBUF、SO_SNDBUF、ALLOCATOR、RCVBUF_ALLOCATOR、WRITE_BUFFER_WATER_MARK。
一份典型的高性能服务端配置:
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class) // 或 EpollServerSocketChannel(§7)
// ---- 监听 socket 级 ----
.option(ChannelOption.SO_BACKLOG, 1024) // accept 队列长度
.option(ChannelOption.SO_REUSEADDR, true) // 允许 TIME_WAIT 状态端口快速重绑
// ---- 每条连接级 ----
.childOption(ChannelOption.TCP_NODELAY, true) // 关 Nagle,低延迟必开
.childOption(ChannelOption.SO_KEEPALIVE, true) // TCP 保活,探测死连接
.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) // 池化堆外
.childOption(ChannelOption.RCVBUF_ALLOCATOR, new AdaptiveRecvByteBufAllocator())
.childOption(ChannelOption.WRITE_BUFFER_WATER_MARK,
new WriteBufferWaterMark(32 * 1024, 64 * 1024)); // 背压水位线(§2)
逐个说清它们在调什么。
5.1 SO_BACKLOG:accept 队列
SO_BACKLOG 是内核为已完成三次握手、等待应用 accept() 的连接准备的队列长度。突发大量新连接(如竞价流量尖峰、服务重启后的重连风暴)时,boss 来不及 accept,新连接就排在这个队列里;队列满了,新连接会被拒绝(客户端收到 connection refused 或超时重试)。高并发建连场景应调大(1024 甚至更高),并注意它受内核 net.core.somaxconn 上限约束——两者要一起调。
5.2 SO_REUSEADDR:端口快速重绑
允许服务重启时立即重新绑定处于 TIME_WAIT 的端口,不用等 2×MSL。生产服务几乎都该开,否则重启时常报 “Address already in use”。(另有 SO_REUSEPORT,允许多个 socket 绑同一端口做内核级负载均衡,见 §7 的 Epoll。)
5.3 TCP_NODELAY:关掉 Nagle 算法
Nagle 算法为了减少小包数量,会把小数据攒一攒再发(等前一个包的 ACK 或攒够一个 MSS)。这对吞吐友好,但对延迟是灾难——一个小请求可能被硬生生延迟几十到几百毫秒(尤其和 TCP 延迟确认 delayed ACK 撞在一起时)。低延迟服务(竞价、RPC、游戏)必须 TCP_NODELAY=true 关掉 Nagle,让每个包立即发出。Netty 里它默认就是开的(关 Nagle),但显式写上更稳妥。
5.4 SO_RCVBUF / SO_SNDBUF:内核读写缓冲
TCP 内核接收/发送缓冲区大小,直接影响 TCP 吞吐上限(吞吐 ≈ 窗口 / RTT,窗口受缓冲区约束)。高带宽 × 高延迟(BDP 大,如跨地域、CTV 长链路)的场景要调大才能吃满带宽;但连接数极多时要谨慎——每条连接都占这么多内核内存,百万连接 × 大缓冲会直接吃爆物理内存。多数情况交给内核自动调优(net.ipv4.tcp_rmem/tcp_wmem)即可。
5.5 ALLOCATOR:池化 ByteBuf 分配器
PooledByteBufAllocator.DEFAULT(Netty 4.1 起默认)用 jemalloc 风格的内存池复用 ByteBuf,避免每次分配/释放都向系统申请堆外内存——这既降低分配开销,又大幅减少 GC 压力(细节见第二篇 ByteBuf 内存模型)。高吞吐服务务必保持池化。代价是必须严格管理引用计数(release),否则池化内存泄漏比非池化更难查。
5.6 RCVBUF_ALLOCATOR:动态接收缓冲
RCVBUF_ALLOCATOR 决定每次从 socket 读数据时分配多大的 ByteBuf。默认的 AdaptiveRecvByteBufAllocator 会根据上一次实际读到的字节数动态调整下一次的分配大小:读得多就调大(少几次 read 系统调用即可读空内核缓冲)、读得少就调小(不浪费内存)。这在连接负载差异极大的场景(有的连接空闲、有的猛灌)尤其省内存——不用给每条连接都按最坏情况预留大缓冲。
参数调优没有银弹,但有一条主线:低延迟开
TCP_NODELAY、内存效率靠池化 + 自适应分配、抗突发靠SO_BACKLOG、抗堆积靠水位线。 先把这几个配对,再谈系统层内核参数。
6. 百万级连接:内存、fd、线程与堆外的约束
单机扛百万长连接不是「把参数调大」就行,而是要同时降服四座资源大山——任何一座先塌,整机就塌。(社区常说的 C10M 指向千万连接;工程上先把百万级约束吃透。)
百万连接是四座资源大山的联立方程:fd 靠 ulimit、内存靠堆外上限、线程靠 EventLoop 复用、GC 靠池化——外加 Epoll 传输层与全套监控告警。
6.1 文件描述符(fd)
每条 TCP 连接在 Linux 上都是一个 fd。百万连接 = 百万 fd,远超默认的 1024。要同时调三层:
ulimit -n 2000000 # 进程级:单进程可打开的 fd 上限
# /etc/security/limits.conf 里持久化 nofile
sysctl -w fs.file-max=3000000 # 系统级:全系统 fd 总量
sysctl -w fs.nr_open=3000000 # 单进程 fd 硬上限(ulimit 不能超过它)
fd 不够的表现是 accept 报 Too many open files——新连接建不上,是百万连接第一道坎。
6.2 内存与堆外上限
内存分两块:堆内(连接对象、handler 状态、业务对象)和堆外(ByteBuf、ChannelOutboundBuffer)。百万连接下,即使每条连接只占几 KB 堆内 + 几十 KB 堆外,乘以百万也是几十 GB 级。
最危险的是堆外:它不受 -Xmx 约束,普通监控看不到。必须显式设上限并监控:
-XX:MaxDirectMemorySize=8g # 堆外 Direct 内存硬上限,逼近即应告警
这个上限是保命的——它把”堆外无限涨到把整机内存吃光、进程被 OOM Killer 静默杀死”这种最难排查的故障,转成一个可预期、可监控、可告警的 OutOfMemoryError。没设上限时堆外能悄悄涨到物理内存耗尽(堆外无上限时的典型事故形态)。配合 §2 的水位线背压,才能真正堵住堆外暴涨。
6.3 线程:靠复用而非堆线程
百万连接最反直觉的一点:不需要百万线程,甚至不需要几千线程。Netty 的 EventLoop 用少量线程(≈2×CPU,几十个)通过 Selector 多路复用承载全部连接。想「一连接一线程」在这个量级是自杀(百万线程的栈内存 + 上下文切换开销直接压垮内核)。所以线程这座山,Reactor 模型本身就替你降服了大半——前提是你没在 EventLoop 里阻塞(§4)。
6.4 GC:靠池化 + 堆外绕开
百万连接意味着海量对象与缓冲的分配/回收,堆内对象一多,GC 扫描/停顿就重(这条和本地缓存那篇的 GC 逻辑同源,详见 JVM GC)。两个手段:池化 ByteBuf(§5.5,减少分配频率)+ 数据主要放堆外(绕开 GC 扫描)。堆外的代价是要自己管生命周期和上限(§6.2),但换来的是 GC 停顿与连接数基本解耦。
7. Epoll native transport 与监控指标
7.1 Linux 上优先用 Epoll native transport
Netty 在 Linux 上除了 JDK 自带的 NIO transport,还提供基于 epoll 的原生传输层 netty-transport-native-epoll。切换只要换 group 和 channel 类型:
EventLoopGroup boss = new EpollEventLoopGroup(1);
EventLoopGroup worker = new EpollEventLoopGroup();
b.group(boss, worker)
.channel(EpollServerSocketChannel.class); // 而非 NioServerSocketChannel
它相对 JDK NIO 的优势:
- 更贴近内核、更少开销:直接用 epoll 系统调用,绕开 JDK NIO 的一些抽象层与已知 bug(如臭名昭著的
epoll空轮询); - 边缘触发(ET)模式:JDK NIO 只能水平触发,Epoll transport 支持 ET,配合读取逻辑能减少无效唤醒;
- 更少 GC:native transport 在事件处理路径上产生的临时对象更少;
- 独占的 Linux 特性:支持
SO_REUSEPORT(多个 EventLoop 各自绑同一端口、内核做连接级负载均衡,消除单一 accept 线程瓶颈)、TCP_FASTOPEN、TCP_CORK等 NIO 给不了的选项。
百万连接 / 高吞吐场景在 Linux 上基本都应该用 Epoll transport;跨平台开发时可以在 macOS 用 KQueue transport,本地 NIO 兜底。
7.2 必盯的监控指标
背压和堆外类事故的共同特点是”发生时堆内存监控一片绿”——所以监控选对指标比加告警更重要。五个必盯:
- 活跃连接数:容量水位的基线,突增可能是重连风暴。
- EventLoop 任务队列延迟:任务从入队到执行的等待时间——它变大是 EventLoop 被阻塞(§4)的直接证据。
- 写缓冲
ChannelOutboundBuffer堆积字节:可通过Channel.unsafe().outboundBuffer().totalPendingWriteBytes()采样——背压事故最早的信号,持续增长就是写不出去在堆积。 - GC 停顿:Full GC 频率与停顿时长,尾延迟毛刺的常见根因。
- 堆外 Direct 内存用量:
BufferPoolMXBean或 Netty 的PooledByteBufAllocatorMetric——逼近MaxDirectMemorySize就必须告警,这是 Direct OOM 的唯一预警。
指标 3 和 5 是这一篇的主角:写缓冲堆积(3)是因,堆外暴涨(5)是果,Direct OOM 是终局。 把这两条曲线放到一张大盘上,一眼就能看出背压是否失守。
延伸阅读
- Netty 深挖(开篇)· Reactor 与 EventLoop
- Netty 深挖 · ByteBuf、引用计数与零拷贝
- Netty 深挖 · Pipeline、编解码与粘包拆包
- 连接与 IO 模型
- 服务限流
- Netty · User guide & API
- Netty · Native transports(Epoll / KQueue)
- Norman Maurer, Marvin Allen Wolfthal. Netty in Action
- Norman Maurer. Netty best practices