排他制御のシステム図。複数のリクエストに対し、TTL付きUPSERTを用いたデータベース更新やキャッシュ処理のフローが示されています。ON CONFLICT DO NOTHINGで処理がスキップされる様子と、TTL切れ後の再取得も描かれています。

はじめに

同じ処理を並列リクエストのうち一件だけに実行させたい、という要件はよくあります。手軽な実装として INSERT ... ON CONFLICT DO NOTHING を使い、行を作れたリクエストだけを「勝者」にする排他制御は広く使われています。

ただこの設計には見落としがちな前提があります。それは「一度きりのイベント」にしか通用しないという点です。手前にTTL付きキャッシュを置いて重複排除しているようなシステムだと、キャッシュが切れた後にもう一度後段の処理をやり直したい場面が出てきます。ところがDBの行は消えないままなので、DO NOTHING は未来永劫「敗者」を返し続けてしまいます。

この記事では、実運用しているイベント処理システムで実際に踏んだこの落とし穴と、TTLを条件にした条件付きUPSERTでどう解決したかを、汎用化したコード例とともに整理します。

こんな人におすすめ

  • 複数のリクエストやワーカーから、同じキーに対する処理を一件だけに絞り込みたい人
  • INSERT ... ON CONFLICT DO NOTHING を使って排他制御・重複排除を実装したことがある人
  • TTL付きキャッシュと永続化されたDBの状態を組み合わせて設計している人
  • SQLiteやCloudflare D1のような環境で、追加のロックなしに排他制御を実現したい人
  • マイグレーションとデプロイのタイミングがずれる問題に頭を悩ませたことがある人

ON CONFLICT DO NOTHINGが「一度きり」にしか効かない理由

典型的な実装はこうなります。

const result = await db
  .prepare(
    `INSERT INTO event_claims
      (fingerprint, source, level, message, first_seen, last_seen, count)
     VALUES (?, ?, ?, ?, ?, ?, 1)
     ON CONFLICT(fingerprint) DO NOTHING`,
  )
  .bind(fingerprint, source, level, message, timestamp, timestamp)
  .run();

return result.meta.changes === 1;

行が既に存在すれば必ず changes === 0 になり、以後そのキーへのリクエストは行が生きている限りずっと敗者として扱われます。単発のイベントを間引きたいだけならこれで十分ですが、手前のTTLキャッシュが切れて「もう一度後段の処理をやり直してよい」タイミングが来ても、DBの行は何も変わらないため再評価が起きません。負荷テストでは気づきにくく、キャッシュ切れ後に同じ事象が再発して初めて症状が表面化する、静かなバグになりがちです。

TTLを条件にしたUPSERTで再取得を実装する

修正の方向性は、単発INSERTを条件付きUPSERTに置き換えることです。行が無ければ従来どおりINSERTで勝者になります。行があっても、記録されている「最後に勝者になった時刻」がNULLか、TTLより古ければ DO UPDATE ... WHERE で権利を取り直せるようにします。

const now = params.now ?? Date.now();
const CLAIM_TTL_MS = CACHE_TTL_SECONDS * 1000; // キャッシュのTTLと揃える

const result = await db
  .prepare(
    `INSERT INTO event_claims
      (fingerprint, source, level, message, first_seen, last_seen, count, claimed_at)
     VALUES (?, ?, ?, ?, ?, ?, 1, ?)
     ON CONFLICT(fingerprint) DO UPDATE SET
       claimed_at = excluded.claimed_at,
       last_seen = excluded.last_seen,
       count = count + 1
     WHERE claimed_at IS NULL OR claimed_at < ?`,
  )
  .bind(fingerprint, source, level, message, timestamp, timestamp, now, now - CLAIM_TTL_MS)
  .run();

return result.meta.changes === 1;

