Skip to content
Charles Shao
Go back

Netty 深挖 · 背压、水位线与百万连接调优

views

前三篇把 Netty 的骨架拆完了:开篇讲 Reactor 与 EventLoop、第二篇讲 ByteBuf 与堆外内存、第三篇讲 Pipeline 与编解码。骨架能跑起来,不代表能扛住——真正把 Netty 推上生产时,翻车的往往不是功能,而是写不出去的数据往哪堆、EventLoop 被谁阻塞了、堆外内存悄悄涨到 OOM

这一篇只讲一件事:怎么让 Netty 在压力下不崩。性能上限不在「能写多快」,而在「写不动时会不会把自己撑死」——背压与水位线是保命阀,参数与百万连接调优是把这条阀门用到极致。

TL;DR

Table of contents

Open Table of contents

1. 写缓冲与背压的根源:数据写不出去往哪堆

要理解背压,先得看清 write() 到底做了什么。很多人以为 ctx.write(msg) 就是「把数据发到网络上」——其实不然。

在 Netty 里,一次出站写要过两关:

write 与 flush 路径。ctx.write 经 Pipeline 编码后塞进 ChannelOutboundBuffer(待发送链表·堆外)→ flush 写入 SO_SNDBUF → 对端/网络慢则缓冲堆积。底部说明 write Future 完成不等于对端收到,需水位线把 TCP 背压接回应用层。

  1. write():把消息经过 Pipeline 的出站编码后,塞进这条 Channel 的 ChannelOutboundBuffer——一个挂在 Channel 上的待发送队列(内部是一条单向链表,节点持有编码后的 ByteBuf,多为堆外内存)。此刻数据还在你的进程里
  2. flush():把 ChannelOutboundBuffer 里的数据真正写进内核的 socket 发送缓冲区(SO_SNDBUF),由内核负责发到对端。

关键在于第 2 步可能写不动。内核 socket 发送缓冲是有限的,当它被填满(对端 TCP 接收窗口满、对端处理慢、网络拥塞),write 系统调用会返回”暂时写不了”。这时 Netty 只能把没写出去的数据继续留在 ChannelOutboundBuffer,等 socket 重新可写(OP_WRITE 事件就绪)再续写。

于是问题来了:如果你写入的速度持续快于对端消费的速度,ChannelOutboundBuffer 就会不断增长。它没有默认上限——链表可以一直加节点,每个节点持有的 ByteBuf 大多是堆外 Direct 内存。堆外内存不受 JVM 堆大小约束、不被普通 GC 及时回收,于是:

