原子写文件:先写临时文件,再 rename
直接覆写会在断电、崩溃、并发读的那一刻留下半个文件。临时文件加 rename 借文件系统的原子性把「半个文件」变成不可能。
直接 write 一个正在被读的文件,中间有无数种方式留下半个文件:进程被杀、断电、磁盘写满、另一个进程正在读。解决办法不是加锁,而是让「半个文件」根本不可见。
rename 的原子性
同分区内的 rename 是原子的:目标路径要么指向旧内容,要么指向新内容,不存在中间态。所以把写入拆成三步:
import { writeFile, rename, unlink } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
export async function atomicWrite(path: string, data: string) {
const tmp = path + '.' + randomBytes(6).toString('hex') + '.tmp';
try {
await writeFile(tmp, data, 'utf8');
await rename(tmp, path); // 原子替换
} catch (err) {
await unlink(tmp).catch(() => {}); // 清理,别吞掉原错误
throw err;
}
}
关键约束:临时文件必须与目标同分区。跨分区 rename 会退化成复制加删除,原子性就没了。所以临时文件放在目标同目录,而不是 /tmp。
为什么不用「先写后截断」
open(path, 'w') 会立刻截断文件。此刻如果崩溃,文件已经变成 0 字节,旧内容也没了。这是最常见的自毁写法。
// 不要这样
await writeFile(path, data);
要不要 fsync
writeFile 返回只代表数据进了页缓存,没落盘。真要抗断电,还要对文件和目录各做一次 fsync:
import { open } from 'node:fs/promises';
const fh = await open(tmp, 'w');
await fh.writeFile(data);
await fh.sync(); // 数据落盘
await fh.close();
await rename(tmp, path);
const dir = await open(dirname(path), 'r');
await dir.sync(); // 目录项落盘
await dir.close();
代价是每次写入都要等磁盘。配置文件、状态快照这类写着做;日志这种高频追加的别做。
临时文件命名要带上随机数
固定名字 .tmp 会让两个并发写入互相踩。加上随机后缀,各自写各自的,最后一次 rename 胜出 —— 结果仍是完整的某一份,不会混合。
跨平台注意
Windows 上 rename 到已存在的目标会失败(EPERM/EEXIST)。Node 的 fs.rename 在 Windows 上做了兼容处理,但如果你用别的语言或库,需要「先删目标再改名」——那就丢掉了原子性,只能靠锁补回。
原子写的全部要点就一句:新内容在别处写完整,再用一次原子操作换上去。

评论
…