この記事でできること
一覧画面に「いいね」「興味なし」「既読」のような、同じ操作を二重に記録したくないトグル系のAPIを実装するとき。DB側にUNIQUE制約を張って二重登録を防いでいても、「存在確認してからINSERTする」という素直な作りのままだと、複数タブでの同時操作や連打によって500エラーが返ることがあります。この記事は、個人開発している技術記事キュレーションサービスDevPickで実際に起きたこの不具合をもとに、トグル系APIを並行リクエストに強くするために確認したいことを整理したものです。
背景: なぜ気づきにくいか
1つのユーザーが1つのボタンを押す操作は、開発中の手動確認では基本的に直列に起きます。同じボタンをほぼ同時に2回押す、あるいは同じアカウントを別タブ・別端末で開いたまま両方で操作する、といった状況は意図的に再現しないと気づけません。UNIQUE制約自体は正しく効いていても、それが投げる例外をアプリ側で何も処理していなければ、制約が守ってくれるのはデータの整合性だけで、ユーザーに返るレスポンスは500のままになります。
確認1: 存在確認からINSERT確定までの間に、他のリクエストが割り込む余地はないか
「同じレコードが無いかSELECTで確認してからINSERTする」という実装は、確認から確定(commit)までの間に時間差があります。この間に別のリクエストが同じ確認・確定を行うと、両方とも「無かった」という結果を見たままINSERTに進み、後からcommitした側だけがUNIQUE制約違反になります。
この時間差は、確認とINSERTを1つのトランザクション内に書いていても解消しません。トランザクションの分離レベルによっては、他のトランザクションがまだcommitしていないレコードを見られないのは当然として、commit直後のレコードであっても、先に発行したSELECTの結果には反映されないことがあるためです。存在確認だけで安全性を担保しようとせず、「最終的な二重登録の防止はDB制約に任せる」という前提で設計します。
確認2: 対象の操作は冪等か。冪等でないなら、そもそもこの対策は使えない
UNIQUE制約違反を「エラーではなく成功」として扱ってよいのは、その操作が冪等な場合、つまり同じ操作を何度実行してもユーザーから見た結果が変わらない場合に限ります。「いいねする」「既読にする」のようなON/OFF系の操作は冪等ですが、「コメントを追加する」「在庫を1つ減らす」のような、実行回数そのものに意味がある操作では、UNIQUE制約違反を握りつぶすと2回目の操作が静かに無視されてしまいます。
対策を入れる前に、対象のエンドポイントが「同じリクエストを複数回投げても最終状態が変わらないか」を確認します。冪等でない操作に同じ対策を横展開してしまうと、別の不具合を生みます。
確認3: catchする例外の対象を、狙った制約違反だけに絞れているか
IntegrityErrorは、狙ったUNIQUE制約違反以外の原因でも発生します。外部キー制約違反や、NOT NULL制約違反なども同じ例外クラスで飛んでくるため、except IntegrityErrorとだけ書いて中身を無視してしまうと、本来気づくべき別のバグまで握りつぶしてしまう可能性があります。
可能であれば、DBドライバが返すエラーメッセージやコードから、対象としているUNIQUE制約に起因する違反かどうかを判定してからrollbackするようにします。判定が難しい場合でも、「このエンドポイントで想定しているINSERTは1つのテーブルへの単純な追加だけで、UNIQUE制約以外で失敗する経路が無いか」をコードレビュー時に確認しておくと安全です。
確認4: 握りつぶしたエラーを、あとから追える形で残しているか
UNIQUE制約違反をユーザーへのレスポンスとしてはエラー扱いしない場合でも、発生自体は記録しておきます。想定より頻繁に発生している場合、それは「並行アクセスがたまに起きている」以上に、フロント側の連打防止が効いていない、あるいは想定していない経路から同じリクエストが重複発行されているといった、別の問題のサインになり得るからです。ユーザーへのレスポンスと、運用上の可観測性は分けて考えます。
テストの安定性についての確認
並行リクエストをテストする際、実際にスレッドやプロセスを立てて再現しようとすると、タイミング次第で成功・失敗が変わる不安定なテストになりがちです。「別のリクエストが先に確定させていた」という状況は、実際に同時実行を起こさなくても、DB上に確定済みレコードを用意したうえで、存在確認クエリの結果だけを差し替えて作ることができます。並行処理のテストを書くときは、まず「本当にスレッドを使う必要があるか」を検討します。
まとめ
- 「存在確認してからINSERT」という2ステップは、確認から確定までの間に他のリクエストが割り込む余地がある限り、矛盾した結果を生み得る
- UNIQUE制約違反を成功として扱ってよいのは、対象の操作が冪等な場合に限る。冪等でない操作に同じ対策を横展開しない
- catchする例外は、狙った制約違反だけに絞れているかを確認する。無関係な違反まで握りつぶさないようにする
- ユーザーへのレスポンスをエラーにしない場合でも、発生自体はログに残し、運用上の可観測性を確保する
- 並行リクエストの再現テストは、実際にスレッドを立てなくても、確定済みレコードと存在確認クエリの差し替えだけで安定して書けることが多い
具体的にどういう経緯でこの対策に行き着いたかはZenn版にまとめています。