15 安全与原子文件写入
1. 直接覆盖的风险
await writeFile(finalPath, content, "utf8");
默认会截断旧文件。程序在写到一半时崩溃,最终路径可能只剩半份内容。对用户可见结果,推荐先写同目录临时文件:
report.html.tmp-<random>
→ 完整写入并关闭
→ rename
report.html
2. 基本实现
import { randomUUID } from "node:crypto";
import { rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
export async function writeTextAtomically(
finalPath: string,
content: string,
): Promise<void> {
const temporaryPath = path.join(
path.dirname(finalPath),
`.${path.basename(finalPath)}.${randomUUID()}.tmp`,
);
try {
await writeFile(temporaryPath, content, {
encoding: "utf8",
flag: "wx",
});
await rename(temporaryPath, finalPath);
} catch (error: unknown) {
await rm(temporaryPath, { force: true }).catch(() => undefined);
throw error;
}
}
临时文件要和最终文件位于同一文件系统,才能避免 EXDEV,也更接近原子替换。
3. “原子”有边界
rename 通常保证观察者不会看到半个目录项替换,但不同操作系统对“目标已存在”的替换行为、文件占用和持久性细节不同。Windows 上目标被占用时可能失败。必须在目标部署平台测试。
原子路径替换也不等于断电后绝对持久化。关键数据可能需要:
写临时文件
→ FileHandle.sync()
→ 关闭
→ rename
→ 必要时同步父目录(平台语义复杂)
普通可重新生成的 HTML/ZIP 通常不需要如此昂贵,但配置和不可重建数据要评估。
4. 并发写入
随机临时文件能避免临时名冲突,却不能决定两个请求谁应该覆盖最终文件。业务必须选择:
- 同一 taskId 只允许一个 Worker;
- 最终路径包含唯一 taskId;
- 目标存在就用
wx拒绝; - 使用数据库状态或分布式锁协调。
5. 大文件使用 Pipeline
const tempPath = `${finalPath}.${randomUUID()}.tmp`;
try {
await pipeline(
createReadStream(sourcePath),
createWriteStream(tempPath, { flags: "wx" }),
);
await rename(tempPath, finalPath);
} catch (error: unknown) {
await rm(tempPath, { force: true }).catch(() => undefined);
throw error;
}
只有 Pipeline 成功后才能 rename。
6. 不要暴露临时文件
Nginx 静态目录、用户下载目录中不应出现可被猜到的 .tmp 半成品。更稳妥的是在私有工作目录生成,完成后移动到发布目录;若跨设备只能复制,则最终发布仍要设计额外临时名。
练习题
- 在写入中途抛错,验证旧正式文件是否仍完整。
- 两个并发任务写同一 finalPath,明确预期结果。
- 模拟目标已存在和 Windows 文件占用。
- 解释原子 rename、Stream finish 和磁盘持久化的区别。