
AIにトランザクション境界と排他制御を相談する|整合性の詰め方
「在庫がマイナスになっているんですが」
そう言われて一覧を開くと、確かに -1 が並んでいます。ログを追っても、例外は1件も出ていません。処理はすべて成功で終わっています。
コードを読み直しても、おかしなところは見つかりません。在庫を読んで、1を引いて、書き戻す。それだけです。テストも通っています。
同時に2人が同じ商品を買ったときのことだけが、どこにも書かれていなかったのです。
この手のズレは、暇な日には出ません。セールの初日、締め日の夕方、キャンペーンのメールを配信した直後。人が集まった瞬間にだけ、静かに数字が1つ狂います。しかもエラーにならないので、気づくのは数日後、誰かが棚卸しをしたときです。
この記事では、その「同時に来たとき」を前提にした設計——トランザクションの境界と排他制御の詰め方を、AIへの相談の仕方とセットで整理します。トランザクションとは、複数の更新をひとまとめにして、全部成功か全部なかったことにするか、どちらかにする仕組みのこと。排他制御とは、同じデータを同時に触らせないための取り決めのことです。
結論:順番は3つです。①囲む——「壊れてはいけない条件」を1行で書き、それを守るために同時に確定させたい更新だけをトランザクションで囲む。外部API呼び出しやAIの推論待ちは、必ず外に出す。②守る——同時更新を、アプリの条件分岐ではなくデータベース側で止める。楽観ロック・悲観ロック・制約・1文のUPDATEから、先に方式を選ぶ。③諦める——待ちきれないとき、競合に負けたときの引き際を決める。タイムアウト、リトライの上限、呼び出し元への返し方まで書く。
覚え方は 囲む → 守る → 諦める。①がないと②の範囲が決まらず、③がないと本番でロック待ちが積み上がります。
今日ぜんぶやる必要はありません。①の「壊れてはいけない条件を1行書く」だけでも、AIに渡す相談の中身が変わります。
何が起きているのか——同時実行は、コードの見た目に出ない

