1 分

CRUDアプリの競合状態:原因と実践的な対策

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

CRUDアプリの競合状態:原因と実践的な対策

CRUDアプリにおける競合状態はどんな見た目か

競合状態は、二つ以上のリクエストがほぼ同時に同じデータを更新し、最終結果がタイミングに依存する状況です。各リクエスト自体は正しく見えますが、一緒になると間違った結果になります。

単純な例:二人が同じ顧客レコードで1秒以内に「保存」を押す。片方はメールアドレスを更新し、もう片方は電話番号を更新する。両方が全レコードを送ると、後から来た書き込みが最初の変更を上書きしてしまい、変更の一方がエラーもなく消えてしまいます。

高速に動くアプリではこれがより頻繁に見られます。ユーザーが1分間により多くの操作を発生させるからです。また、フラッシュセール、月末の集計、大規模なメール配信、あるいは同じ行にリクエストが集中するような忙しい瞬間にも急増します。

ユーザーが「競合状態が起きた」と報告することは稀で、多くは症状として現れます:注文やコメントの重複、保存したはずの更新が消える(「保存したのに戻っている」)、不自然な合計(在庫がマイナスになる、カウンターが戻る)、あるいはステータスが予期せず反転する(承認されてから保留に戻る)などです。

リトライが状況を悪化させます。人はダブルクリックをしたり、応答が遅いとリロードして再送したり、別タブから同じアクションを送ったり、ネットワークの不安定さでブラウザやモバイルが再送することがあります。サーバーがすべてのリクエストを新しい書き込みとして扱えば、二重作成、二重課金、二度行うべきでないステータス変更が起きます。

競合状態が思ったより起きやすい理由

多くのCRUDアプリは単純に見えます:行を読み取り、フィールドを変え、保存する。しかし問題はタイミングをアプリが制御できない点です。データベース、ネットワーク、リトライ、バックグラウンド作業、ユーザーの振る舞いが重なります。

よくあるトリガーは二人が同じレコードを編集することです。両方が同じ「現在」の値を読み、どちらも有効な変更をし、最後に保存した方が静かに上書きしてしまう。誰も間違っていないのに、更新が失われます。

一人でも起きます。保存ボタンのダブルクリック、戻るや進むでの操作、遅い接続で再送されるフォーム。エンドポイントが冪等でなければ重複作成、二重請求、ステータスが二段階進むなどが起き得ます。

現代の利用形態はさらに重なりを増やします。同じアカウントで複数のタブやデバイスが競合更新を発生させることがあります。バックグラウンドジョブ(メール、課金、同期、クリーンアップ)がWebのリクエストと同じ行に触ることもあります。クライアント、ロードバランサ、ジョブランナーでの自動リトライが、既に成功したリクエストを繰り返すこともあります。

素早く機能を出すと、同じレコードが思ったより多くの場所から更新されることがよくあります。Koder.aiのようなチャット駆動のビルダーでアプリを早く成長させているなら、同時実行をエッジケースではなく通常の挙動として扱う価値があります。

見ておくべき典型的な衝突ポイント

競合状態は「レコードを作る」デモではあまり顕在化しません。ほとんどは二つのリクエストが同じ真実の一部にほぼ同時に触れるところで現れます。典型的なホットスポットを知っておくと、初めから安全な書き込み設計ができます。

カウンターや「次の番号」フィールド

「ただ+1するだけ」に見えるものは負荷下で壊れます:いいね数、閲覧数、合計、請求書番号、チケット番号。危険なパターンは値を読み、加算してから書き戻すことです。二つのリクエストが同じ開始値を読み取り、互いに上書きしてしまいます。

ステータス遷移

Draft -> Submitted -> Approved -> Paid のようなワークフローは一見単純ですが、衝突がよく起きます。承認と編集、キャンセルと支払いのように同時に起き得るアクションがあると問題になります。ガードがないとステップが飛ばされたり戻されたり、異なるテーブルで異なる状態が表示されたりします。

ステータス変更は契約のように扱ってください:次の有効なステップだけを許可し、それ以外は拒否します。

在庫、容量、予約枠

残席数、在庫数、予約枠、「残り容量」フィールドは典型的な過剰販売問題を生みます。二人の購入者が同時にチェックアウトして両方とも空きがあるように見え、両方が成功する。データベースが最終判断でないと、最終的に在庫以上を販売してしまいます。

一意性や「アクティブなものは一つだけ」ルール

絶対的なルールもあります:アカウントごとに一つのメール、ユーザーごとに一つのアクティブサブスクリプション、ユーザーごとに一つのオープンカートなど。最初に存在をチェックしてから挿入するパターンは並行で失敗しやすく、両方のリクエストがチェックを通ってしまいます。

