JSONを「読んで書き換えて保存」する処理が、静かにデータを失う条件

2026.08.28 09:00
2026.08.30 00:36
JSONを「読んで書き換えて保存」する処理が、静かにデータを失う条件

SQLiteを用意するほどではない小さなローカルツールなら、JSONファイルをデータストアにすると手早く作れます。読み込み、配列を書き換え、保存するだけなので、最初の実装も簡単です。

ただし、この単純さには落とし穴があります。読み込みエラーを空データとして扱ったり、複数プロセスが同時に保存したりすると、例外を出さないまま全データが消えることがあります。実際に踏んだ条件を順番に見ていきます。

ENOENTだけを空DBとして扱う

すべての例外を握りつぶして空DBを返すと、破損JSONを空データで上書きします。

function load() {
  try {
    return JSON.parse(fs.readFileSync(DB_PATH, 'utf8'));
  } catch (e) {
    if (e.code === 'ENOENT') return { items: [] };
    throw e;
  }
}

保存前の世代を .bak として1つ残すと復旧しやすくなります。

atomic writeだけでは競合を防げない

tmpへ書いてrenameする方法は、途中でプロセスが止まった際の半端なファイルを防ぎます。しかし空DBによる論理的な上書きや、複数プロセスの後勝ち更新は防げません。

loadからsaveまでをロックで囲みます。

fs.writeFileSync(LOCK, String(process.pid), { flag: 'wx' });

短いリトライ、古いロックの回収、PIDを含む一時ファイル名が必要です。保存関数が入れ子で呼ばれるなら、深度カウンタを持つ再入可能ロックにしないと自己デッドロックします。

IDを再利用しない

配列の最大IDに1を足す方式では、最大IDの項目を削除すると同じIDが再発行されます。rootに単調増加カウンタを持たせます。

{ "last_id": 128, "items": [] }

既存データは読み込み時に最大値を補い、次回保存で永続化するlazy移行が可能です。

完了日時のような自動値は、UIではなく保存層で状態遷移を見て設定します。これによりUI、CLI、自動処理のどの経路でも同じルールが適用されます。

JSONストアは、動くところまでが早いぶん、壊れ方も静かです。atomic writeを入れただけで安心せず、読み込み失敗の扱い、loadからsaveまでのロック、IDの単調増加までを一続きの保存処理として見る必要がありました。

規模が大きくなればSQLiteなどへ移る判断も必要です。それでもJSONで十分な間は、「ENOENTだけを初回扱いにする」「複数の書き手を前提にする」の2点を最初に入れておくだけで、取り返しのつかない消え方はかなり避けられます。

今回は以上です。