打ち手の前に、なぜここが抜けやすいのかを整理します。原因の側を先に見ておくと、AIに渡す前提も書きやすくなります。
1つ目は、トランザクションの境界がコードの上でほとんど見えないこと。アノテーション1行、with ブロック1つ、フレームワークの既定の挙動。書かれていないことのほうが多いので、レビューでも話題に上がりません。境界は「決めたもの」ではなく「気づいたらそうなっていたもの」になりがちです。
2つ目は、「読んで・計算して・書く」の形が、同時実行に弱いこと。在庫を読み、1を引き、書き戻す。この3ステップの途中に別の処理が割り込むと、後から書いたほうが、先に書かれた結果を上書きします。両方とも成功し、両方ともエラーになりません。数字だけが1つ足りません。書き方が雑なのではなく、この形そのものが同時実行に弱いという話です。
3つ目は、AIに頼むと、まさにその形が返ってきやすいこと。「在庫を減らす処理を書いて」と頼めば、読みやすく、テストの通る「読んで・計算して・書く」が出てきます。AIが手を抜いているのではなく、同時に呼ばれるという前提を渡していないだけです(AIにコードを書かせる前に渡す前提と制約|伝え方の型)。
4つ目は、トランザクションの中に、待ち時間の長い処理が紛れ込むこと。外部APIの呼び出し、メール送信、ファイルの生成、そしてAIの推論待ち。AI機能は応答に数秒かかることも珍しくないので、その間ずっとデータベースの行を握り続けることになります。AIを組み込んだ機能ほど、この穴が効いてきます。
5つ目は、分離レベルが環境によって違うこと。分離レベルとは、同時に走るトランザクションから、お互いの途中経過がどこまで見えるかの設定です。たとえば MySQL(InnoDB)と PostgreSQL では既定値が違います。AIは一般論として正しい説明をしますが、あなたの環境の実際の設定までは知りません。ここは自分の環境の値を確認する場所です。
6つ目は、デッドロックが「複数の場所を見比べないと分からない」種類の問題であること。2つの処理が、AとBを違う順番でロックしに行くと、お互いの解放を待って止まります。AIは渡した範囲のコードしか見ていないので、別ファイルにある処理との順番までは揃えてくれません。ここは人がシステム全体を見て決めるところです。
裏を返すと、これは腕前の問題ではなく、前提の渡し方と、見る場所の問題です。見る場所さえ決まっていれば、AIはかなり頼れる相談相手になります。
具体例——つい、こうなりがちという形
どれも「まず動くものを」という自然な流れから生まれます。心当たりのある行があれば、そこだけ見れば十分です。
- 在庫を読んで1を引いて書き戻す:同時に2件買われると、片方の引き算が消えます。在庫がマイナスになるのも、逆に減らないのも、同じ原因です。
- 残高を確認してから出金する:確認と出金が別々に走ると、確認と出金のあいだに別の出金が入り込みます。お金が絡むぶん、影響が大きくなります。
- ポイント付与のバッチが二重に走る:夜間ジョブの再実行や、ジョブサーバの二重起動。処理自体は正しいのに、結果だけが2倍になります。
- トランザクションの中でAI APIを呼んでいる:応答を待つ数秒のあいだ、行を握り続けます。利用者が増えた日に、突然ロック待ちが積み上がります。
- 例外を握りつぶして、ロールバックされない:
catchして処理を続けた結果、途中まで書かれた状態が確定してしまう。合計と明細が合わなくなる典型です(AIにエラーハンドリング方針を相談する|握りつぶさない設計)。 - とりあえずトランザクションを広く取る:安全側に倒したつもりが、握る時間が延びて、待ち行列が伸びます。広ければ安全、ではないのがややこしいところです。
- 更新の順番が処理ごとに違う:ある処理は注文→在庫、別の処理は在庫→注文。ふだんは通りますが、混み合った瞬間にお互いを待ち合ってデッドロックになります。
- 入力画面を開いている間ずっとトランザクションを開けている:人が考えている時間は、数秒では終わりません。人の操作をまたぐトランザクションは、待ちの原因になります。
- 楽観ロックのバージョン列は足したが、負けたときの処理がない:更新件数が0だったことに気づかず、そのまま成功として返している。仕組みはあるのに、効いていない状態です。
- たまに落ちるので、リトライ回数だけ増やした:デッドロックの再試行としては妥当なこともありますが、冪等でない処理を再試行すると、今度は二重に実行されます(AIに冪等性の設計を相談する|リトライで二重実行させない型)。
影響——エラーにならないので、見つかるのが遅い
急いで全部直さないと危ない、という話ではありません。ただ、出方が静かで、出たときには説明が要る種類の問題なので、書いておきます。
- 数字が静かにズレる:在庫、残高、ポイント、集計。例外が出ないので監視にも引っかからず、人が数えて初めて分かります。
- 忙しい日にだけ出る:セール、締め日、月初。いちばん止めたくない時間帯に集中するのが、この問題のつらいところです。
- 再現しないので、調査が長引く:手元では1回ずつしか呼ばれません。「本当に同時だったのか」の確認から始まります(AIに障害の再現手順を整理させる|報告と切り分けを速く)。
- 無関係な機能まで巻き込む:1か所のロックが長いだけで、同じテーブルを触る別の画面がタイムアウトします。原因の場所と、症状の出る場所が違うので、切り分けに時間がかかります。
- 手作業のデータ修正が増える:ズレた分を人が直す運用が定着すると、直し間違いという別のリスクが生まれます。
- 「たまに落ちるテスト」として片づけられる:デッドロックはCIでも不定期に出ます。再実行で通ってしまうので、本番の予兆が埋もれます(たまに落ちるテスト(Flaky)の原因をAIに切り分けさせる手順)。
少し安心できる話をすると、この領域は打ち手がはっきりしています。作りを入れ替える必要はありません。守りたい条件を1行決めて、更新の書き方を1つ変えるところから始められます。効果も、負荷をかければその場で確かめられます。
明日からやること(囲む・守る・諦める)
上から順に、小さく始められます。今日は①だけで構いません。
1. 囲む——「壊れてはいけない条件」から、境界を決める
ここを飛ばして相談すると、AIの答えも一般論になります。逆に、ここさえ決まれば残りは詰めやすくなります。
- 壊れてはいけない条件を、1行で書く。「在庫は0を下回らない」「明細の合計と伝票の合計は必ず一致する」「同じ座席は1人にしか割り当たらない」。この1行が、境界を決める物差しになります。
- その条件を守るために、同時に確定させたい更新だけを囲む。関係のない更新まで入れると、握る時間が延びます。囲むのは、業務として1つのかたまりになっている範囲です。
- 外部への呼び出しを、必ず外に出す。外部API、メール送信、ファイル生成、AIの推論。巻き戻せないものは、トランザクションの中に置かない——これはほぼ例外なく当てはまります。
- 人の操作をまたがない。編集画面を開いている間ずっと開けておく作りは避け、保存ボタンを押してから閉じるまでを短くします。
- 境界がどこにあるか、コードの上で指させるようにする。設計書ではなく、実際のアノテーションや
withブロックを見ます。「たぶんここまで」の状態が残っていたら、そこが最初の確認場所です。
2. 守る——同時更新の止め方を、先に選ぶ
方式を選ばずに書き始めると、条件分岐が積み上がっていきます。先に1つ選ぶだけで、レビューも楽になります。
- まず、データベースの制約で守れないか考える。一意制約やチェック制約で表せる条件なら、それがいちばん確実で安く済みます。アプリ側の
ifは、同時に来ると両方が通り抜けます(AIにDB設計を相談する|前提の渡し方と鵜呑みにしない確認)。 - 「読んで・計算して・書く」を、1文の更新にできないか試す。たとえば在庫なら、
在庫 = 在庫 - 1を「在庫が1以上のときだけ」という条件つきで1回のUPDATEにし、更新件数が0なら在庫不足として扱う。読み書きの隙間そのものが無くなるので、いちばん素直です。 - 楽観ロックは、衝突がまれで、人が編集する画面に向く。バージョンを表す列を1つ持ち、更新時に「読んだときと同じバージョンなら書く」とします。更新件数が0だったときの処理を必ず書くのが条件です。書かなければ、仕組みがあっても効きません。
- 悲観ロックは、衝突が多く、処理が短いときに向く。
SELECT ... FOR UPDATEのように先に行を押さえます。押さえている間は他を待たせるので、中で重い処理をしないことが前提です。 - ロックを取る順番を、システム全体で1つに決める。「必ずID昇順」「必ず注文→在庫」など、決めて書き残します。デッドロックのかなりの部分は、順番を揃えるだけで減ります。
- 分離レベルは、自分の環境の実際の値を確認する。既定値はデータベースや設定によって違います。AIの一般論ではなく、動いている環境の設定を見るのが確実です。
- ロックする範囲を、必要な行だけに絞る。条件に合う索引がないと、想定より広い範囲が押さえられることがあります。実行計画も一度見ておくと安心です(AIに書かせたSQLの確認ポイント|N+1・インデックス漏れ)。
3. 諦める——待てないとき、負けたときの振る舞いを書く
ここが抜けると、詰まったときに全体が巻き込まれます。「うまくいかなかったとき、どうするか」まで書いて、ひとまとまりです。
- ロック待ちのタイムアウトを決める。無限に待たせない。待たせ続けるより、早く諦めて返したほうが、全体は健全です。
- デッドロックで落ちたときの再試行を決める。回数の上限と、待ち時間を少しずつ延ばす形をセットにします。ただし再試行してよいのは、二重に実行されても平気な処理だけです。
- 楽観ロックで負けたときに、利用者へ何を返すか決める。黙って上書きしない、黙って捨てない。「他の人が先に更新しました」と伝え、最新の内容を読み直せる導線を用意します。
- 再試行してはいけない処理を、はっきり分ける。外部への送信、決済、通知を含むものは、そのまま繰り返せません。ここは冪等性の設計と組みで考えます。
- 競合とデッドロックの発生件数を、ログに残す。件数が見えていれば、「効いているか」を後から確認できます。急に増えたときは、上流の変化にも気づけます(AIにログ出力の不足を指摘させる|障害調査で困らない設計)。
AIに相談するときのプロンプト(コピーして使えます)
前提を渡さないと、AIは素直に「読んで・計算して・書く」を出してきます。同時実行を前提だと明記し、禁じ手を先に伝えるのがコツです。
ある更新処理について、同時に複数のリクエストが来る前提で、トランザクションの境界と排他制御を相談させてください。
①まず、私が示す「壊れてはいけない条件」が壊れる同時実行のシナリオを、思いつく限り具体的に挙げてください(何と何が、どの順で走ったときか)。
②「読んで→計算して→書く」形の案は出さないでください。同時に来ると片方の更新が消えるためです。
③排他制御の方式を、DBの制約/1文のUPDATE/楽観ロック/悲観ロックの中から2案、それぞれ効かなくなる条件つきで挙げてください。
④トランザクションの中に入れてはいけない処理(外部API・メール送信・AI推論など)が案に混ざっていないか、自分で点検して指摘してください。
⑤複数の行やテーブルを更新する場合、ロックを取る順番を明示してください。
⑥競合して失敗したときに、呼び出し元と利用者へ何を返すかを書いてください。再試行してよいかどうかも添えてください。
⑦分離レベルに依存する部分があれば「DBと設定に依存」と明記し、断定しないでください。
なお、確実でない部分は「不明」と書いてください。埋めなくて構いません。
壊れてはいけない条件:( ) 対象の操作:( ) 使っているDBと版:( ) 想定される同時実行の多さ:( ) 巻き戻せない処理:( )
②の一文が効きます。先に一つ禁じ手を伝えるだけで、返ってくる案の筋が変わります。①は、そのままテストのシナリオになります。
出てきた案は、次の3点だけ自分の目で確かめます。(1)トランザクションの中に外部呼び出しやAI推論が残っていないか、(2)「読んで・計算して・書く」が消えているか、(3)ロックを取る順番が書かれているか。この3点は、もっともらしい説明で埋まっていても抜けていることがあります(設計レビューの見落としをAIに洗わせる|プロンプトの型)。
確認は、テストにも落としておくと安心です。同じ行を同時に2つ更新する、在庫が残り1のときに同時に2件買う、更新の順番が逆の処理を同時に走らせる——この3つが通れば、だいたいの経路は塞がっています(境界値・異常系をAIに考えさせて漏れを減らす手順)。
同時更新に強くするチェックリスト(コピーして使えます)
全部に○が要るわけではありません。気になった行だけで十分です。
① 囲む
- 壊れてはいけない条件を、1行で書けたか
- その条件を守るために同時に確定させたい更新を、洗い出したか
- トランザクションの境界を、コードの上で指させるか
- 外部API・メール送信・ファイル生成・AI推論を、境界の外に出したか
- 人の入力や画面操作を、またいでいないか
- 関係のない更新まで、囲みに入っていないか
② 守る
- データベースの制約で守れる条件がないか、先に確かめたか
- 「読んで・計算して・書く」を、条件つきの1文の更新にできないか試したか
- 排他制御の方式(制約/1文UPDATE/楽観/悲観)を、先に1つ選んだか
- 楽観ロックなら、更新件数が0だったときの処理を書いたか
- 悲観ロックなら、押さえている間に重い処理を挟んでいないか
- ロックを取る順番を、システム全体で1つに決めて書き残したか
- 分離レベルを、動いている環境の実際の設定で確認したか
- ロックの範囲が広がりすぎていないか、実行計画を見たか
③ 諦める
- ロック待ちのタイムアウトを決めたか
- デッドロック時の再試行の回数と待ち方を決めたか
- 再試行してよい処理と、してはいけない処理を分けたか
- 楽観ロックで負けたとき、利用者に伝えて読み直せるようにしたか
- 例外時に、確実にロールバックされる形になっているか
- 競合・デッドロックの件数をログに残しているか
- 同時更新のテスト(同じ行に2件・残り1に2件・逆順で2件)を書いたか
この記事のまとめ
数字のズレは、腕前の問題ではなく前提の問題です。同じ行は同時に触られるもの、更新の順番は処理ごとにバラバラになりがちなもの——そこを前提に置くと、打ち手ははっきりします。
やることは3つ。①囲む——壊れてはいけない条件を1行で書き、それを守る更新だけを囲む。巻き戻せない処理は外へ。②守る——制約・1文のUPDATE・楽観ロック・悲観ロックから先に方式を選び、ロックの順番を1つに決める。③諦める——タイムアウトと再試行の上限を決め、負けたときに何を返すかまで書く。
AIに相談するときは、「読んで・計算して・書く案は出さないで」と先に一言添える。それだけで、返ってくる設計の筋がよくなります。

