侧边栏壁纸
博主头像
一笑痕

仙人之下我无敌,
仙人之上一换一。

  • 累计撰写 67 篇文章
  • 累计收到 7 条评论

Node 流不调 finalize 会漏尾块,定时器先行也不行

2026-9-19 / 0 评论 / 3 阅读

Node 流不调 finalize 会漏尾块,定时器先行也不行

一个压缩脚本跑完不报错,输出文件却比源文件小一截,解压时在尾部报 unexpected end of file。本篇按案例拆解的完整链条走一遍,从现象到防复发,全部在本机复现(Node v26.8.1,脚本 tools/verify-stream-finalize-flush.mjs,另有对照数据留档在 tools/verify-stream-finalize-flush.log)。写这篇之前把 Node stream 文档的 pipeline 章节重读了一遍,确认结论和文档一致。

现象

用 zlib 的 Gzip 流压缩一个约 2MB 的文本文件,写法是最常见的 pipeline:

import { createGzip } from "node:zlib";
import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";

await pipeline(createReadStream(src), createGzip(), createWriteStream(dst));

这个写法本身没问题,文件完整。但另一段手写的流拼接代码,同样压缩同样的文件,产物解压到 90% 左右就断了。两段代码唯一结构性的区别:手写那段是拿 gzip.pipe(out) 拼的,收尾时直接 out.end(),没等 gzip 自己把剩余缓冲推完。

影响面

凡是「压缩完立刻转发或落盘」的路径都可能中招,而且无声:process 退出码是 0,文件 mtime 是新的,大小只差一点点。坏处在于它不会立刻暴露,是消费端解压时才炸,那时距离产出可能已经隔了几层队列。备份、日志归档这类「写完就没人再看」的场景里,能在损坏后第一时间被发现的窗口很短。

定位

对照实验只改一个变量:收尾时有没有等 gzip 流发出 end。

A pipeline(gzip 流 end 后落盘)  : 输出 2087418 字节, 解压 OK
B 手动 pipe + out.end()          : 输出 2086143 字节, 解压在尾部报错
C B 的写法 + 等 gzip 的 'end' 事件: 输出 2087418 字节, 解压 OK

A 和 C 字节数一致且解压完整,B 少 1275 字节且尾部损坏。1275 字节正好是一个 gzip 尾块加部分未刷出数据的量级。三个场景用的是同一个源文件,差异全部来自收尾方式。

根因

Gzip 流内部有缓冲,源数据读完不代表压缩数据写完:最后一段数据还在压缩器的缓冲里排队。gzip.pipe(out) 只负责把到达 gzip 可读端的数据转发出去,gzip 流的 end 事件才是「全部压缩数据已经写进 out」的信号。提前 out.end() 等于在快递员还在装车的时候把仓库大门锁了,尾块永远出不去。

规范(stream 文档)对此的说法是:调用 end() 后流不再接受写入,缓冲里未刷出的数据不保证送达。所以这不是 bug,是 API 的既定语义,问题在于手动拼接时把「源读完了」误当成「压缩完了」。

防复发

「源读完了」不等于「压缩完了」,这个误判第一次排查时花了最长时间才绕出来。收尾收敛成一行:压缩落盘一律走 pipeline,别手动 pipe。pipeline 自带背压传递和错误传播,还保证等整条链路 flush 完才 resolve。这条已经踩过一次,后续所有压缩脚本先检查收尾写法再上。

我的取舍是宁可少一层控制也不手动拼流:pipeline 把错误传播、背压、flush 时机这三件事打包处理了,手动拼接要自己全部做对才能达到同等可靠性,性价比太低。

边界

只测了纯文本、单文件、2MB 量级的情况。更大的文件、二进制内容,或者压缩中途出错的路径(源文件读一半损坏),行为会不会变没实测。另外 B 场景少掉的字节数依赖 zlib 版本和缓冲实现,别的 Node 版本上数字会不同,但「少一截且解压报错」这个定性结论应该是稳定的。

    🤞 分享