资讯动态

Netty写数据链路拆解:从writeAndFlush到SocketChannel的完整流程

发布时间:2026/10/17 2:07:34 来源:尧图企业网站定制
做网络编程时间长了会在“写数据”这件事上形成一个错觉写不出去就往Channel上调用一行writeAndFlush完事。直到线上出现发送队列积压、连接写半包甚至内存不断攀升的问题才意识到这条链路里其实藏了不少东西。这篇我把Netty从业务线程调用写操作到字节真正交付到NIO SocketChannel 的整个过程拆开主要基于4.1版本源码核心围绕 pipeline、ChannelOutboundBuffer 和 NioEventLoop 这三层展开。如果你刚开始啃Netty源码先别急着把EventLoop、Promise、Future这些概念全都搞明白。盯住一个字写。同一个消息对象是怎么从 tail 传到 head怎么进入写缓冲区怎么被标记为可flush最终怎么通过JDK的SocketChannel.write交给内核这条链路完整走一遍很多诡异的线上问题都能正面推理出原因。1. 一次writeAndFlush调用在pipeline中的流转轨迹1.1 从Channel入口到TailContext的第一次交接很多刚接触Netty的读者会以为channel.writeAndFlush(msg)是直接在某个Unsafe上执行其实不是。它的入口是AbstractChannel.writeAndFlush内部调用的是pipeline.writeAndFlush(msg)。也就是说一个出站数据要经历的并不是什么神秘通道而是和入站数据完全同一套pipeline机制只不过方向相反。DefaultChannelPipeline.writeAndFlush会从 tail 开始往前找。为什么要从tail开始因为pipeline里每个handler都持有两个相邻节点的引用出站事件的传播顺序就是“从内部往外部”tail是最里面的一个节点它先拿到这个消息然后由每个handler决定继续传还是就地拦下。这一步关键在AbstractChannelHandlerContext.writeAndFlush里。如果你去翻4.1.x的源码会发现它最终调到一个重载方法write(Object msg, boolean flush, ChannelPromise promise)。flush为true时它找的是身上带有出站写标记的handler找到后调用invokeWriteAndFlush(msg, promise)。这个方法会真正触达用户自定义Handler的write回调。1.2 Handler改写msg与HeadContext的最终落点在出站链路上Handler往往不只是“传递”消息还经常承担编码工作。比如你业务层传进一个自定义的Java对象出站方向某个Encoder会把它转成ByteBuf再调用ctx.write(buf, promise)继续往下传。如果同一个Handler里没有调用ctx.write消息就断在这层了——这是新手写Netty Handler时最容易犯的错自测时发现对端收不到任何数据多半就是这个原因。消息一路往前最终到达 HeadContext。看HeadContext.write这个方法里头其实特别简单Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { unsafe.write(msg, promise); }也就是说真正写数据的入口是AbstractUnsafe.write。在这个方法里Netty会先做一件事filterOutboundMessage(msg)。对NioSocketChannel而言这个方法会把不是ByteBuf或FileRegion的消息过滤掉甚至尝试把堆外消息转换处理之后才把消息扔进写缓冲区。这里的“写缓冲区”就是下节要聊的 ChannelOutboundBuffer它是整条写链路里最核心的数据结构也是你排查内存积压问题第一个要盯的地方。另外write和flush在源码层面是严格分开的。write只负责“入队”真正让内核发送是另一个入口flush。所以writeAndFlush只是前者和后者的语法糖理解这一点是理解后面所有缓冲机制的前提。2. ChannelOutboundBuffer从addMessage到addFlush的缓冲核心2.1 环形Entry数组与三根指针的语义ChannelOutboundBuffer 不是一个简单的队列它内部维护了一个环形Entry数组。每个Entry是一个链表节点同时也承载消息本体和对应的ChannelPromise。你往里丢消息时addMessage会把msg包装成Entry放到tailIndex指向的位置。它同时维护了三个关键索引tailIndex 下一个Entry写入的位置 flushedEntry 当前第一个已被标记为可flush的Entry unflushedEntry 当前第一个还没flush的Entry如果你只看这三个索引会发现一个关键事实未flush的消息和已flush但还没写完的消息其实共享同一个环形空间只是索引位置不同。addMessage只会移动tailIndex不会移动flushedEntry。直到你调用addFlushflushedEntry才会追到unflushedEntry的位置把这一批消息统一标为“该发送了”。所以channel.write(msg)和channel.flush()分离的意义就很清楚了你可以连续写10个小消息再调用一次flush让10条消息作为一个批处理进入真正的发送循环大幅降低系统调用次数。这种设计在低延迟高吞吐的服务里非常实用。2.2 current、progress、remove三个方法配合推进EventLoop真正发送数据时不是遍历整个数组而是只看current()public ByteBuf current() { Entry entry buffer[flushedEntry]; if (entry ! null) { return entry.buf; } return null; }这里有一个细节Entry里既存msg又存buf。在addMessage的时候如果msg本身是ByteBufnetty会把entry.buf指过去后面对外暴露的发送对象其实是buf。这样设计是为了兼容FileRegion这类非ByteBuf消息对于文件发送类型current里可能是null发送逻辑会走到另一条分支。当JDK层的SocketChannel完整写完一个ByteBufNetty调用remove()把当前Entry清空flushedEntry前移一位如果只写了一半就调用progress(amount)记录当前已写的字节数等下一次OP_WRITE事件再接着写。这三个方法配合得极其紧密是理解“部分写”机制的核心。2.3 释放引用计数的正确时机很多同学问ByteBuf到底什么时候释放答案就在remove()里。当消息被完整写入Socket后Entry会把自己持有的msg和promise清理干净同时触发一次decrementPendingOutboundBytes对引用计数的ByteBuf做一次release。如果消息是被取消或者连接关闭时仍滞留队列也会走失败释放的逻辑。我在调试一个服务时见过一个典型问题业务侧在Handler里把同一个ByteBuf同时传给了两个channel写出结果两边都释放导致Double Free。这类问题如果在remove的回调上看引用计数变化会清晰得多。3. NioSocketChannel的部分写与OP_WRITE触发机制3.1 写自旋为什么不是写一次就完事当消息进了ChannelOutboundBuffer真正离开Netty、进入内核靠的是AbstractNioByteChannel.doWrite。这一步要面对一个NIO程序员都绕不开的现实JDK的SocketChannel.write(ByteBuffer)不保证一次写完整个缓冲区。Socket发送缓冲区满了它会返回0或者只写了一部分。Netty的处理办法是“自旋 OP_WRITE注册”。看doWrite的主循环关键点在于writeSpinCount默认值是16。也就是说一个ByteBuf最多允许循环写16次如果16次之内还没写完就不再傻乎乎地空转而是注册OP_WRITE等内核通知可以继续写。while (!in.isEmpty()) { ByteBuf buf in.current(); if (buf null) { // FileRegion场景 in.remove(); continue; } final int readableBytes buf.readableBytes(); int writeSpinCount config().getWriteSpinCount(); int writtenBytes 0; boolean setOpWrite false; while (writtenBytes readableBytes writeSpinCount 0) { int localWrittenBytes ch.write(buf); if (localWrittenBytes 0) { writtenBytes localWrittenBytes; } else if (localWrittenBytes 0) { setOpWrite true; break; } else { throw new IOException(... ); } writeSpinCount --; } if (writtenBytes readableBytes) { in.remove(); } else { in.progress(writtenBytes); if (setOpWrite) { break; } } }这里in.progress(writtenBytes)很重要它不移动ByteBuf的readerIndex也不会让Entry被移除。ByteBuf内部的readerIndex是JDK底层写操作自己推进的Netty记录下来只是为了统计数据用下次OP_WRITE触发时数据从ByteBuf当前readerIndex继续写。3.2 从setOpWrite到clearOpWrite的循环避免当doWrite发现写不进去时会调用setOpWrite()在Selector上注册唤醒事件。这里最需要留意的是OP_WRITE是一个很“危险”的事件它几乎总是可写如果不加控制Selector会立刻通知、立刻触发flush、然后又立刻进入写方法形成CPU空转。Netty对此的处理是在 NioEventLoop 处理 OP_WRITE 事件时不会直接无止境地调用flush而是通过forceFlush在事件循环里保证每一次OP_WRITE只对应一次写尝试。当数据真正写完时clearOpWrite()会把这个兴趣位去掉。因此如果某个连接长时间都在setOpWrite而又写不出去基本就是对端处理不过来或者网络拥塞了这种连接在你用ss或netstat排查时会看到大量的Send-Q积压。另外writeSpinCount并不总是越大越好。调大自旋次数能减少OP_WRITE事件的切换但会占用EventLoop线程的时间其他连接会被拖累。线上遇到某条连接写不出去时我一般不会优先去调这个数而是先看目标端的消费能力和TCP窗口。3.3 FileRegion与零拷贝分支对于大文件发送Netty没有让你把整个文件读进ByteBuf而是直接传FileRegion。doWrite里对buf null的情况会走FileRegion.transferTo这是JDK底层基于sendfile的零拷贝优化。这条分支和ByteBuf的写循环走的是两套逻辑但也一样受写自旋和OP_WRITE控制。想排查大文件传输半截断掉的问题重点就在transferTo返回值和进度记录上。4. 水位线从isWritable变化到背压全链条4.1 totalPendingSize是怎么记账的ChannelOutboundBuffer 里的积压不只是“消息条数”Netty更关心的是“字节总量”。每次addMessage之后都会调用incrementPendingOutboundBytes(size)这里的size就是这条消息预计会占用的字节数。这样设计的原因很直接服务端内存压力主要来源于写缓冲区的总大小而不是Entry数量。反过来当消息被完整移除时decrementPendingOutboundBytes会扣掉相应字节。注意progress()记录的部分写进度不会减少totalPendingSize因为它只是表示“写了多少”但消息还没彻底发送完毕内存还没释放。所以你看pending值高的时候要先判断到底是消息本身太大还是某个大消息被卡在部分写状态。4.2 高水位低水位的切换逻辑Netty提供了一个配置类WriteBufferWaterMark默认低水位32KB、高水位64KB。当一个channel的totalPendingSize超过高水位时内部会把unwritable标记为true此时channel.isWritable()返回false同时触发一次fireChannelWritabilityChanged把事件沿着pipeline的入站方向传播出去。当积压降到低水位以下才移回writable状态再次通知业务Handler。源码里这段逻辑非常直观public void incrementPendingOutboundBytes(long size) { long newWriteBufferSize totalPendingSize size; totalPendingSize newWriteBufferSize; if (newWriteBufferSize channel.config().getWriteBufferHighWaterMark()) { setUnwritable(); } } public void decrementPendingOutboundBytes(long size) { long newWriteBufferSize totalPendingSize - size; totalPendingSize newWriteBufferSize; if (newWriteBufferSize channel.config().getWriteBufferLowWaterMark()) { setWritable(); } }你可以在业务Handler里监听channelWritabilityChanged当isWritable变为false时停掉对端的消息生产或者丢弃非关键的实时数据避免内存被打爆。这是Netty应用层最常见的背压姿势之一。4.3 自动关闭读事件与对端保护水位线只是背压的一部分。另一个天然的背压手段是暂停读。当一条连接写不出去时如果还继续拼命从对端读数据消息进得来出不去内存照样会涨。Netty里有一个autoRead配置在写缓冲区不可写时结合ChannelConfig的调整会取消OP_READ兴趣位相当于让对端发送的TCP窗口变满从而把压力反推给对方。这段联动逻辑并不复杂isWritable()为false时doBeginRead会判断是否还要继续注册读事件。所以如果你发现某个连接不再读数据了先查它是不是已经处于不可写状态而不是一上来就怀疑Selector出问题。5. 写线程模型EventLoop与外部线程的切换代价5.1 为什么不能直接在业务线程写缓冲区ChannelOutboundBuffer 不是线程安全的所有对它的操作包括addMessage、addFlush、remove都必须在channel所属的EventLoop线程里执行。但业务线程往往不在EventLoop上于是 Netty 在 AbstractChannelHandlerContext 中做了一个引人注目的判断if (executor.inEventLoop()) { if (flush) { next.invokeWriteAndFlush(msg, promise); } else { next.invokeWrite(msg, promise); } } else { // 构建 WriteAndFlushTask塞入 EventLoop 的任务队列 safeExecute(executor, task, promise, msg); }如果你在业务线程里直接调用channel.writeAndFlush实际上这个写操作不会立刻执行而是被包装成了一个WriteTask或WriteAndFlushTask投递到EventLoop的MPSC任务队列里排队等待EventLoop处理。这也是为什么很多人会在Flush之后立刻去读Future的状态发现还没完成——因为线程切换还没发生。5.2 跨线程写带来的回调线程问题跨线程写还有一个容易被忽略的后果ChannelPromise的完成回调最终一定是在EventLoop线程里触发的。也就是说你给writeAndFlush添加的listener会在EventLoop线程上执行。如果在listener里做了耗时的IO操作比如查数据库、调远程接口整个EventLoop会被卡住同一线程上的所有其他channel都会跟着遭殃。我之前遇到过一次线上毛刺某个服务里有人习惯在writeFuture的listener里做一次Redis读取结果触顶时上千个channel共用同一个EventLoop其他连接全部延迟飙升。排查思路很简单看EventLoop线程的栈基本一眼就定位了。正确做法是把耗时操作丢回业务线程池或者完全异步化。5.3 减少跨线程失败的通用策略跨线程写本身没有错但高频跨线程写会对MPSC队列造成压力。有一些服务会把同一个Channel的写入集中到固定几个业务线程再批量write然后flush一次这是一个很实用的优化。还有一个细节如果你在业务线程里频繁调用writeAndFlush每一次都会产生一个task在小消息高并发场景下task创建数量会非常可观GC压力也随之增大。这种情况下考虑先调用write累积让EventLoop上形成一次批量flush往往比疯狂用writeAndFlush更合理。6. 实测中值得留意的坑和调优建议6.1 水位线别照着默认值用默认的32KB/64KB水位线对很多小消息服务来说是够用的但如果你一个单条消息就是几百KB还没开始发就触顶了。网上能看到一个常见做法是调高水位比如改成256KB/512KB这个方向没问题但千万别只改不测。水位设太高内存积压风险大设太低又会导致isWritable频繁抖动业务Handler跟着反复触发背压逻辑白白浪费CPU。建议根据单条消息大小乘以合理的积压上限来估算。设置方式channel.config().setWriteBufferWaterMark( new WriteBufferWaterMark(256 * 1024, 512 * 1024));6.2 报错Connection reset by peer的常见场景写数据时最常遇到的两个异常Connection reset by peer和Broken pipe。很多人下意识认为是Netty的问题其实绝大多数是对端直接关闭了连接而你还在往它写。这种情况可以理解为一个正在写数据的通道被对端硬掐了。按我的经验与其纠结异常本身不如做好连接异常关闭后的清理并关注对端应用是否有慢消费、卡顿、死锁。6.3 用ChannelOutboundBuffer的视图排查积压调试写积压问题时可以在断点里直接观察ChannelOutboundBuffer的totalPendingSize或者通过channel.unsafe().outboundBuffer()拿到内部结构。不过生产环境没法这样断点。更实用的办法是定期打印channel.isWritable()和待写字节数把积压趋势记录下来看是不是在持续增长。如果pending一直涨优先看对端TCP窗口和消费速度再回头查自己的生产节奏。6.4 关于小消息合并与零拷贝还有一个很常见的优化点如果业务是高频率发送几十字节的小消息这些消息在TCP层会非常浪费。你可以在业务层用CompositeByteBuf把多个消息拼成一个或者用Netty自带的聚合能力在Encoder里攒一批再写。这不仅是减少系统调用也能降低ChannelOutboundBuffer的Entry数量从根上减少内存碎片。我做过一个压测对比同样吞吐下小消息聚合后再写内存占用能下降近一半整机GC明显改善。写数据这条链路真正深入之后你会发现它其实是一套完整的缓冲、背压、事件驱动的系统工程。每个看似繁琐的机制最后都是为了让网络IO在不可靠的TCP连接上尽量高效、可控地运转。遇到线上异常时先判断问题落在哪一层是pipeline传丢了是ChannelOutboundBuffer积压了还是JDK Socket写不进去了。逐层排查总比盲目调参舒服得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