既存行は claimed_at がNULLかTTLより古いときだけ WHERE を通過して勝者になり、それ以外は行を変更せず敗者として返ります。マイグレーション前や別経路で作られた行は新しい列がNULLになるため、「NULL=まだ一度も新しいロジックで扱われていない=取り直してよい」とみなすことで、既存データを一括更新しなくても新旧の行を同じ条件式で扱えます。

なお、再取得で更新してよいフィールドと、してはいけないフィールドは明確に分けておく必要があります。所有権と鮮度を示す値(タイムスタンプ、件数)は更新する一方、後段の非同期処理が書き込む値(分類結果や外部連携のIDなど)は取り直し時点では触れない設計にしています。勝者になった後の処理がその値を上書きすることを前提にしているためです。

どちらの時計を使うか、なぜそれが重要か

TTL判定には自分のプロセスの時計、つまり Date.now() を使い、送信側から渡されたタイムスタンプは使わないようにします。送信側の時刻がずれている、あるいは細工されている場合に、権利の取り直しタイミングが操作されてしまうのを防ぐためです。

ただしテストのしやすさは犠牲にしたくありません。呼び出し側からテスト用に時刻を注入できる引数(now?: number)だけは残しておくと、実運用では Date.now() を使いつつ、テストでは任意の時刻を指定して境界条件を検証できます。

この設計はSQLiteやCloudflare D1のように「1行に対する文は直列実行される」性質を、追加のロックなしの排他制御として利用しています。並列に届いたリクエストのうち最初に書き込んだものが条件を満たさなくする側に回るため、残りは changes === 0 で自然に敗者になります。トランザクションを別途組まなくても「ちょうど1件だけ勝者」が保証されます。

マイグレーション未適用のDBに対するフォールバック

NULL可・デフォルト値なしの列を追加するマイグレーションは後方互換を保ちやすい一方、新コードが先にデプロイされて旧スキーマに当たる瞬間は現実に起こり得ます。CIがマイグレーション適用後にデプロイする構成でも、数秒から数分のズレは十分あり得ます。

try {
  // 上記の条件付きUPSERTを実行
} catch (err) {
  const reason = err instanceof Error ? err.message : String(err);
  if (reason.includes('claimed_at')) {
    // マイグレーション未適用: 旧来のDO NOTHINGで判定し直す
    return legacyTryClaim(db, params);
  }
  throw err;
}

新しい列が存在しない前提のクエリで例外になった場合は旧ロジックに委譲することで、全リクエストが勝者扱いになってしまう事故(下流での重複処理)を避けられます。

つまづきやすいポイント

  • 境界条件を < にするか <= にするかで、TTLちょうどの瞬間の挙動が変わります。意図を決めた上でテストに落とし込む必要があります。
  • NULL行を条件式から除外し忘れると、マイグレーション直後の既存行がいつまでも取り直せなくなります。
  • 再取得時に更新してよい値と後段処理が書き込む値を混同すると、非同期処理の結果を取り直し処理が上書きしてしまいます。
  • DO NOTHING に戻す、WHERE 句を外す、NULL行を除外する、< を <= にする、送信側の時刻を使うようにする、といった「バグを意図的に再現するパッチ」を一つずつ当ててテストが検知できるか確認する手動のミューテーションテストは、専用ツールなしでも並行処理まわりの修正に対するテストの十分性を安く検証できる方法として有効でした。

まとめ

DB制約と DO NOTHING によるリーダー選出は、一度きりのイベントにしか使えません。TTLキャッシュのように周期的にリセットされる状態と組み合わせる場合は、単純な DO NOTHING ではなく条件付き DO UPDATE ... WHERE にする必要があります。

SQLite系DBの「1行に対する文は直列実行される」性質を使えば、追加のロックなしで排他制御を実現できます。TTL判定はサーバー側の時計を使い、テスト用の時刻注入だけ残しておくと安全性とテスト容易性を両立できます。マイグレーションとデプロイのタイミングがずれる可能性を前提に、旧スキーマへのフォールバックも忘れずに用意しておくとよいでしょう。