
把一个旧状态文件改写成短 JSON,读取时却发现新内容后面还跟着旧字节,这种结果乍看像是写入没完成。原因可能不在写入是否成功,而在文件打开时有没有截断旧内容。Node.js 用不同标志打开同一个文件,短内容写完后会留下不同结果。
文件变短,不代表文件长度变短
实验先把临时文件写成 "previous-long-value",然后用 open(path, 'r+') 得到 FileHandle,在当前位置调用 writeFile('"new"')。新字符串覆盖了文件开头,但这次写入不会替调用方截掉后半段。读取结果是 "new"ious-long-value",字节数仍是 21。
原因在文件操作的边界:r+ 允许读写,不会因打开文件而清空内容;写入只改动实际写到的位置和长度。短数据覆盖长数据时,文件末尾超过新数据的位置仍然存在。若服务端把 JSON 配置、令牌快照或任务状态直接落到固定路径,下一次读取可能拿到拼接后的无效内容。这里说的是一种可复现风险,不是某个真实事故。
三种打开方式留下三种文件
脚本 tools/verify-backend-node-stdio-backpressure.mjs 每次在系统临时目录创建文件,依次重置初始长文本,再分别用 r+、w、a 写入短内容。下面是 Node v26.8.1 本机运行的原始输出,完整日志保存在同名 .log 文件。
r+ bytes=21 content="\"new\"ious-long-value\""
w bytes=5 content="\"new\""
a bytes=26 content="\"previous-long-value\"\"new\""
r+ 把前五个字节替换掉,尾部还在;w 打开时截断旧内容,所以结果只有五个字节;a 将数据追加到末尾,得到两段文本接在一起。三种结果都符合各自的打开语义,选择哪个会决定最终文件状态。
脚本在 finally 中递归删除自己的临时目录,不触碰仓库里的其他文件。日志只记录三个具体结果,没有用固定耗时作结论。这个实验验证的是本机 Node 对这三种模式的表现,不涉及并发写入、断电持久性或文件系统缓存。
更新完整快照时,让截断成为明确动作
如果文件是完整快照,通常需要截断语义。最简单的是以 w 打开并写入:
import { writeFile } from 'node:fs/promises'
await writeFile('/tmp/state.json', JSON.stringify(state), {
encoding: 'utf8',
flag: 'w',
})
writeFile 默认也是 w,显式写出 flag 能让代码意图更清楚。若必须复用已打开的 r+ 句柄,则应在写入前调用 truncate(0),再从偏移量零开始写;也要把错误处理与句柄关闭放在同一条控制路径里。不能只看这次 write 返回成功,就推断旧文件尾部已消失。
如果数据文件不能接受写到一半留下不完整内容,单纯 w 仍有风险:打开时旧文件会先被截掉,后续写入失败就没有旧快照可读。常见的稳妥做法是写同目录临时文件,确认写入关闭后再用 rename 替换目标。这个流程还涉及文件替换行为,以及崩溃后的恢复条件;本次脚本没有验证这些性质,因此原子性或断电安全不作为实验结论。
判断标准是文件语义,不是方法名
读写固定文件前,先确定它表达的是追加日志、增量编辑,还是完整快照。日志适合追加;快照应明确截断或采用临时文件替换;需要保留原文件时,不要在 r+ 上假设写短就会清尾。覆盖某个区间与重写整个文件不是一回事。
我会避免用 r+ 写整份状态文件,因为截断不是它的默认动作,而且遗留字节可能让下游解析报错,也可能形成看似合法但内容混杂的数据。若需求是追加或局部更新,才选择相应模式,并把它对尾部的影响写进测试。