write() 返回 false 之后,数据去了哪:我把 Node Stream 背压的账算了一遍
- 作者:Bougie
- 创建于:2026-10-13
有个服务,平时好好的,某个下午内存一路涨到 OOM。查日志、看监控,QPS 没涨,代码也没发。最后发现是下游一个客户端出了问题:TCP 连接还建着,但它一个字节都不读。
而我们的代码是这样的:
for (const chunk of rows) {
socket.write(serialize(chunk))
}
write() 的返回值被丢掉了。Node 对此的处理方式很直白:你不停,我就不停地把数据攒在自己内存里。
这件事的官方名字叫背压(backpressure),几乎所有 Node 教材都会用一句话带过:「write 返回 false 时要等 drain」。但真正值得搞清楚的是几个更具体的问题:false 到底在哪个精确的时刻返回?hwm 是不是上限?忽略它到底贵多少?pipe 是怎么自动做的?我把能测的都测了一遍,下面是账本。

所有数据来自本机 Linux x86_64、12 核、Node v24.16.0,脚本不依赖网络,可以照着复现。
# 一、write() 什么时候返回 false
先看默认水位线。Node v24.16.0 上,各种流的默认 highWaterMark(下文简称 hwm):
new Writable() 65536 字节
new Readable() 65536 字节
new Transform() writable 65536 / readable 65536
fs.createWriteStream() 65536 字节
fs.createReadStream() 65536 字节
objectMode 的流 16(个数,不是字节!)
如果你印象里这个数是 16384——老教材没骗你,但版本变了,写代码前打印一下你自己环境里的值。
然后是关键问题:往一个 hwm=65536 的 Writable 里连续 write,第几次返回 false?直接测:
| 每块大小 | 第几次 write 返回 false | 返回 false 那一刻队列里 |
|---|---|---|
| 1 KB | 第 64 次 | 65536 字节 |
| 16 KB | 第 4 次 | 65536 字节 |
| 64 KB | 第 1 次 | 65536 字节 |
| 65537 B | 第 1 次 | 65537 字节 |
(换 hwm=16384 再测一遍,规律一致:1KB→ 第 16 次,4KB→ 第 4 次,16385B→ 第 1 次)
规律就一句话:返回值是「加上这一块之后,队列 ≥ hwm」才变 false。它不是提前预警——等你看到 false,水位已经顶到线上了,而且这一块本身也已经在队列里了。

所以正确的理解不是「false = 出问题了」,而是:false 是水位线第一次被你踩线的确切回执。从这一刻起,你每多写一块,队列就多一块,直到你停下来。
# 二、hwm 不是上限,是水位线
一个更容易踩的理解偏差:hwm 到了之后还能不能写?能。write() 永远接受你的数据(除非 end 之后),区别只是返回 false 并把数据继续排队。hwm 不会替你拦下任何东西。
上面表格的最后一行就是证据:65537 字节的一块照样收下,队列变成 65537。理论上你可以在返回 false 之后继续写 1GB,Node 会老老实实攒 1GB——这就是开头那个 OOM 的原因。
objectMode 还有一个更隐蔽的版本。hwm=16 的流,写 16 个对象之后返回 false,看起来很安全——但它数的是个数:
objectMode、默认 hwm=16,每个对象是 8MB 的 Buffer:
第 16 次 write 返回 false
writableLength = 16(个)
队列实际持有:16 × 8MB = 128 MB
实测进程 RSS:45.1 → 165.9 MB(+120.7 MB)
水位线管住了「条数」,管不住「每条多大」。用 objectMode 传大块数据的同学,心里要另有一本账。

