ファイルの原子的書き込み:一時ファイルに書いてから rename

上書きは停電・クラッシュ・並行読み取りの瞬間に半端なファイルを残します。一時ファイルと rename を使えば、ファイルシステムの原子性で「半端」を不可能にできます。

他者が読んでいるファイルへ直接書くと、半端な状態を残す道が無数にあります。プロセスの強制終了、停電、ディスク満杯、読み取りの最中。解決はロックではなく、半端なファイルを不可視にすることです。

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 は互換処理をしますが、他の言語では先に削除が必要な場合があり、原子性を失ってロックで補うことになります。

原子的書き込みの要点は一行です。新しい内容を別の場所で完成させ、一度の原子的操作で入れ替える。

← 記事一覧に戻る

コメント

…