Koder.aiのようにCRUDフローを素早く生成しているなら、これらのホットスポットを早めに書き出し、UIチェックだけでなく制約と安全な書き込みで裏付けてください。

UIからの二重送信とリピートリクエスト

多くの競合は実は単純な原因から始まります:同じアクションが二度送られる。ユーザーがダブルクリックする、ネットワークが遅いので再度押す、スマホが二回タップを拾う。ブラウザがPOST後に更新を促して再送させることもあります。

そのときバックエンドは並列で二つの作成や更新を実行することがあります。両方が成功すると重複や合計のズレ、ステータス変更が二度行われる(例:二度の承認)などが起きます。タイミングに依存するためランダムに見えます。

最も安全なのは多層防御です。UIを直すと同時に、UIが失敗する前提で設計してください。

多くの書き込みフローに適用できる実践的な変更:

  • 最初のクリックで送信ボタンを無効化し、はっきりした「保存中」状態を表示する。
  • POST成功後にリダイレクトして、リロードでフォームが再送されないようにする。
  • 冪等性キーを使う:ユーザーのアクションごとに一意のトークンを生成し、サーバー側で一度だけ受け入れる。
  • 二つの同一挿入が両方成功しないよう、データベースの一意制約で裏付ける。

例えば、モバイルで「請求書を支払う」を二度タップした場合、UIは二度目をブロックすべきで、サーバーは同じ冪等キーを見て二度目を拒否し、最初の成功結果を返すべきです(重複課金させない)。

ずれたステータス遷移

ステータスフィールドは単純に見える一方で、二つが同時に変更しようとすると問題になります。ユーザーが「承認」を押すと同時に自動ジョブが同じレコードを「期限切れ」にすることがあります。両方の更新が成功しても最終的なステータスはルールではなくタイミングで決まります。

ステータスを小さな状態機械として扱ってください。許可される遷移の短い表を持ち(例:Draft -> Submitted -> Approved と Submitted -> Rejected)、各書き込みで「この遷移は現在のステータスから許可されるか?」をチェックし、許可されなければ拒否して静かに上書きしないようにします。

楽観的ロックは他者の変更を検出してブロックせずに古い更新を捕まえるのに有効です。バージョン番号や updated_at を追加し、保存時に一致を要求します。他の人が行を変更していれば更新は0行になり、「この項目は変更されています。再読込してから再試行してください。」のような明確なメッセージを表示できます。

ステータス更新の単純なパターン:

  • 現在のステータスとバージョンを読む
  • 遷移を検証する
  • ガード付きで更新する(WHERE status = ? AND version = ?)
  • バージョンをインクリメントする

また、ステータス変更は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」に切り替え、監査エントリを書き込む。二番目のリクエストは待たされてから状態を再確認し止められます。

楽観ロックと悲観ロック

衝突の頻度に応じて選んでください:

  • 楽観ロック:バージョン番号(または updated_at)を追加し、一致するときだけ更新する。
  • 悲観ロック:まず行をロックしてから同じトランザクション内で読み書きする。

ロック時間は短く保ってください。ロック保持中は外部API呼び出しや遅いファイル処理、大きなループを避けてください。Koder.aiのようなツールでフローを作る場合でも、トランザクションはデータベースのステップだけに限定し、残りはコミット後に行うべきです。

実践手順:一つの書き込みフローをエンドツーエンドで強化する

ロールバック可能に安全に変更する
エッジケースが出たときにロールバックできるよう、制約を導入するときはスナップショットとロールバックで安全に展開しましょう。

金銭や信頼を失う可能性があるフローを1つ選んでください。よくある例は:注文を作り、在庫を確保し、注文ステータスを確定する流れです。

現在のコードが実際に何をしているかを順を追って書き出してください。何を読んで、何を書き、何をもって「成功」と見なすのか具体的に。衝突は読んだ後から後の書き込みまでの隙間に隠れています。

ほとんどのスタックでうまくいく強化手順:

  • 防ぐべき悪い結果を定義する(例:在庫が負になる、同一注文行に二つの予約が入る)。
  • その結果を不可能にするデータベースルールを追加する(例:在庫が < 0 を防ぐ CHECK 制約、予約に対する (order_id, sku) の UNIQUE 制約)。
  • フロー全体を一つのトランザクションに包み、結果を決定する行をロックする(例:在庫行をロックしてから数量を引く)。
  • 可能な限り単一の文で更新する(例:"update stock where stock >= qty" のように失敗安全にする)。
  • 競合をきれいに処理する:数回リトライするか、明確な「在庫切れのためリフレッシュしてください」メッセージを返す。

修正を証明するテストを1つ追加してください。同じ商品・数量に対して同時に二つのリクエストを発生させ、正確に1つだけが確定し、もう一つは制御された形で失敗する(在庫が負にならない、重複予約行がない)ことをアサートします。