# 三、不理会 false 的代价:390MB 的账
构造一个「跟不上趟的接收方」(每次处理让出一次事件循环),往里面灌 100000 块 × 4KB = 390.6MB,三种写法对照:
【A】不理会 write() 的返回值
生产者跑完耗时 137.3 ms —— 生产者觉得很爽
此刻 stream 队列里 390.6 MB 还没被消费
起始 RSS 44.1 MB
峰值 RSS 514.9 MB (+470.8 MB)
end→finish 总耗时 214.0 ms
【B】等 drain 再写
等了几次 drain 6250 次
起始 RSS 514.2 MB
峰值 RSS 514.7 MB (+0.5 MB)
end→finish 总耗时 121.4 ms
【C】交给 pipe()
峰值 RSS (+1.0 MB)
总耗时 127.3 ms
两个结论,第二个比第一个有意思:
- 内存差了三个数量级:+470.8MB 对 +0.5MB。队列不会替你兜底,它只是把你没处理完的数据换个地方放着——放在你的进程里。
- 不理会背压反而更慢:214ms 对 121ms,慢了 1.8 倍。几十万个 Buffer 堆在队列里,GC 压力、内存分配、缓存 miss 全来了。背压不是「牺牲吞吐换稳定」,慢生产者配合背压,吞吐反而更高。
pipe() 那 127ms 就是 B 方案的自动化版本:它内部做的正是「write 返回 false → 暂停读端 → 等 drain → 恢复」这一套。
# 四、真实世界:一个不读数据的客户端
上面是合成实验。换真实 TCP:服务端往一个客户端写 2048 块 × 64KB = 128MB,客户端连上之后一个字节都不读(现实中对应:卡死的下游、半死不活的爬虫、忘了读 body 的对端):
【A】服务端不理会 write() 的返回值
生产端 28 ms 就把 2048 块全部 write 完
socket 队列里压着 125.5 MB
起始 RSS 46.8 MB → 峰值 183.0 MB (+136.2 MB)
【B】服务端等 drain
3 秒后:socket 队列 0.1 MB
真正写出到内核的只有 2.6 MB
峰值 RSS (+2.8 MB)
这就是那句「一个慢客户端打爆一个服务」的完整机理:内核的 socket 缓冲区只有几 MB,塞满之后,你没接背压的每一笔数据都变成你自己的堆内存。A 方案里 28ms 就决定了后面 125MB 的命运;B 方案里数据停在源头,客户端不读,服务端就不写,内存纹丝不动。

这里还有个细节值得记:socket.writableLength 就是那本「欠账明细」。给所有对外写数据的连接加上「writableLength 超过阈值就报警/断开」的看门狗,比等 OOM 便宜得多。
# 五、背压的另一半收益:攒批
背压常被理解为「限流」,但它还有一个不那么出名的收益:给下游攒批的机会。
Node 写文件时不是每条数据一次 syscall——队列里的数据会用 writev 合并成一次系统调用。用 /proc/self/io 数真实 syscall(syscw = write 类系统调用计数),往文件写 100000 条 × 100B = 10MB:
| 写法 | syscw | 每次 syscall 摊到 | 耗时 |
|---|---|---|---|
| hwm=1(一条一写) | 200020 | 0.1 KB | 354.9 ms |
| 默认 hwm=64KB | 2454 | 4.0 KB | 19.3 ms |
| 默认 + cork 包住整个循环 | 133 | 73.4 KB | 20.6 ms |
| hwm=1MB | 136 | 71.8 KB | 15.9 ms |
hwm=1 那行就是把「背压关掉」的代价:syscall 次数 ×82,耗时 ×18。每次 write 都立刻下沉到内核,一条 100 字节的数据换一次系统调用。
另一个数字:hwm=64KB、每块 1KB 时,writev 单批最多合并 63 块——攒到接近 hwm 就放行,这个批处理窗口的宽度就是水位线本身。
cork() 是手动版的攒批:把水闸关上,随便写,uncork() 一开,队列里的东西用最少的 syscall 一次性放出去。写日志、写数据库批量导入这种「一小口一小口喂」的场景,值得随手 cork 一下。

