get-or-create の race は SELECT 先行ではなくユニーク制約 + ON CONFLICT DO NOTHING + 再取得で吸収する
「同じ値があれば既存を返す、無ければ作る」という 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 で確認する。