mizulba
冪等性と競合制御(UNIQUE制約・ON CONFLICT・Webhook冪等性)
get-or-create の race は SELECT 先行ではなくユニーク制約 + ON CONFLICT DO NOTHING + 再取得で吸収する
6日前
「同じ値があれば既存を返す、無ければ作る」という get-or-create を SELECT → 無ければ INSERT で実装すると、SELECT と INSERT の間に並行リクエストが割り込み、両方が「存在しない」と判定して重複行を作る race がある。アプリ層のチェックだけでは原理的に防げない。
安全な実装パターン:
- 一意にしたい列(単一または複合)に DB の UNIQUE 制約を必ず張る。これが最終防壁で、アプリのロジックがどうであれ重複行の作成自体を不可能にする。
- INSERT を
ON CONFLICT DO NOTHING RETURNING *にする。新規なら行が返り、競合(既存あり)なら 0 行返る。 - 影響行数が 0 だった場合だけ、同じ条件で SELECT し直して既存行を返す。これで「先に他リクエストが作った行」も正しく取得できる。
この順序にすると、先行 SELECT が不要になり、競合時の挙動(既存を返す)も自然に満たせる。DO UPDATE にすれば get-or-create ではなく upsert になる。
落とし穴 — 関数インデックスへの ON CONFLICT 推論:
大文字小文字を無視した一意性などを関数ユニークインデックス(例: CREATE UNIQUE INDEX ... ON t ((lower(name))))で実現している場合、ON CONFLICT (lower(name)) DO NOTHING のように conflict target の式をインデックスの式と一致させる必要がある。列名だけ(ON CONFLICT (name))では推論が一致せずエラーになる。暗黙キャスト(varchar → text 等)は通常吸収されるが、式が違うと一致しないので、実 DB で「重複挿入時に静かにスキップされるか」を1回確認しておくと確実。
既存データへの適用順: UNIQUE 制約を後付けする場合、既にテーブルに重複行があると制約追加(マイグレーション)が失敗する。空 DB なら問題ないが、本番適用前に重複解消(統合 or 削除)が必要。マイグレーション自体に重複解消 SQL を含めるか、別手順で先に潰すかを事前に決める。
検証方法: 同じキーで Store を2回呼び、エラーにならず行数が1のままであることを統合テスト(実 DB)で固定する。mock では SQL と DB 制約の破綻を検出できないため、この種の冪等性は実 DB で確認する。
Webhook の冪等性は event ID を一意制約付きテーブルに記録して担保する
6日前
多くの webhook(決済プロバイダ等)は at-least-once 配信で、同じイベントが再送される。受信ハンドラが冪等でないと、再送のたびにレコード重複作成や権限の二重付与などの実害が出る。
設計
- プロバイダが各イベントに付与する一意な event ID を、UNIQUE 制約付きの「処理済みイベント」テーブルに記録する。
- ハンドラ冒頭で event ID の登録を試み、
INSERT ... ON CONFLICT DO NOTHINGの影響行数で「初回 or 既処理」を原子的に判定。既処理なら副作用を起こさず早期 return(2xx 相当)。 - 状態を変える複数の書き込みは1トランザクションで括る。
判断基準・落とし穴
- 「最新1件を見るから重複しても平気」は危険。重複行が残ると後続の検索や状態判定がどの行を返すか不定になる。
- 受信処理で 5xx を返すとプロバイダが延々リトライする。処理不能でも『記録済み/対象なし』として扱えるものは 2xx を返しリトライ嵐を防ぐ(重複作成は一意制約で別途防ぐ)。
- 既存テーブルに後付けで UNIQUE 制約を足す場合は、既存重複の解消が前提(別途検出が必要)。
検証方法
同一 event ID を2回送り、2回目が副作用なく早期 return すること、状態変更が1回だけ適用されることをテストする。
既存テーブルへの UNIQUE 制約後付けは適用前に重複を検出する
6日前
データが入っているテーブルに UNIQUE 制約(または UNIQUE INDEX)を後から追加するマイグレーションは、既存データに重複があると本番適用時に失敗してデプロイが止まる。ローカル/テスト DB は空なので気づきにくい。
手順
- 適用前に対象キーで重複を検出する読み取り専用クエリを本番で実行する:
SELECT key..., count(*) FROM t GROUP BY key... HAVING count(*) > 1。0件なら安全。該当があれば行(id 群)を確認して統合・削除してから適用する。 - 余剰行数の概算は
sum(cnt - 1)。
型に応じて検出条件を変える
- nullable カラムを含む UNIQUE は、多くの RDB で複数の NULL を重複扱いしない。重複検出では
WHERE col IS NOT NULLで NULL を除外する。 - 関数インデックス(例:
lower(name)の UNIQUE)は、検出も同じ式でグルーピングする(GROUP BY lower(name))。 - ソフトデリート列があるテーブルに「全行対象」の UNIQUE を付けると、削除済み行と現行行の衝突や、削除後の再作成で違反が起きうる。その場合は部分ユニークインデックス(
WHERE deleted_at IS NULL)を検討する。列の有無だけでなく、実際にソフトデリートを使っている(deleted_at を set している)かを確認する。
検証方法
空でない検証用 DB に重複を仕込み、検出クエリが拾うこと、解消後にマイグレーションが通ることを確認する。
クォータ/レート制限の check-then-act は TOCTOU で並行突破される
6日前
「現在の使用回数を COUNT して上限未満なら通す → 後で使用を記録する」という実装は、判定(read)と記録(write)が原子的でないため、上限境界で同時に来た複数リクエストが全て同じ COUNT を読んで全て通過し、上限を超過できる(TOCTOU)。記録を非同期(fire-and-forget)にしているとさらに窓が広がる。
判断基準・対策
- レート制限/クォータが DoS やコスト抑制の主防御なら、判定と記録を原子的にする。例: 使用回数の INSERT を同期化し
INSERT ... RETURNINGで件数を取って上限超過なら拒否、または upsert カウンタで increment-and-check を1文で行う。 - 最低でも記録を同期化(応答前に書く)して最悪の競合窓を潰す。完全な原子化が難しい場合は残存リスクを明示する。
落とし穴
- 「並行数は通常数件だから」と放置すると、攻撃者は意図的に同時バーストを送る。
- 判定のたびに重い問い合わせ(外部 API 含む)をすると、判定自体がボトルネック/コスト源になる。
検証方法
上限境界で同時並行リクエストを送り、許可された数が上限を超えないことを確認する。