# 六、读端:hwm 决定了你一次读多少
背压是双向的,读端也有 hwm,而且同样能从 syscall 账本上看出来。读同一个 256MB 文件,改 fs.createReadStream 的 hwm,数 read 次数:
| hwm | syscr | 平均每次读 |
|---|---|---|
| 16 KB | 32775 | 8.0 KB |
| 64 KB | 8198 | 32.0 KB |
| 1 MB | 729 | 359.6 KB |
| 16 MB | 454 | 577.4 KB |
16KB 的 hwm 让内核跑了三万多次 read。最后那行还藏着一个彩蛋:就算你把 hwm 开到 16MB,单次 read 也只摊到 577KB 左右——hwm 是缓冲预算,不是单次 syscall 的大小,fs 流内部有自己的块大小。
顺带测了三种消费写法读同一个文件(各跑 3 次取代表值):
'data' 事件 ~50 ms 内存 +11~43 MB 波动(GC 时机噪声)
pipe 到 /dev/null ~46 ms 内存 +0 MB syscr 12286
for await ~45 ms 内存 +0 MB syscr 8207
吞吐都在 4.5~5.5 GB/s,没有本质差别——选哪种写法不该看性能,该看谁替你把背压做对了。for await 和 pipe 都内置了背压;裸 'data' 事件意味着背压的担子全在你自己肩上,你要手动 pause()/resume(),漏了就是第三节那个下场。
# 七、三个顺手就踩的坑
# 坑 1:pipe() 不传错误,上游不会死
下游的 Writable 出错之后,pipe() 另一头的 ReadStream 是什么状态?
pipe() 版本:
下游 error: sink 炸了
上游收到错误:否,它不知道下游已经死了
src.destroyed = false,fd 数 69 → 70(泄漏 +1)
src 还在自己读文件
pipeline() 版本:
pipeline 抛出: sink 炸了
fd 数 69 → 69
src.destroyed = true
pipe() 出错时什么都不做:上游不会销毁、文件描述符不释放、错误也不会传给你——除非你给两端都挂了 error 监听,否则这是一个静默的 fd 泄漏加幽灵流。生产代码里用 stream.pipeline()(或 stream.promises.pipeline),一个出错,全线拆除,错误集中抛出。
# 坑 2:destroy() 会把队列里的数据静默蒸发
往一个慢 Writable 里 write 了 2000KB,sink 只来得及消化 32KB,这时调用 destroy():
此刻队列里还压着 1972 KB
destroy() 之后 sink 总共收到 32 KB
被丢掉的 1968 KB:没有 error、没有回调、没有任何通知
destroy 是「泯恩仇」的快刀:队列里欠着的数据说丢就丢。所以别在「可能还有数据没写完」的路径上随手 destroy,要么先 end() 等 finish,要么清楚知道自己在弃车保帅。

# 坑 3:Transform 肚子里有两个缓冲
Transform 有 writable 和 readable 两侧,每侧各有自己的 hwm。让 Transform 直通(收到什么转发什么),下游是每条睡 30ms 的慢消费者,灌 5000 条 × 4KB:
写入侧队列峰值 64.0 KB
读出侧队列峰值 64.0 KB
好消息:Transform 的背压是联动好的,两侧各自卡在自己的 hwm 上,谁也不会无限涨。坏消息:很多人以为「Transform 不就是函数吗」,在这里面做重活、发网络请求,忘了它是流式契约的一部分——你 _transform 里攒的每个闭包、每次 cb() 之前不调用就压着不动的东西,都活在这两个 64KB 的刻度之外。
# 八、清单
- 对外写数据,返回值要么看,要么别自己写——用
pipe/pipeline/for await,让背压自动化。 - false 是「已踩线」的回执,不是预警;hwm 不是上限,拦不住任何数据。
- objectMode 的 16 是个数,每条多大自己心里记账。
- 给长连接加
writableLength看门狗,那本欠账明细 Node 已经替你记好了。 - 小包高频写用 cork/uncork,一次 syscall 攒 73KB 和一次攒 0.1KB 是 18 倍的差距。
- 用 pipeline 不用 pipe:前者替你拆管线、传错误,后者替你什么都不做。
- destroy 会丢队列,可能没写完的路径别乱用。
- 背压不是限流牺牲品:实测等 drain 比不理会返回值快 1.8 倍——内存不抖,GC 不喘,大家都好过。
Node 从没承诺过替你兜底。它只是很诚实地把 false 塞回你手里,然后把选择权——和 470MB 内存——一起留给你。
题图与部分配图来自开放授权的摄影作品,感谢这些作者:Water pipeline over Sakai riv — xezna;Industrial instruments pressure gauge — Palagiri;Allen Road — Floydian;Reservoir of Sei dam — Deccantrap(CC BY-SA 4.0);Akosombo Dam spilling water — ZSM;Drum gate drain valves — DeVries, David G.(公有领域)。除注明外均为 CC BY-SA 3.0,来自 Wikimedia Commons。