夕方のオフィスで、管理画面の在庫の表示を見ながら「どうしてこうなったんだろう」と首をかしげる保守運用の担当者

同時実行で壊れるバグの見つけ方|排他制御の漏れを探す4つの手順

「在庫、マイナス1になってるんですけど……」

ある朝、そんな連絡が届く。在庫が足りなければ注文できないはずの画面です。自分で同じ商品を何度注文してみても、ちゃんと「在庫切れ」と表示される。ログにもエラーは出ていない。「どこも壊れていないのに、なぜ?」——画面の前で手が止まる、あの感じ。保守をしていると、一度は出会う不思議です。

これは、あなたの調べ方が悪いわけではありません。一人で静かに試しているあいだは、絶対に起きない種類のバグがあるのです。2つの処理が「ほぼ同時」に同じデータを触ったときだけ壊れる——いわゆる同時実行(競合状態)のバグです。

この記事では、そんなバグを見つけるための順番を、小さく分けて一緒に整理していきます。

結論:同時実行のバグは、次の4つの順で追います。①症状が「同時実行の型」に当てはまるかを見る(たまに・混む時間・数がずれる)、②ログで「同じデータへの、ほぼ同時のリクエスト」を探す、③コードで「読んでから書く」箇所を探す、④検証環境で、わざと同時に叩いて再現する。直し方は、DBの一意制約・条件付きの更新・行ロックなど、「同時に来ても1つずつ処理される仕組み」をどこか1か所に置くのが基本です。

なお、SQLやコマンドはDBの種類・バージョン・フレームワークで挙動が変わります。ここで持ち帰ってほしいのは順番と観点です。手元の環境に置き換え、必ず検証環境で試してから使ってください。

何が起きているか:「読んでから書く」のあいだに、別の処理が割り込む

同時実行のバグの多くは、こんな流れで起きます。在庫が1個の商品に、2人のお客さんがほぼ同時に注文した場面です。

  1. 注文Aの処理が在庫を読む → 「1個ある」
  2. 注文Bの処理が在庫を読む → 「1個ある」(Aはまだ書き換えていない)
  3. 注文Aが「在庫あり」と判断して、在庫を 1 − 1 = 0 に書き換える
  4. 注文Bも「在庫あり」と判断して、在庫を減らす → 0 − 1 = −1

一つひとつの処理は、正しく書かれています。ただ、「読む」と「書く」のあいだに、別の処理が割り込める隙間があった。それだけです。

この隙間は、一人で試すときはまず開きません。処理は一瞬で終わるので、次の操作をするころには前の処理が終わっているからです。だから手元では再現しないし、混む時間帯にだけ、たまに起きる。それが、このバグが見つけにくい理由です。

よくある症状は、次のような形で現れます。

同時実行のバグを見つける4つの手順

左の場面では2本の矢印が同時に1つの箱にぶつかって箱にひびが入り、右の場面では鍵のかかった門の前で矢印が1本ずつ順番に並んで箱に入っている図
同時に来た処理が同じデータを触ると壊れる。「1つずつ順番に通す」仕組みをどこかに置くのが直し方の基本

① 症状が「同時実行の型」に当てはまるかを見る

最初に、いま追っているバグが同時実行のものかどうかを、症状から見当をつけます。次のうち2つ以上当てはまれば、疑ってみる価値があります。

最後の項目は意外と大事です。サーバーが1台・プロセスが1つのころは問題なかったのに、台数やワーカー数を増やしたあとから起き始めた——というのは、よくある流れです。

② ログで「同じデータへの、ほぼ同時のリクエスト」を探す

見当がついたら、壊れたデータ(例:在庫がマイナスになった商品)にほぼ同時に届いたリクエストがないかをログで探します。

# アクセスログから、問題の商品への注文リクエストを時刻つきで抜き出す(パス・形式は環境に合わせて)
grep "POST /order" /var/log/nginx/access.log | grep "item_id=1234" | awk '{print $4, $1, $7}'

# 同じ秒に2件以上来ていないかを数える([06/Oct/2026:10:15:03 のような時刻部分で集計)
grep "POST /order" /var/log/nginx/access.log | grep "item_id=1234" | awk '{print $4}' | sort | uniq -c | sort -rn | head

同じ秒(あるいは1秒以内)に、同じ商品への注文が2件並んでいたら、かなり有力な手がかりです。POSTの中身がアクセスログに出ない構成なら、アプリのログや注文テーブルの作成日時で代わりに並べます。

DB側からも、壊れ方を数字で確かめておきます。 重複ができている型なら、次のような集計で「本来1件のはずのものが2件ある」ことが分かります。

-- 同じ注文番号が2件以上ないかを確かめる(閲覧だけ。テーブル名・列名は環境に合わせて)
SELECT order_no, COUNT(*) AS cnt, MIN(created_at), MAX(created_at)
FROM orders
GROUP BY order_no
HAVING COUNT(*) > 1;

MIN(created_at) と MAX(created_at) の差がごくわずか(同じ秒や数百ミリ秒)なら、同時実行の線がさらに濃くなります。ログの絞り込み方はエラーログの見方|grep・tailで絞り込む手順も参考にしてください。