这就是背压(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

用两条水位线而不是一条阈值,是为了避免抖动(hysteresis,滞回):如果只有一条线,缓冲量在阈值附近反复横跳会导致可写状态疯狂翻转。高低水位拉开一段区间,越过高位才停、跌破低位才恢复,状态切换就平稳了。

Netty 写缓冲高低水位与背压状态机示意图。左侧是一根竖直的量表,代表一条 Channel 的 ChannelOutboundBuffer(堆外 Direct 内存)当前的待发送字节堆积量,红色填充表示堆积已越过顶部的红色虚线"高水位 high 默认 64KB",下方还有一条绿色虚线"低水位 low 默认 32KB",量表上方标注"写入速度大于写出速度会导致堆积上升"。右侧是一个两状态的状态机:上方绿色状态框写着 Channel.isWritable() = true、autoRead 开、正常写出、正常读上游;下方红色状态框写着 Channel.isWritable() = false、setAutoRead(false)、暂停读上游、攒着不再写。左边一条红色向下箭头表示"达到高水位:置为不可写,暂停读上游",右边一条绿色向上箭头表示"回落到低水位:恢复可写,恢复读上游",两态循环切换。底部说明:channelWritabilityChanged 回调在两态切换时触发,转不可写就 setAutoRead(false) 停止读上游,转回可写再恢复;写前先判断 isWritable,是 Netty 把 TCP 背压反压到应用层的标准开关。

写缓冲越过高水位 → 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()=falsesetAutoRead(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

Netty 线程模型划分:左列 bossGroup(1~2 EventLoop)只 accept;中列多个 worker EventLoop 各用 1 线程+Selector 复用 M 个 Channel;右列业务线程池 DefaultEventExecutorGroup 跑阻塞 DB/RPC/重计算。底部说明:EventLoop 线程不能阻塞,阻塞或重活交给业务线程池,worker 数默认约 2×CPU 核。

boss 只管 accept,worker 用少量线程 M:N 复用海量连接,阻塞/重计算必须外包给业务线程池。一个 worker EventLoop 被阻塞,它承载的所有 Channel 一起卡死。

4.1 为什么一次阻塞就是灾难

假设一个 worker EventLoop 承载了 5000 条连接。某个 handler 在 channelRead 里做了一次同步 DB 查询(阻塞 200ms):

同样致命的还有:在 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() / 自定义线程池提交任务,效果类似。

要点:

EventLoop 是流水线的传送带,业务线程池才是干重活的工位。 让传送带停下来去拧螺丝,整条线都得停。

5. 关键参数调优:SO_BACKLOG、TCP_NODELAY 与缓冲区

Netty 的参数分两层,别配错地方:

一份典型的高性能服务端配置:

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 指向千万连接;工程上先把百万级约束吃透。)

百万连接 C10M 的资源约束与调优旋钮示意图,横排四张卡片。卡片①文件描述符 fd:每连接约 1 个 fd,100 万连接等于 100 万 fd,分隔线下方是调优——调大 ulimit -n、fs.file-max / nr_open。卡片②内存·堆外:每连接读写缓冲加 ByteBuf,以堆外 Direct 为主,调优——-XX:MaxDirectMemorySize 设上限防暴涨 OOM。卡片③线程·EventLoop:不是一连接一线程,M 个 Channel 比 1 线程,调优——EventLoop 约等于 2×CPU 核,少而绝不阻塞。卡片④GC·分配:对象和缓冲频繁分配拖累 GC 与延迟,调优——PooledByteBufAllocator 池化复用加堆外绕开 GC。下方两个说明框:左框 Epoll native vs NIO——Linux 上首选 EpollEventLoopGroup(epoll 边缘触发、更少 GC,支持 SO_REUSEPORT/TCP_FASTOPEN),优于 JDK NIO 的 select/poll;右框必盯监控指标——活跃连接数、EventLoop 任务队列延迟、写缓冲 outboundBuffer 堆积字节、GC 停顿、堆外 Direct 内存用量逼近上限即告警。

百万连接是四座资源大山的联立方程: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 不够的表现是 acceptToo 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 的优势:

百万连接 / 高吞吐场景在 Linux 上基本都应该用 Epoll transport;跨平台开发时可以在 macOS 用 KQueue transport,本地 NIO 兜底。

7.2 必盯的监控指标

背压和堆外类事故的共同特点是”发生时堆内存监控一片绿”——所以监控选对指标比加告警更重要。五个必盯:

  1. 活跃连接数:容量水位的基线,突增可能是重连风暴。
  2. EventLoop 任务队列延迟:任务从入队到执行的等待时间——它变大是 EventLoop 被阻塞(§4)的直接证据
  3. 写缓冲 ChannelOutboundBuffer 堆积字节:可通过 Channel.unsafe().outboundBuffer().totalPendingWriteBytes() 采样——背压事故最早的信号,持续增长就是写不出去在堆积
  4. GC 停顿:Full GC 频率与停顿时长,尾延迟毛刺的常见根因。
  5. 堆外 Direct 内存用量BufferPoolMXBean 或 Netty 的 PooledByteBufAllocatorMetric——逼近 MaxDirectMemorySize 就必须告警,这是 Direct OOM 的唯一预警。

指标 3 和 5 是这一篇的主角:写缓冲堆积(3)是因,堆外暴涨(5)是果,Direct OOM 是终局。 把这两条曲线放到一张大盘上,一眼就能看出背压是否失守。

延伸阅读


views
Share this post on:

Previous Post
缓存机制:命中率、更新策略与穿透击穿雪崩
Next Post
Netty 深挖 · Pipeline、编解码与粘包拆包