本节目标:把
Channel.isWritable()从「一个布尔值」还原成「用户态待写字节与两条水位的比较」,并写出可暂停、可恢复的生产者 handler。
适用版本:JDK 25 + Netty 4.2.19.Final。
承接的问题
8.3 处理的是连接已经决定关闭时怎么收尾:半关闭、closeFuture、ChannelGroup 回收。还有一种连接始终活着、对端也始终不关,但就是读得慢。linkd-v2 的 9093 自定义协议连接上,业务侧持续产生 RESPONSE 和广播帧,事件循环把帧写进 Netty 的待写队列,内核发送缓冲区满了以后,队列开始堆积。此时连接没有断,ChannelFuture 也没有失败,日志里什么都看不到,只有进程的堆外内存一路涨。
9.1 要回答的就是「什么时候该让生产者停一下」。Netty 给出的信号是 Channel.isWritable() 和 channelWritabilityChanged,阈值由 WriteBufferWaterMark(low, high) 配置。这三个东西经常被误解成 TCP 发送窗口:看到 isWritable() == true 就以为对端收得下,看到 false 就以为网络拥塞。两者不是一回事,混用会让限流逻辑在错误的时间点动作。
先看 linkd-v2 里一个真实的广播循环。ConnectionRegistry.broadcast 从会话表里取出所有连接,对每条连接写一帧,写完立刻 flush:
for (Channel ch : channels) {
ch.writeAndFlush(frame.retainedDuplicate());
}
对端读得慢时,这段代码不会立刻报错。writeAndFlush 返回的 future 通常只是完成得越来越晚,严重时一直不完成;待写队列里的字节越来越多。生产者没有任何反馈,只能等内存耗尽或者连接被操作系统重置。本节先把这个队列的口径讲清楚,再给一个能真正让生产者停下来的 handler。
核心机制一:水位比较的是用户态待写字节
WriteBufferWaterMark 只有两个值:low 和 high,单位是字节。构造器会校验 low >= 0 且 high >= low,不满足直接抛 IllegalArgumentException。它不关心 TCP 接收窗口、拥塞窗口,也不关心内核 SO_SNDBUF 里还塞着多少字节;它只看 Netty 自己还没写进 socket 的那部分消息大小之和。
Channel.isWritable() 的判定可以写成一句话:
待写字节 > high -> 不可写
待写字节 < low -> 可写
其余情况 -> 保持上一次的状态
注意两个比较都是严格不等号,不是 >= / <=。这正好构成滞回:超过高水位才翻成不可写,低于低水位才翻回可写,中间那段不产生动作。isWritable() 返回的是一个缓存标志,不会每次调用都重新累加队列;标志只在 channelWritabilityChanged 真正翻转时改变。因此它适合做「状态」,不适合做「精确余量」。
| 量 | 是否被 isWritable 统计 | 说明 |
|---|---|---|
| Netty 待写队列里的消息 | 是 | 已 write() 但还没完整交给 socket 的字节 |
| 内核发送缓冲区 | 否 | 已经写进 socket,但可能还没发出去 |
| 网络上在途的字节 | 否 | 已经离开本机,对端尚未确认 |
| 对端接收缓冲区 | 否 | 完全在本端视野之外 |
这张表解释了两个常见现象。第一,isWritable() 为 true 时,内核发送缓冲区可能已经满了,因为 Netty 把消息全部交给了内核,自己的队列是空的。第二,isWritable() 为 false 时,TCP 窗口可能还很宽,只是事件循环还没轮到 flush,消息暂时排在用户态。把 isWritable 当成「网络拥塞指示器」会得出错误结论;它指示的是本进程愿不愿意再接收新的写任务。
配置入口有两个:Bootstrap 的 ChannelOption.WRITE_BUFFER_WATER_MARK,或者拿到 ChannelConfig 后调用 setWriteBufferWaterMark。前者在连接创建时生效,后者适合测试或运行期调整。服务端要用 childOption,它作用于 accept 出来的子连接;写在 ServerBootstrap.option 上只会改监听 socket,对业务连接无效:
serverBootstrap.childOption(ChannelOption.WRITE_BUFFER_WATER_MARK,
new WriteBufferWaterMark(32 * 1024, 128 * 1024));
客户端用 Bootstrap.option,因为客户端只有一条连接,没有 parent/child 之分。这个差别在 2.3 已经出现过,配错时的表现是「代码执行了、水位没变」,不会报错。
low 不应设为 0。构造函数允许 0,但那样意味着只有队列彻底清空才恢复可写,一个大响应会把连接锁到最后一字节写完,期间生产者完全停摆。low 与 high 之间要留出「一次 flush 大致能排空多少」的余量;余量太小,恢复后立刻又越线,channelWritabilityChanged 会在事件循环里反复触发。
核心机制二:可写事件只在翻转时触发
channelWritabilityChanged 是入站 handler 的方法,但它的来源是出站队列长度变化:write() 让待写字节增加,flush 成功让待写字节减少。增加后超过 high,Netty 把标志置为不可写并触发一次事件;减少后低于 low,再触发一次。两次事件之间无论队列怎么波动,都不会重复通知。
这意味着 handler 里拿到事件时,应该做的是「切换生产状态」,而不是「再写一点试试」。后者会在不可写事件里继续 write(),把队列推得更高,下一次 flush 只释放一点点,立刻又触发不可写,形成事件风暴。
一个最小的观察器长这样:
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
public final class WritabilityLog extends ChannelInboundHandlerAdapter {
private static final System.Logger LOG = System.getLogger("linkd.writability");
@Override
public void channelWritabilityChanged(ChannelHandlerContext ctx) {
Channel ch = ctx.channel();
LOG.log(System.Logger.Level.INFO,
"writable={0} beforeWritable={1} beforeUnwritable={2}",
ch.isWritable(), ch.bytesBeforeWritable(), ch.bytesBeforeUnwritable());
ctx.fireChannelWritabilityChanged();
}
}
bytesBeforeWritable() 在不可写状态下给出「还要排空多少字节才能恢复」,bytesBeforeUnwritable() 在可写状态下给出「还能再塞多少字节才会翻转」。两个值都由当前待写字节和两条水位算出来,适合放进诊断日志,但不适合拿来做精确配额:队列在两次采样之间就被 flush 改变了。
还要注意事件传播。ChannelInboundHandlerAdapter 的默认实现会把事件继续往后传;一旦覆写,就必须自己调用 ctx.fireChannelWritabilityChanged(),否则后面的 handler 收不到这个事件。这个方法和 channelRead 不一样,漏传不会报错,只会表现为「某个限流器一直不生效」。
核心机制三:默认值要在运行时打印核对
Netty 4.2 的 WriteBufferWaterMark.DEFAULT 是 low = 32768、high = 65536,也就是 32 KiB / 64 KiB。这个值在 4.x 系列长期没变,但不要把它背成协议常量:如果项目里有人通过 ChannelOption.WRITE_BUFFER_HIGH_WATER_MARK 或配置文件改过,默认值就被覆盖了;靠记忆写死的阈值会和实际行为对不上。
核对方法很直接:连接建立后打印 config().getWriteBufferLowWaterMark() 与 getWriteBufferHighWaterMark()。本机用一个未设置水位的服务端连接和一个显式设置 (8192, 16384) 的客户端连接做对比,输出是:
client low=8192 high=16384
server child low=32768 high=65536
第一行来自客户端,ChannelOption.WRITE_BUFFER_WATER_MARK 把它设成了 8 KiB / 16 KiB;第二行来自服务端 accept 出来的子连接,没有设置,打印出来就是 32 KiB / 64 KiB。这就是 4.2.19.Final 的默认值。本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final;单次运行,未做统计。
默认值适不适合生产,取决于单连接写放大。64 KiB 对 HTTP 短响应足够,对持续推送大帧的长连接偏小:待写字节超过 64 KiB 就翻成不可写,而一次 flush 可能只写出去十几 KiB,生产者会被频繁唤醒。linkd-v2 在 9093 广播通道上把 high 调到 256 KiB,low 保持 64 KiB,理由是单帧上限 1 MiB、平均帧 4 KiB 左右,64 KiB 的恢复线大约对应 16 帧的余量。这个数字要按自己的帧分布压测,不是通用推荐。
在 linkd-v2 里落地:WritabilityPump
WritabilityPump 是一个每连接新建的入站 handler。它从 FrameSource 拉取待发送的 LinkdFrame,只要通道可写就继续写,不可写就通知 source 暂停;等 channelWritabilityChanged 报告恢复可写,再继续拉。它不做编码,编码仍由 LinkdFrameEncoder 完成。
package com.plume.linkd.v2.server;
import com.plume.linkd.v2.protocol.LinkdFrame;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
public final class WritabilityPump extends ChannelInboundHandlerAdapter {
public interface FrameSource {
LinkdFrame poll();
void onPaused();
void onResumed();
void close();
}
private final FrameSource source;
private boolean pumping;
public WritabilityPump(FrameSource source) {
this.source = source;
}
@Override
public void channelActive(ChannelHandlerContext ctx) {
ctx.fireChannelActive();
pump(ctx);
}
@Override
public void channelWritabilityChanged(ChannelHandlerContext ctx) {
Channel ch = ctx.channel();
if (ch.isWritable()) {
source.onResumed();
ch.eventLoop().execute(() -> pump(ctx));
} else {
source.onPaused();
}
ctx.fireChannelWritabilityChanged();
}
@Override
public void channelInactive(ChannelHandlerContext ctx) {
source.close();
ctx.fireChannelInactive();
}
private void pump(ChannelHandlerContext ctx) {
if (pumping) {
return;
}
pumping = true;
boolean wrote = false;
try {
while (ctx.channel().isWritable()) {
LinkdFrame frame = source.poll();
if (frame == null) {
break;
}
ctx.write(frame);
wrote = true;
}
} finally {
pumping = false;
}
if (wrote) {
ctx.flush();
}
if (!ctx.channel().isWritable()) {
source.onPaused();
}
}
}
几个行为细节值得逐条说明。
pump先看isWritable()再poll()。 如果先取帧再判断,不可写时会把已经出队的帧留在手里,既不能写回去,也不能释放,只能额外维护一个「回推」分支。先判断就不会把帧取出队列。- 每轮只在末尾 flush 一次。 循环里多次
ctx.write(frame)只是入队,最后一次ctx.flush()才让事件循环尝试写 socket。这和 9.2 的批量写是同一个原则。 - 恢复时用
eventLoop().execute而不是直接调用pump。channelWritabilityChanged可能在ctx.write内部、也就是pump还没退出时触发;直接递归会绕开pumping保护。投递到事件循环队尾,等当前调用栈退出后再拉,顺序更可控。 onPaused是给上游的刹车信号。 在linkd-v2里,它把该连接的FrameSource标记为暂停,业务线程看到标记后不再往这个 source 投递新帧。pump本身不阻塞、不 sleep,事件循环线程不会因为某一个慢连接停住;onPaused可能被事件回调和循环末尾各调用一次,实现必须幂等。channelInactive负责清理。 source 里可能还排着未发送的帧,close()要清空队列并记录丢弃数;已经ctx.write出去但没 flush 的帧由 Netty 在通道关闭时处理。
装配顺序是「先编码器,后 pump」:
pipeline.addLast(new LinkdFrameDecoder());
pipeline.addLast(new LinkdFrameEncoder());
pipeline.addLast(new WritabilityPump(source));
pipeline.addLast(new LinkdBusinessHandler());
出站事件从 tail 往 head 走。WritabilityPump 调用 ctx.write(frame) 时,事件从它所在的位置向前找 handler,先经过 LinkdFrameEncoder,帧被编码成 ByteBuf,再交给 head 写 socket。如果 pump 放在编码器前面,ctx.write(frame) 会直接走到 head,LinkdFrame 不是 ByteBuf,写操作会失败。
用一个真实 socket 对跑一遍状态迁移:服务端 AUTO_READ=false 模拟不读的对端,客户端 SO_SNDBUF=8192、水位设为 (8192, 16384),每帧 4 KiB,持续 writeAndFlush 直到不可写,600 ms 后服务端恢复读取。本机单次运行的节选输出如下(省略号不是程序输出):
client low=8192 high=16384
server child low=32768 high=65536
write 1 writable=true beforeUnwritable=16385
write 2 writable=true beforeUnwritable=16385
...
write 28 writable=true beforeUnwritable=12193
write 29 writable=true beforeUnwritable=8001
write 30 writable=true beforeUnwritable=3809
event writable=false
write 31 writable=false beforeUnwritable=0
stop at write 31
event writable=true
server resume read
前 27 次写入都被内核直接接收,队列是空的,所以 beforeUnwritable 一直停在 high + 1 附近。第 28 次开始内核发送缓冲区吃不下,待写字节进入 Netty 队列,beforeUnwritable 按剩余空间递减。第 31 次写入后待写字节超过 16384,标志翻成不可写,事件在 writeAndFlush 返回前就触发了;循环看到 writable=false 立即停止。服务端恢复读取后队列排空,低于 8192 时事件翻回可写。整个过程没有异常,也没有关闭连接。
常见错误与边界
错误一:把 isWritable() == false 当成「对端已经断开」。 它只表示本端待写字节超过高水位,连接完全可能健康。此时关闭连接会把一个「暂时读得慢」的客户端踢掉,重连后继续慢,问题只会放大。正确动作是暂停生产、记录持续时间,只有在超过硬上限或超时后才考虑关闭。
错误二:在 channelWritabilityChanged 里继续 write。
// 反例:不可写时再写一帧,试图「推一把」
if (!ctx.channel().isWritable()) {
ctx.writeAndFlush(nextFrame());
}
写操作不会因为不可写而拒绝,队列继续变长;flush 释放的字节小于新写入的字节,事件会在下一次状态检查时再次触发,形成「不可写 → 再写 → 更不可写」的循环。事件处理里只应该改变生产状态,写入动作留给可写分支或独立的 pump。
错误三:用 ChannelOption.WRITE_BUFFER_HIGH_WATER_MARK 和 WRITE_BUFFER_WATER_MARK 同时设置。 两个选项最终都会写进同一个 WriteBufferWaterMark,后设置的那个覆盖先设置的,low 可能停留在旧值。要么统一用 WRITE_BUFFER_WATER_MARK,要么分别设置高低两个选项并确保 low < high,不要混用。
错误四:把 low 设成等于 high。
// 反例:没有滞回区间
new WriteBufferWaterMark(65536, 65536);
构造器允许 low == high,但状态会在「待写字节恰好等于 65536」附近反复翻转:写入一帧超过就不可写,flush 一帧低于就又可写。等于高水位的边界值本身不触发不可写(判定是严格大于),但下一字节就会触发。留出至少一次 flush 的排空量,才能让事件有意义的稀疏。
边界:isWritable 看不见内核缓冲区和在途数据。 它能说明的只是「Netty 用户态队列没有超过阈值」,不能说明对端应用读得快。对端完全不读时,内核发送缓冲区最终会满,待写队列才开始增长;在那之前 isWritable() 一直是 true。所以水位线要和 SO_SNDBUF、单连接内存预算一起看,而不是单独调一个 high。
边界:水位是每连接的,不是全局的。 一万条连接各自 64 KiB 待写字节就是约 625 MiB,isWritable 不会告诉你总量。全局内存保护要靠连接数上限、单连接队列硬上限和进程级指标,不能指望水位线自动兜底。
小结
WriteBufferWaterMark(low, high)统计的是 Netty 用户态待写字节,不是 TCP 窗口、内核发送缓冲区或在途数据。isWritable()在待写字节严格大于high时翻成false,严格小于low时翻回true,中间区间保持原状态。channelWritabilityChanged只在翻转时触发;覆写后必须调用ctx.fireChannelWritabilityChanged(),否则后续 handler 收不到。- 4.2.19.Final 的默认水位是 32768 / 65536;上线前用
config().getWriteBufferLowWaterMark()和getWriteBufferHighWaterMark()打印核对。 WritabilityPump用「可写才 poll、批次末尾 flush、恢复时投递事件循环」三件事把生产者暂停下来,自身不阻塞事件循环。- 本机输出验证了从可写到不可写再到可写的完整迁移;具体触发写入次数取决于内核缓冲区,不能当成固定值。
水位只解决了「什么时候停」,没有解决「一次写多少、flush 放在哪里、写失败以后怎么办」。9.2 把 write() 与 writeAndFlush() 拆开,用 ChannelFuture 观察真正的完成时机,再给广播场景的批量写实现。
阅读导航:上一节:8.3 关闭、半关闭与连接回收 · 下一节:9.2 写队列与可写事件 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。