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. 并发写入

随机临时文件能避免临时名冲突,却不能决定两个请求谁应该覆盖最终文件。业务必须选择:

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 半成品。更稳妥的是在私有工作目录生成,完成后移动到发布目录;若跨设备只能复制,则最终发布仍要设计额外临时名。

练习题

  1. 在写入中途抛错,验证旧正式文件是否仍完整。
  2. 两个并发任务写同一 finalPath,明确预期结果。
  3. 模拟目标已存在和 Windows 文件占用。
  4. 解释原子 rename、Stream finish 和磁盘持久化的区别。