マイナスの在庫を見つけたとき、まず自分の書いたコードを疑ったと思います。実際には、コードは指示どおりに動いていて、同じ行が同時に触られていただけでした。
これは、気をつけて防ぐ種類のものではありません。仕組みで受け止める種類のものです。そして仕組みは、条件を1行決めて、更新の書き方を1つ変えるところから作れます。
今日は、あなたのシステムで「壊れてはいけない条件」をひとつだけ思い浮かべて、1行書いてみる。それだけで、次に混み合う日の夕方が、少し静かになります。
よければ、こちらも
- 再送と二重実行はAIに冪等性の設計を相談する|リトライで二重実行させない型
- 競合そのものの見方はAIに非同期・並行処理を書かせる|競合とデッドロックの確認
- テーブル設計の相談はAIにDB設計を相談する|前提の渡し方と鵜呑みにしない確認
- SQLの確認はAIに書かせたSQLの確認ポイント|N+1・インデックス漏れ
- 例外の扱いはAIにエラーハンドリング方針を相談する|握りつぶさない設計
- 見落としを洗うなら設計レビューの見落としをAIに洗わせる|プロンプトの型
- 前提の渡し方はAIにコードを書かせる前に渡す前提と制約|伝え方の型
- 異常系のテストは境界値・異常系をAIに考えさせて漏れを減らす手順