CRUDアプリの競合状態は重複注文や誤った合計を招きます。制約、ロック、UXガードで衝突点を防ぐ方法を解説します。

競合状態は、二つ以上のリクエストがほぼ同時に同じデータを更新し、最終結果がタイミングに依存する状況です。各リクエスト自体は正しく見えますが、一緒になると間違った結果になります。
単純な例:二人が同じ顧客レコードで1秒以内に「保存」を押す。片方はメールアドレスを更新し、もう片方は電話番号を更新する。両方が全レコードを送ると、後から来た書き込みが最初の変更を上書きしてしまい、変更の一方がエラーもなく消えてしまいます。
高速に動くアプリではこれがより頻繁に見られます。ユーザーが1分間により多くの操作を発生させるからです。また、フラッシュセール、月末の集計、大規模なメール配信、あるいは同じ行にリクエストが集中するような忙しい瞬間にも急増します。
ユーザーが「競合状態が起きた」と報告することは稀で、多くは症状として現れます:注文やコメントの重複、保存したはずの更新が消える(「保存したのに戻っている」)、不自然な合計(在庫がマイナスになる、カウンターが戻る)、あるいはステータスが予期せず反転する(承認されてから保留に戻る)などです。
リトライが状況を悪化させます。人はダブルクリックをしたり、応答が遅いとリロードして再送したり、別タブから同じアクションを送ったり、ネットワークの不安定さでブラウザやモバイルが再送することがあります。サーバーがすべてのリクエストを新しい書き込みとして扱えば、二重作成、二重課金、二度行うべきでないステータス変更が起きます。
多くのCRUDアプリは単純に見えます:行を読み取り、フィールドを変え、保存する。しかし問題はタイミングをアプリが制御できない点です。データベース、ネットワーク、リトライ、バックグラウンド作業、ユーザーの振る舞いが重なります。
よくあるトリガーは二人が同じレコードを編集することです。両方が同じ「現在」の値を読み、どちらも有効な変更をし、最後に保存した方が静かに上書きしてしまう。誰も間違っていないのに、更新が失われます。
一人でも起きます。保存ボタンのダブルクリック、戻るや進むでの操作、遅い接続で再送されるフォーム。エンドポイントが冪等でなければ重複作成、二重請求、ステータスが二段階進むなどが起き得ます。
現代の利用形態はさらに重なりを増やします。同じアカウントで複数のタブやデバイスが競合更新を発生させることがあります。バックグラウンドジョブ(メール、課金、同期、クリーンアップ)がWebのリクエストと同じ行に触ることもあります。クライアント、ロードバランサ、ジョブランナーでの自動リトライが、既に成功したリクエストを繰り返すこともあります。
素早く機能を出すと、同じレコードが思ったより多くの場所から更新されることがよくあります。Koder.aiのようなチャット駆動のビルダーでアプリを早く成長させているなら、同時実行をエッジケースではなく通常の挙動として扱う価値があります。
競合状態は「レコードを作る」デモではあまり顕在化しません。ほとんどは二つのリクエストが同じ真実の一部にほぼ同時に触れるところで現れます。典型的なホットスポットを知っておくと、初めから安全な書き込み設計ができます。
「ただ+1するだけ」に見えるものは負荷下で壊れます:いいね数、閲覧数、合計、請求書番号、チケット番号。危険なパターンは値を読み、加算してから書き戻すことです。二つのリクエストが同じ開始値を読み取り、互いに上書きしてしまいます。
Draft -> Submitted -> Approved -> Paid のようなワークフローは一見単純ですが、衝突がよく起きます。承認と編集、キャンセルと支払いのように同時に起き得るアクションがあると問題になります。ガードがないとステップが飛ばされたり戻されたり、異なるテーブルで異なる状態が表示されたりします。
ステータス変更は契約のように扱ってください:次の有効なステップだけを許可し、それ以外は拒否します。
残席数、在庫数、予約枠、「残り容量」フィールドは典型的な過剰販売問題を生みます。二人の購入者が同時にチェックアウトして両方とも空きがあるように見え、両方が成功する。データベースが最終判断でないと、最終的に在庫以上を販売してしまいます。
絶対的なルールもあります:アカウントごとに一つのメール、ユーザーごとに一つのアクティブサブスクリプション、ユーザーごとに一つのオープンカートなど。最初に存在をチェックしてから挿入するパターンは並行で失敗しやすく、両方のリクエストがチェックを通ってしまいます。
Koder.aiのようにCRUDフローを素早く生成しているなら、これらのホットスポットを早めに書き出し、UIチェックだけでなく制約と安全な書き込みで裏付けてください。
多くの競合は実は単純な原因から始まります:同じアクションが二度送られる。ユーザーがダブルクリックする、ネットワークが遅いので再度押す、スマホが二回タップを拾う。ブラウザがPOST後に更新を促して再送させることもあります。
そのときバックエンドは並列で二つの作成や更新を実行することがあります。両方が成功すると重複や合計のズレ、ステータス変更が二度行われる(例:二度の承認)などが起きます。タイミングに依存するためランダムに見えます。
最も安全なのは多層防御です。UIを直すと同時に、UIが失敗する前提で設計してください。
多くの書き込みフローに適用できる実践的な変更:
例えば、モバイルで「請求書を支払う」を二度タップした場合、UIは二度目をブロックすべきで、サーバーは同じ冪等キーを見て二度目を拒否し、最初の成功結果を返すべきです(重複課金させない)。
ステータスフィールドは単純に見える一方で、二つが同時に変更しようとすると問題になります。ユーザーが「承認」を押すと同時に自動ジョブが同じレコードを「期限切れ」にすることがあります。両方の更新が成功しても最終的なステータスはルールではなくタイミングで決まります。
ステータスを小さな状態機械として扱ってください。許可される遷移の短い表を持ち(例:Draft -> Submitted -> Approved と Submitted -> Rejected)、各書き込みで「この遷移は現在のステータスから許可されるか?」をチェックし、許可されなければ拒否して静かに上書きしないようにします。
楽観的ロックは他者の変更を検出してブロックせずに古い更新を捕まえるのに有効です。バージョン番号や updated_at を追加し、保存時に一致を要求します。他の人が行を変更していれば更新は0行になり、「この項目は変更されています。再読込してから再試行してください。」のような明確なメッセージを表示できます。
ステータス更新の単純なパターン:
また、ステータス変更は1か所にまとめてください。更新が画面、バックグラウンドジョブ、Webhookに散らばっているとルールが抜け落ちます。同じ遷移チェックを一貫して強制する関数やエンドポイントの背後に置きましょう。
最も一般的なカウンターバグは無害に見えます:値を読み、+1して書き戻す。負荷がかかると二つのリクエストが同じ数値を読み取り、両方が同じ新値を書き込むのでインクリメントが失われます。テストでは通常動くため見落としやすいです。
値が単純にインクリメントやデクリメントされるだけなら、データベースに一文でやらせてください。そうすれば多くのリクエストが同時に来てもデータベースが安全に適用します。
UPDATE posts
SET like_count = like_count + 1
WHERE id = $1;
同じ考え方は在庫、閲覧数、リトライカウンター、つまり「new = old + delta」で表せるものすべてに当てはまります。
合計はしばしば派生値(order_total、account_balance、project_hours)を保存しておき、複数箇所から更新することで壊れます。可能なら合計はソース行(明細行、元帳エントリ)から計算させると、ドリフトのクラスの問題を避けられます。
速度のために合計を保持する必要があるなら、それを重要な書き込みとして扱ってください。ソース行の更新と保存された合計を同一トランザクションに入れ、同じ合計を同時に更新できるのは一つの書き手だけになるようにする(ロック、ガード付き更新、単一所有者パスなど)。不可能な値を防ぐ制約(例:在庫は0未満にできない)を入れ、定期的にバックグラウンドで再計算して不一致を検知・報告してください。
具体例:二人のユーザーが同じカートに同時に商品を追加する。各リクエストが cart_total を読み、商品価格を足して書き戻すと一方の追加が消える。カートのアイテムと合計を1つのトランザクションで更新すれば、重い同時クリックでも合計は正しいままです。
競合を減らしたければ、まずデータベースから始めてください。アプリコードはリトライしたりタイムアウトしたり二度実行されます。データベースの制約は、二つのリクエストが同時に来ても正しさを保つ最後の門です。
一意制約は「起きてはならないが起きる」重複を止めます:メールアドレス、注文番号、請求書ID、一ユーザー一アクティブサブスクリプションなど。二つのサインアップがほぼ同時でも、データベースは一行だけ受け入れ、もう一方を拒否します。
外部キーは壊れた参照を防ぎます。外部キーがないと、あるリクエストが親レコードを削除している間に別のリクエストが子レコードを作成し、孤立した行が残ることがあります。
チェック制約は値を安全な範囲に保ち、単純な状態ルールを強制します。例:quantity >= 0、rating は1から5、status は許可された集合に限定するなど。
制約違反を「サーバーエラー」として扱うのではなく期待される結果として扱ってください。一意制約や外部キー、チェック違反をキャッチして「そのメールは既に使われています」のような明確なメッセージを返し、内部情報を漏らさずにデバッグ用にログを残します。
例:レイテンシ中に二人が「注文を作成」を二度クリックした場合、(user_id, cart_id) に一意制約があれば二つの注文は作られません。1つは成功し、1つはクリーンで説明可能な拒否になります。
単一文で済まない書き込みもあります。行を読み、ルールをチェックし、ステータスを変更し、監査ログを挿入するようなケースです。二つのリクエストが同時にこれを行うと、両方がチェックを通って両方が書き込むことがあります。これが古典的な失敗パターンです。
複数ステップの書き込みは1つのデータベーストランザクションに包み、全てのステップが一緒に成功するか全て失敗するようにします。もっと重要なのはトランザクションが同じデータに同時に変更を加えられる主体を制御する場所を提供することです。
一つのアクターだけがレコードを編集できるようにするには行レベルのロックを使います。例:注文行をロックし、まだ「pending」か確認してから「approved」に切り替え、監査エントリを書き込む。二番目のリクエストは待たされてから状態を再確認し止められます。
衝突の頻度に応じて選んでください:
ロック時間は短く保ってください。ロック保持中は外部API呼び出しや遅いファイル処理、大きなループを避けてください。Koder.aiのようなツールでフローを作る場合でも、トランザクションはデータベースのステップだけに限定し、残りはコミット後に行うべきです。
金銭や信頼を失う可能性があるフローを1つ選んでください。よくある例は:注文を作り、在庫を確保し、注文ステータスを確定する流れです。
現在のコードが実際に何をしているかを順を追って書き出してください。何を読んで、何を書き、何をもって「成功」と見なすのか具体的に。衝突は読んだ後から後の書き込みまでの隙間に隠れています。
ほとんどのスタックでうまくいく強化手順:
修正を証明するテストを1つ追加してください。同じ商品・数量に対して同時に二つのリクエストを発生させ、正確に1つだけが確定し、もう一つは制御された形で失敗する(在庫が負にならない、重複予約行がない)ことをアサートします。
Koder.aiのようにアプリを素早く生成する場合でも、このチェックリストは重要です。重要な書き込みパスに関しては実施しておく価値があります。
最大の原因の1つはUIを信用しすぎることです。ボタンの無効化やクライアント側チェックは助けになりますが、ユーザーはダブルクリックしたり、リロードしたり、別タブで操作したり、リクエストを再生したりします。サーバーが冪等でないと重複が通ってしまいます。
別の静かなバグ:データベースエラー(一意制約違反など)をキャッチしてもワークフローを続けてしまうこと。これにより「作成は失敗したがメールは送った」や「支払い失敗なのに注文が支払い済みにマークされた」のような状態になります。副作用が起きると巻き戻しが難しいです。
長時間のトランザクションも罠です。トランザクションを開いたままメールや支払い、外部APIを呼ぶとロックを長く保持して待ちやタイムアウト、リクエストのブロッキングが増えます。
バックグラウンドジョブとユーザー操作が単一の真実源を持たないまま混在するとスプリットブレイン状態を作ります。ジョブがリトライして行を更新する一方でユーザーが編集中だと、どちらが最後の書き手か分からなくなります。
実際には「直したつもり」でも直っていないパターンがいくつか:
Koder.aiのようなチャットからアプリを作る場合でも、サーバーサイドの制約と明確なトランザクション境界を求めてください。単にUIを改善するだけでは不十分です。
競合は本番トラフィックでしか出ないことが多いですが、出荷前に最も一般的な衝突ポイントをチェックすれば大きな問題を回避できます。
まずデータベースから始めてください。何かが一意でなくてはならない(メール、請求書番号、ユーザーごとの一つのアクティブサブスクリプションなど)なら、本物の一意制約にしてアプリ側の「まずチェックする」だけのルールにしないでください。コードがいつも制約違反を検出することを想定し、明確で安全な応答を返すようにします。
次に状態を見てください。任意のステータス変更(Draft -> Submitted -> Approved)は明示的な許可遷移集合に対して検証されるべきです。同じレコードを二つのリクエストが動かそうとしたら、二番目は拒否されるかノーオペ(no-op)になるべきで、中間状態を作るべきではありません。
実用的な出荷前チェックリスト:
Koder.aiでフローを作る場合、これらを受け入れ基準としてください:生成されたアプリはリトライや並行性下でも安全に失敗するべきで、ハッピーパスだけを通すのではいけません。
二人のスタッフが同じ購入要求を開き、数秒の間に両方が承認をクリックする。両方のリクエストがサーバーに届きます。
起こり得る問題はややこしいです:要求が二重に承認され、二つの通知が送られ、承認に紐づく合計(予算使用、日次承認カウントなど)が2増える。各更新は単独では有効でも、衝突します。
PostgreSQLスタイルのデータベースでうまく働く修正プランを示します。
承認を別テーブルに保存し、request_id に対して一意制約を設けるなどのルールを追加します。こうすればアプリコードにバグがあっても二回目の挿入は失敗します。
承認処理は次を1つのトランザクションで行います:
二人目のスタッフが遅れて来ても、更新が0行になるか一意制約エラーが返り、どちらにせよ一つだけが勝ちます。
修正後、最初のスタッフは Approved を見て通常の確認を受け取ります。二人目には「この要求は既に別の人によって承認されています。最新のステータスを見るために更新してください。」のような親切なメッセージを表示します。スピニングや重複通知、静かな失敗は避けられます。
Koder.ai のようなプラットフォームで CRUD フロー(Go バックエンドと PostgreSQL)の生成を行っているなら、この承認アクションのチェックを一度実装しておき、他の「一人勝ち」が必要なアクションにも再利用できます。
競合状態は繰り返しのルーチンとして扱うと最も簡単に直せます。一度限りのバグ探しではなく、重要な書き込み経路をいくつか選んで確実に直していくことに注力してください。
まずトップの衝突ポイントに名前を付けます。多くのCRUDアプリでは同じ三つが問題になります:カウンター(いいね、在庫、残高)、ステータス変更(Draft -> Submitted -> Approved)、二重送信(ダブルクリック、リトライ、遅いネットワーク)。
堅実なルーチン例:
Koder.ai 上で作るなら、Planning Mode は各書き込みフローをステップとルールにマップするのに便利です。制約やロック挙動を導入する際にスナップショットとロールバックを使えると、問題が出ても素早く戻せます。
時間が経てば習慣になります:新しい書き込み機能ごとに制約、トランザクション設計、同時実行テストを付ける。そうすればCRUDアプリの競合状態が驚きではなくなります。