五秒钟前还能正常工作的配置文件,现在变成了一个 347 字节的损坏 JSON 片段。你的 CLI 工具无法启动。用户仅仅是因为更新时间比预期长而按下了 Ctrl-C,现在却不得不盯着一段他们并不想要的错误堆栈轨迹。写入前存在的两 KB 有效配置已荡然无存,取而代之的是数字世界里“打印了一半的收据”般的残缺物。

这种情况之所以发生,是因为 writeFile 不是原子性的。它会打开现有路径,将其截断(truncate),将数据从 Node 流式传输到内核的页缓存(page cache)中,最后关闭文件描述符。截断发生的瞬间,旧内容就已经消失了。从截断到最终 close 之间的任何时刻都是脆弱窗口。在这个窗口期内,无论是 SIGINT、停电,还是笔记本电脑盖子突然合上,都会导致文件系统留下一个被截断的烂摊子。即使 Node 报告 Promise 已解决(resolved),操作系统可能仍在内存中缓冲写入操作。便利方法掩盖了这一间隙,但并没有消除它。

解决办法不是原地写入。解决办法是将“写入”动作与“发布”动作分离。

先写入同级文件,然后进行交换

这个可靠的模式分为五个步骤。它们都不复杂,但组合在一起,可以将故障窗口从整个流式写入过程缩减到单次文件系统元数据操作。

首先,在内存中序列化整个负载。请在创建任何临时文件之前执行此操作。如果因为有人传入了循环引用对象而导致 JSON.stringify 抛出异常,你希望该异常在触碰磁盘之前就向上冒泡。

其次,将序列化后的数据写入位于目标目录下的临时文件中。使用随机名称以防止两个并发运行的任务发生冲突。将临时文件保留在同一目录下非常重要,因为 rename 仅在单个文件系统内是原子性的。如果你的临时文件位于不同的分区,操作系统会退回到“复制并删除”的序列,这会引入其自身的故障模式,且不再具有原子性。

第三,请求内核将该临时文件刷新(flush)到物理存储中。Node 的 fsync(在此通过 filehandle 上的 sync 方法暴露)会阻塞,直到缓冲区数据真正写入硬件。这很慢,但配置文件的写入频率足够低,为了数据的持久性,这点毫秒级的延迟是值得的。

第四,将临时文件重命名为原始路径。无论是在 POSIX 系统还是 Windows 上,这都是提交点(commit point)。打开原始路径的读取者要么看到完整的旧文件,要么看到完整的新文件。不存在任何时刻能让读取者打开路径并观察到写了一半的缓冲区。

第五,同步父目录。这处理了一个微妙的边缘情况。重命名会更新目录项,但目录元数据本身可能仍留在内核的页缓存中。重命名成功后如果突然断电,有时会导致文件系统处于一种新的 inode 引用从未被持久记录的状态。同步目录会将该元数据更新强制写入磁盘,从而完成整个事务。

一个具体的 Node.js 实现

以下是仅使用 Node.js 标准库实现该模式的具体做法:

import { open, rename, rm } from "node:fs/promises";
import { dirname, basename, join } from "node:path";
import { randomUUID } from "node:crypto";

export async function writeJsonAtomic(path, value) {
  const directory = dirname(path);
  const temporary = join(directory, `.${basename(path)}.${randomUUID()}.tmp`);
  const body = `${JSON.stringify(value, null, 2)}\n`;
  let handle;

  try {
    handle = await open(temporary, "wx", 0o600);
    await handle.writeFile(body, "utf8");
    await handle.sync();
    await handle.close();
    handle = undefined;

    await rename(temporary, path);

    const directoryHandle = await open(directory, "r");
    try {
      await directoryHandle.sync();
    } finally {
      await directoryHandle.close();
    }
  } catch (error) {
    if (handle) await handle.close().catch(() => {});
    await rm(temporary, { force: true }).catch(() => {});
    throw error;
  }
}

这段代码中有几个细节值得注意。

wx 标志意味着“写入,但如果文件已存在则失败”。这可以防止 UUID 冲突或之前崩溃进程留下的废弃临时文件。如果有人在你的临时文件预定位置放置了一个恶意文件,你会立即收到通知,而不是覆盖掉那里的任何内容。

0o600 权限掩码创建的临时文件仅允许所有者读取和写入。配置文件通常包含密钥、API 令牌或私有仓库 URL。没有理由在准备临时文件时让系统上的其他用户窥视它。

注意对文件和目录分别进行的 sync 调用。许多开发者会跳过目录同步,因为觉得这很冗余。其实不然。Ext4、APFS 和 NTFS 处理目录更新的方式各不相同,但它们都有为了性能而批量处理元数据写入的共同习惯。如果你关心在断电后能否保证数据安全,目录同步就是最后的保险。

catch 块中的清理操作是有意为之的防御性设计。如果在打开文件句柄后发生任何异常,代码会尝试关闭句柄并删除临时文件,同时吞掉任何次生错误,以便让原始异常能够干净地传播。你不希望清理过程中的权限错误掩盖了导致失败的真正 Bug。

这种模式何时不再奏效

原子化文件替换可以防止“撕裂写入”(torn writes),但无法防止“丢失更新”(lost updates)。如果你的 CLI 有两个实例同时读取同一个配置,两者都在内存中进行编辑,随后都写入了新的临时文件并执行了重命名操作,那么第二次重命名会胜出。第一个进程并没有观察到第二个进程所做的更改。根据你的应用场景,这可能意味着一个用户在终端 A 中添加了一个设置,而另一个用户在终端 B 中删除了它,最终的文件只会反映最后一次写入的内容。

如果你的工具需要支持并发修改,那么在原子化写入之上,你需要一套协调机制。对于简单场景,使用建议性锁文件(advisory lock file)即可。在配置本身中使用版本向量(version vectors)或单调递增的版本号可以帮助检测冲突,从而让第二个写入者进行重试。但这些都会增加复杂度,而复杂度正是 Bug 滋生的地方。

这就是为什么边界感很重要。如果只是一个进程偶尔更新的单个 JSON 数据块,那么原子化文件写入是一个不错的选择。一旦你发现自己需要管理多条记录、强制执行 Schema 或担心并发修改时,你就已经超出了文件系统的处理能力。SQLite 的存在正是为了解决这个问题。它在单个本地文件中为你提供了原子事务、回滚日志以及对并发读写者的妥善处理。一个巧妙的文件协议并不等同于数据库,你不应该把维护预算浪费在假装它就是数据库这件事上。

真正的启示

下次你在 CLI 工具中使用 writeFile 时,请停下来思考一下。序列化并不是难点,持久性才是。配置文件太小,不适合流式传输;但也太重要,不能被截断。你应该将整个负载写入一个隐藏的同级文件,刷新它,通过重命名来提交它,并通知目录。无论你的用户是按下 Ctrl-C、拔掉电源,还是合上笔记本电脑盖子,当机器重新启动时,文件要么保留旧状态,要么呈现新状态。绝不会处于中间的尴尬状态。