ファイルの原子的書き込み:一時ファイルに書いてから 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 は互換処理をしますが、他の言語では先に削除が必要な場合があり、原子性を失ってロックで補うことになります。
原子的書き込みの要点は一行です。新しい内容を別の場所で完成させ、一度の原子的操作で入れ替える。

コメント
…