③ コードで「読んでから書く」箇所を探す

次に、そのデータを書き換えているコードを探し、「読む → 判断する → 書く」が別々の処理になっている箇所を見つけます。たとえば、こんな形です。

// 読んで、判断して、書く——このあいだに別の注文が割り込める
$stock = $db->query("SELECT stock FROM items WHERE id = 1234")->fetchColumn();
if ($stock >= 1) {
    $db->exec("UPDATE items SET stock = " . ($stock - 1) . " WHERE id = 1234");
    // 注文を登録する
}

探すときの目印は、次のようなものです。

grep -rn "SELECT.*stock" src/ のように、壊れたデータの列名でコードを検索すると、たどり着くのが早くなります。長いコードを追うときは長い関数・巨大ファイルの読み方も役に立ちます。

④ 検証環境で、わざと同時に叩いて再現する

怪しい箇所が見つかったら、検証環境で同時にリクエストを送って、本当に壊れるかを確かめます。本番では絶対に試さないでください。

# 検証環境で、同じ商品への注文を5件ほぼ同時に送る(URL・パラメータ・認証は環境に合わせて)
for i in 1 2 3 4 5; do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST \
    -b "session=検証用のセッション" \
    -d "item_id=1234&qty=1" https://staging.example.jp/order &
done
wait

在庫を1にしてから流し、終わったあとに在庫と注文件数を見ます。在庫が0・注文が1件なら正しい動き、在庫がマイナスだったり注文が2件以上入っていたりしたら、再現できたということです。

一度で出ないこともあります。それは「起きない」ではなく「隙間が狭い」だけのことが多いので、件数を増やしたり、何度か繰り返したりしてみてください。どうしても出にくいときは、検証環境のコードの「読む」と「書く」のあいだに一時的に sleep(1) を入れると、隙間が広がって再現しやすくなります(確かめ終わったら必ず外します)。

再現できたら、もう半分は解けています。 直したあとに同じ手順を流して壊れなくなれば、それがそのまま「直った」の確認になります(修正の効果を確かめる手順)。

直し方の基本:「1つずつ順番に通す」仕組みを1か所に置く

原因が分かったら、同時に来ても1つずつ処理される仕組みをどこかに置きます。代表的なのは次の5つです。現場のコードとDBに合うものを1つ選べば十分です。

  -- 在庫が1以上あるときだけ1減らす。更新件数が0なら「在庫切れ」として扱う
  UPDATE items SET stock = stock - 1 WHERE id = 1234 AND stock >= 1;

画面のボタンを押したら無効にする「二度押し防止」も、あると親切です。ただ、それだけではブラウザの外から来るリクエストや、別の人の同時操作は防げません。最後の砦はDB側に置く、と覚えておくと迷いません。

具体例:「在庫がマイナスになる」を4つの手順で追う

小さな通販サイトを一人で保守している担当者の、よくある流れです。

変わったのは、腕の良し悪しではありません。「一人で試しても出ないバグがある」と知って、同時に叩いて確かめたことです。月に1〜2回の手直しも、それきりなくなりました。

影響:同時実行を疑えるようになると、何が変わるか

明日やること

  1. 「たまに数がずれる」「たまに2件入る」系の未解決の問い合わせを1つ思い出す。同時実行の型に当てはまるか、①のリストで見てみます。
  2. 重複チェックのSQLを1本流してみる。注文番号・会員のメールアドレスなど、「1件しかないはず」の列で GROUP BY ... HAVING COUNT(*) > 1 を(閲覧だけで)実行します。
  3. cronのバッチに二重起動防止があるかを確かめる。crontabを開いて、長く動くバッチに flock などが付いているかを見るだけで大丈夫です。
  4. 大事なテーブルに一意制約が付いているかを確かめる。付いていなければ、メモに残して次の改修の候補にします。

今日やるのは、2の「重複チェックのSQLを1本流す」だけでも十分です。0件なら一安心、見つかったら原因へ近づく手がかりになります。

同時実行のバグを探すチェックリスト

全部を一度にそろえなくても大丈夫です。まずは「同時実行を疑ってみる」と「重複チェックのSQLを1本流す」の2つだけで、見えなかった原因にぐっと近づけます。

よければ、こちらも

明るい昼のオフィスで、検証結果が緑のチェックマークになった画面を見て、ほっとした笑顔を浮かべる保守運用の担当者
「一人で試しても出ないバグがある」と知っているだけで、調べる道がひとつ増える

「手元では再現しないのに」と首をかしげた時間は、無駄ではありません。それは、一人で静かに試すだけでは見えない場所に、ちゃんと目を向け始めたしるしです。あとは「同時に動いたら?」と一度だけ疑ってみる。それだけで、原因不明だったバグに、たどり着く道ができます。

まずは明日、「1件しかないはず」のテーブルに、重複チェックのSQLを1本だけ流してみてください。

ほかの実務ヒントは記事一覧からどうぞ。保守運用の小さな備えを、メールでも少しずつお届けしています。

関連用語