mizulba
冪等性と競合制御(UNIQUE制約・ON CONFLICT・Webhook冪等性)
get-or-create の race は SELECT 先行ではなくユニーク制約 + ON CONFLICT DO NOTHING + 再取得で吸収する
約1か月前
「同じ値があれば既存を返す、無ければ作る」という 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 を一意制約付きテーブルに記録して担保する
約1か月前
多くの 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 制約後付けは適用前に重複を検出する
約1か月前
データが入っているテーブルに 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 で並行突破される
約1か月前
「現在の使用回数を COUNT して上限未満なら通す → 後で使用を記録する」という実装は、判定(read)と記録(write)が原子的でないため、上限境界で同時に来た複数リクエストが全て同じ COUNT を読んで全て通過し、上限を超過できる(TOCTOU)。記録を非同期(fire-and-forget)にしているとさらに窓が広がる。
判断基準・対策
- レート制限/クォータが DoS やコスト抑制の主防御なら、判定と記録を原子的にする。例: 使用回数の INSERT を同期化し
INSERT ... RETURNINGで件数を取って上限超過なら拒否、または upsert カウンタで increment-and-check を1文で行う。 - 最低でも記録を同期化(応答前に書く)して最悪の競合窓を潰す。完全な原子化が難しい場合は残存リスクを明示する。
落とし穴
- 「並行数は通常数件だから」と放置すると、攻撃者は意図的に同時バーストを送る。
- 判定のたびに重い問い合わせ(外部 API 含む)をすると、判定自体がボトルネック/コスト源になる。
検証方法
上限境界で同時並行リクエストを送り、許可された数が上限を超えないことを確認する。
失敗からの復旧は外部副作用をコミット後・冪等にし、資源は能動解放し再試行寄りで設計する
25日前
処理が途中で失敗しうる経路では、正しさ(重複を作らない)だけでなく「壊れた後にどう整合へ戻すか」を一級の設計対象にする。最適化するのは「部分失敗しても再実行・後追いで収束できること」、避けるのは「DB トランザクションの内側で非トランザクショナルな副作用を起こして失敗時に外部状態だけ残す設計」と「失敗を放置して資源(ロック・処理中状態)を握り続ける設計」。
判断基準
- 非トランザクショナルな副作用(検索インデックス更新、外部 API 連携、別サービスへの書き込み、通知)はトランザクションの内側で起こさない。内側は DB 書込のみに限定し、外部反映はコミット後の別フェーズへ出す(commit → 外部反映の順)。途中失敗で DB だけ巻き戻ると、反映済みの外部副作用が残って恒久不整合になる。
- 外部反映は冪等にし、失敗は記録して後追い再実行できる経路(outbox / 失敗レコード化)を用意する。「失敗=即終了・リトライなし」を選ぶなら、外部副作用の不整合可能性を設計として明示する。
- 排他ロックや処理中状態を取る処理は、再試行が枯渇したときに資源を能動解放する。ブローカーの配信回数などで「次に失敗したら見捨てられる」最終試行を検知し、放置せず失敗確定+ロック解放する。「次回起動時に期限切れなら解放」だけだと TTL ぶんブロックが続く。
- 一時障害か恒久障害かの分類は非対称に倒す。データ更新系では一時障害を恒久と誤判定して黙ってスキップする方が実害が大きいので、疑わしきは再試行寄りにし、明確に恒久と分かる例外だけ恒久扱いにする。
- 冪等性キーの無い登録・副作用つき処理は、付随処理の失敗を全体失敗にして利用者に再送させると重複を生む。付随処理の失敗は警告に留め、本処理は成功扱いで再送経路を絶つ。
検証
外部副作用を出した後で意図的に失敗させ、(1) DB がロールバックされても外部状態の不整合が残ること、(2) 再試行経路があれば再実行で収束し、無ければ不整合が残ること、(3) 最終試行の失敗で資源が即解放され後続が待たされないことを確認する。
根拠(synthesize 元)
- 546 非同期バルクワーカーで全件を単一トランザクションに包むと内側の非トランザクショナル副作用が不整合になる
- 559 排他ロックを持つキューワーカーはリトライ枯渇時にロックを能動解放する(配信回数を見る)
- 258 登録成功後の付随処理(ファイルアップロード等)失敗を全体失敗にせず重複登録を防ぐ
並行・再送下の正しさはアプリ層のcheck-then-actでなくDB制約と原子操作・冪等経路で担保する
25日前
同時実行や再送がある経路では、read してから write する判定(check-then-act)を信用しない。一意性・上限・一度きりの消費・冪等性は、最終防壁を DB の制約や原子的な1文・条件付き更新に置く。最適化するのは「アプリのロジックがどうであれ破綻しない不変条件」、避けるのは「判定と記録が原子的でない隙にレースで突破される設計」。
判断基準
- 「あれば既存・なければ作成」は先行 read でなく、一意制約を張ったうえで「競合時は無視(ON CONFLICT DO NOTHING 相当)」で挿入し、作成されなければ取り直す。アプリ層チェックだけでは原理的に重複を防げない。
- レート制限・クォータなど上限系は、判定と記録を原子化する(増分と判定を1文にする、または記録を同期化して応答前に書く)。記録を fire-and-forget にすると窓が広がる。
- 認可コードやトークンの一度きり消費は、未使用・未失効・期限内を条件に含めた更新を1文で行い、影響行数が1のときだけ後続を進める。読み取り後に別更新する方式はレースで再利用される。
- at-least-once な webhook など再送前提の入口は、一意な event ID を一意制約付きで記録し、影響行数で初回/既処理を原子的に判定して既処理は副作用なく早期 return する。
- 外部副作用を伴う多段 write は、最初の副作用で生成された ID をレスポンスに含め、再実行時はその ID を受けて副作用をスキップする冪等経路を用意する。
落とし穴 一意制約を後付けする場合は既存重複の解消が前提。関数インデックスに対する競合解決は式を一致させないと効かない。
検証 上限境界や同一キーで同時並行・二重送信を起こし、許可数が上限を超えない・重複行が作られない・二回目が副作用なく終わることを実 DB で固定する。mock では制約破綻を検出できない。
根拠(synthesize 元)
- 522 get-or-create の race はユニーク制約 + 競合時無視 + 再取得で吸収する
- 531 クォータ/レート制限の check-then-act は TOCTOU で並行突破される
- 525 Webhook の冪等性は event ID を一意制約付きテーブルに記録して担保する
- 405 OAuth code と refresh token の消費は条件付き更新で再利用レースを防ぐ
- 488 リフレッシュトークン運用の正しい設計と失敗パターン
- 567 分割 write は最初の副作用 ID を返して冪等 retry 経路を作る
分割writeは最初の副作用IDを返して idempotent retry 経路を作る
26日前
複数の write を順に実行するフローで、最初の write が外部副作用を作り、後続の状態更新が失敗し得る場合、通常の再実行だけに頼ると同じ副作用を二重に作るリスクがある。
判断基準
- 最初の write が成功した時点で生成された resource ID を、後続失敗時の error response や structured result に含める。
- 再実行時はその resource ID を明示的に受け取り、最初の write をスキップして後続の紐付け・状態更新だけを再実行できるようにする。
- 後続 write 側は、同じ resource ID で既に完了済みの request を受けた場合に成功扱いにする。これにより「サーバー側では成功したがクライアントには失敗に見えた」ケースも安全に回復できる。
向いている条件
完全な単一 transaction に寄せるには既存境界の再設計が大きいが、短期的に重複副作用を避けたい場合に有効。将来的に atomic endpoint を作る余地は残しつつ、まず再実行可能性と復旧情報をレスポンス契約として固定する。
避けるべき設計
- 後続 write 失敗時に単なるエラーだけを返し、作成済み resource ID を失う設計。
- 再実行時に最初の write からやり直す設計。
- 先に状態だけ完了扱いにしてから外部副作用を作る設計。外部副作用が失敗すると完了状態だけが残る。