write() 返回 false 之后,数据去了哪:我把 Node Stream 背压的账算了一遍

有个服务,平时好好的,某个下午内存一路涨到 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,水位已经顶到线上了,而且这一块本身也已经在队列里了。

工业压力表,指针贴着量程顶端:hwm 就是那根红线,指针过线只说明「已经到了」,不代表「马上爆」

所以正确的理解不是「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

两个结论,第二个比第一个有意思:

  1. 内存差了三个数量级:+470.8MB 对 +0.5MB。队列不会替你兜底,它只是把你没处理完的数据换个地方放着——放在你的进程里。
  2. 不理会背压反而更慢: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 一下。

水坝机房里的阀门与管路:关闸(cork)攒够一池水,开闸(uncork)一次放行

# 六、读端: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,要么清楚知道自己在弃车保帅。

大坝泄洪:destroy() 就是泄洪闸,闸一开,队列里攒的水直接放掉,不会一滴一滴还给你

# 坑 3:Transform 肚子里有两个缓冲

Transform 有 writable 和 readable 两侧,每侧各有自己的 hwm。让 Transform 直通(收到什么转发什么),下游是每条睡 30ms 的慢消费者,灌 5000 条 × 4KB:

写入侧队列峰值  64.0 KB
读出侧队列峰值  64.0 KB

好消息:Transform 的背压是联动好的,两侧各自卡在自己的 hwm 上,谁也不会无限涨。坏消息:很多人以为「Transform 不就是函数吗」,在这里面做重活、发网络请求,忘了它是流式契约的一部分——你 _transform 里攒的每个闭包、每次 cb() 之前不调用就压着不动的东西,都活在这两个 64KB 的刻度之外。

# 八、清单

  1. 对外写数据,返回值要么看,要么别自己写——用 pipe/pipeline/for await,让背压自动化。
  2. false 是「已踩线」的回执,不是预警;hwm 不是上限,拦不住任何数据。
  3. objectMode 的 16 是个数,每条多大自己心里记账。
  4. 给长连接加 writableLength 看门狗,那本欠账明细 Node 已经替你记好了。
  5. 小包高频写用 cork/uncork,一次 syscall 攒 73KB 和一次攒 0.1KB 是 18 倍的差距。
  6. 用 pipeline 不用 pipe:前者替你拆管线、传错误,后者替你什么都不做。
  7. destroy 会丢队列,可能没写完的路径别乱用。
  8. 背压不是限流牺牲品:实测等 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。