Koder.aiのようにアプリを素早く生成する場合でも、このチェックリストは重要です。重要な書き込みパスに関しては実施しておく価値があります。

競合状態を残し続ける一般的なミス

最大の原因の1つはUIを信用しすぎることです。ボタンの無効化やクライアント側チェックは助けになりますが、ユーザーはダブルクリックしたり、リロードしたり、別タブで操作したり、リクエストを再生したりします。サーバーが冪等でないと重複が通ってしまいます。

別の静かなバグ:データベースエラー(一意制約違反など)をキャッチしてもワークフローを続けてしまうこと。これにより「作成は失敗したがメールは送った」や「支払い失敗なのに注文が支払い済みにマークされた」のような状態になります。副作用が起きると巻き戻しが難しいです。

長時間のトランザクションも罠です。トランザクションを開いたままメールや支払い、外部APIを呼ぶとロックを長く保持して待ちやタイムアウト、リクエストのブロッキングが増えます。

バックグラウンドジョブとユーザー操作が単一の真実源を持たないまま混在するとスプリットブレイン状態を作ります。ジョブがリトライして行を更新する一方でユーザーが編集中だと、どちらが最後の書き手か分からなくなります。

実際には「直したつもり」でも直っていないパターンがいくつか:

  • UIだけでの二重送信防止
  • エラーを飲み込んでそのまま続行する
  • トランザクション内で遅いネットワーク呼び出しを行う
  • ジョブとユーザー要求が同じ状態を別々に更新する
  • 読んでから書く方法でカウンターを更新する(原子更新にしない)

Koder.aiのようなチャットからアプリを作る場合でも、サーバーサイドの制約と明確なトランザクション境界を求めてください。単にUIを改善するだけでは不十分です。

出荷前の簡単なチェック

データベースに主導権を持たせる
一意性、チェック、そして安全な更新を強制するGoとPostgreSQLのバックエンドを作成します。

競合は本番トラフィックでしか出ないことが多いですが、出荷前に最も一般的な衝突ポイントをチェックすれば大きな問題を回避できます。

まずデータベースから始めてください。何かが一意でなくてはならない(メール、請求書番号、ユーザーごとの一つのアクティブサブスクリプションなど)なら、本物の一意制約にしてアプリ側の「まずチェックする」だけのルールにしないでください。コードがいつも制約違反を検出することを想定し、明確で安全な応答を返すようにします。

次に状態を見てください。任意のステータス変更(Draft -> Submitted -> Approved)は明示的な許可遷移集合に対して検証されるべきです。同じレコードを二つのリクエストが動かそうとしたら、二番目は拒否されるかノーオペ(no-op)になるべきで、中間状態を作るべきではありません。

実用的な出荷前チェックリスト:

  • 必須の一意ルールに対して一意制約が存在すること(複合キーを含む)。
  • 各書き込みエンドポイントがリトライに安全であること(冪等性キー、リクエストID、自然な冪等性)。
  • 読んでから書くパターンのときはマルチステップ書き込みをトランザクションに包むこと。
  • 一つの変更が勝つべき場所だけでロックを使う(例:注文ごとに一回の承認)。
  • 競合シグナル(重複キーエラー、シリアライゼーション失敗、ロックタイムアウト)をログに出し、スパイクをアラートする。

Koder.aiでフローを作る場合、これらを受け入れ基準としてください:生成されたアプリはリトライや並行性下でも安全に失敗するべきで、ハッピーパスだけを通すのではいけません。

例:二重承認を防ぐシナリオ

二人のスタッフが同じ購入要求を開き、数秒の間に両方が承認をクリックする。両方のリクエストがサーバーに届きます。

起こり得る問題はややこしいです:要求が二重に承認され、二つの通知が送られ、承認に紐づく合計(予算使用、日次承認カウントなど)が2増える。各更新は単独では有効でも、衝突します。

PostgreSQLスタイルのデータベースでうまく働く修正プランを示します。

1) データベースで二重承認を不可能にする

承認を別テーブルに保存し、request_id に対して一意制約を設けるなどのルールを追加します。こうすればアプリコードにバグがあっても二回目の挿入は失敗します。

2) 状態変更をトランザクションに包む

承認処理は次を1つのトランザクションで行います:

  • 要求行をロックする(または WHERE status = 'pending' のような条件付き更新を行う)。
  • まだ pending か確認する。
  • 承認レコードを書き込む。
  • 要求のステータスを approved に更新する。

二人目のスタッフが遅れて来ても、更新が0行になるか一意制約エラーが返り、どちらにせよ一つだけが勝ちます。

3) UIに明確なフィードバックを出す

修正後、最初のスタッフは Approved を見て通常の確認を受け取ります。二人目には「この要求は既に別の人によって承認されています。最新のステータスを見るために更新してください。」のような親切なメッセージを表示します。スピニングや重複通知、静かな失敗は避けられます。

