《Netty 高性能网络编程》9.1 Channel.isWritable 与高低水位

WriteBufferWaterMark 的两个参数到底在统计什么?本节用 linkd-v2 的 WritabilityPump 拆开 Channel.isWritable 的判定、channelWritabilityChanged 的触发与 4.2 默认的 32/64 KiB 水位,给出生产者暂停与恢复的完整 handler,并用本机输出核对 config 打印值与状态迁移。

本节目标:把 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 写队列与可写事件 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发