Koder.ai のようなプラットフォームで CRUD フロー(Go バックエンドと PostgreSQL)の生成を行っているなら、この承認アクションのチェックを一度実装しておき、他の「一人勝ち」が必要なアクションにも再利用できます。

次のステップ:競合修正をビルドルーチンにする

競合状態は繰り返しのルーチンとして扱うと最も簡単に直せます。一度限りのバグ探しではなく、重要な書き込み経路をいくつか選んで確実に直していくことに注力してください。

まずトップの衝突ポイントに名前を付けます。多くのCRUDアプリでは同じ三つが問題になります:カウンター(いいね、在庫、残高)、ステータス変更(Draft -> Submitted -> Approved)、二重送信(ダブルクリック、リトライ、遅いネットワーク)。

堅実なルーチン例:

  • 各フローについて一つの真のルールを書く(例:注文は一度だけ、しかも pending からのみ承認できる)。
  • まずデータベース制約を入れる(ユニークキー、外部キー、チェック制約)。データベースが不可能な状態を拒否するようにする。
  • 一つの変更が勝たねばならない場合にのみトランザクションロックを使う(例:承認時に行をロック)。
  • 重要なワークフローごとに小さな同時実行テストを1つ追加して、その失敗ケース(2つの承認、2つのインクリメント、2つのフォーム送信)を再現する。
  • データベースが競合書き込みを拒否したときにUIが明確なメッセージを表示するようエラーハンドリングを見直す。

Koder.ai 上で作るなら、Planning Mode は各書き込みフローをステップとルールにマップするのに便利です。制約やロック挙動を導入する際にスナップショットとロールバックを使えると、問題が出ても素早く戻せます。

時間が経てば習慣になります:新しい書き込み機能ごとに制約、トランザクション設計、同時実行テストを付ける。そうすればCRUDアプリの競合状態が驚きではなくなります。

よくある質問

CRUDアプリにおけるレースコンディションとは何ですか?

レースコンディションは、ほぼ同時に届いた2つのリクエストが同じデータを変更し、タイミングによって結果が決まるときに発生します。正常な保存処理が別の保存処理を上書きしたり、重複を作成したり、ステータスを二重に進めたりすることがあります。

レースコンディションが起こりやすいCRUD機能はどれですか?

カウンター、在庫、予約枠、ステータス項目、重複不可のルールがあるレコードに注意してください。これらでは読み取り後に書き込むパターンが使われがちで、リクエストが重なると問題が起きます。

ユーザーによるフォームの二重送信を防ぐにはどうすればよいですか?

最初のクリック後にボタンを無効化します。ただし、サーバー側でも保護が必要です。操作ごとに1つの冪等性キーを送信して保存し、同じリクエストが繰り返された場合は2件目のレコードを作成せず、元の結果を返します。

カウンターを安全に更新するにはどうすればよいですか?

SQL文内で1を加算するなど、値を直接変更するデータベース更新を使います。カウンターをアプリに読み込み、そこで変更してから書き戻さないでください。

ステータス変更の競合を防ぐにはどうすればよいですか?

現在のステータスを確認し、有効な次の状態だけを許可するサーバー側のルールを1つにまとめます。古い状態に基づく遷移や無効な遷移は、明確なメッセージで拒否します。

楽観的ロックとは何ですか?

楽観的ロックでは、レコードとともにバージョンまたはタイムスタンプを保存します。その値がまだ一致している場合にのみ更新が成功するため、アプリは他のユーザーが先にレコードを変更したことをユーザーに伝えられます。

データベースロックはいつ使うべきですか?

在庫の確保と注文の確定のように、1つのリクエストで複数のレコードを変更する前に判断が必要な場合は、行ロックを伴うトランザクションを使います。ロック中の処理は短くし、トランザクション内で外部APIを呼び出さないでください。

レースコンディション対策でデータベース制約が重要なのはなぜですか?

アカウントごとにメールアドレスは1つ、リクエストごとに承認は1つといったルールには、一意制約を追加します。有効な参照先や値を保つために、外部キー制約とチェック制約も追加します。2つのリクエストが同時に届いても、データベースが不可能な状態を拒否します。

在庫がマイナスになるのを防ぐにはどうすればよいですか?

1つのトランザクションで、十分な在庫が残っている場合にだけ差し引くよう、在庫更新に条件を付けます。更新された行がなければ、売り過ぎた注文を確定するのではなく、商品を購入できないことを購入者に伝えます。

書き込みフローにレースコンディションがないかテストするにはどうすればよいですか?

同じレコードまたは数量に対して、同時に2つのリクエストを実行し、結果を確認します。必要な場合に一方のリクエストだけが成功すること、競合時に制御された応答が返ること、データベースに重複や無効なデータがないことをテストします。

Related posts