「本番環境でのみ発生する謎のエラー」「高負荷時に頻発する Deadlock found…」
Webアプリケーション、特に複数人が同時に利用する業務システムにおいて、エンジニアを悩ませる最大の敵の一つがデッドロック(Deadlock)です。

この記事では、なぜ更新処理でデッドロックが発生するのかという基礎から、Laravelを用いた具体的な解決策、そしてPHP/MySQL全般に通じるベストプラクティスを解説します。

1. そもそもデッドロックとは何か?

デッドロック(相互排他)とは、2つ以上の処理がお互いに「相手が持っているリソース(ロック)」の解放を待ち続けてしまい、永久に処理が進まなくなる状態を指します。

わかりやすい発生イメージ

もっとも単純な例は「クロス更新」です。

[トランザクションA] [トランザクションB]
↓          ↓
ID:1をロックして更新 ID:2をロックして更新
↓          ↓
ID:2も更新したい… ID:1も更新したい…
(Bの解放待ち)    (Aの解放待ち)
↓          ↓
∞ 永遠に待ち続ける(デッドロック成立) ∞

しかし、実際の現場(特にMySQL/InnoDB)では、これほど単純なケースばかりではありません。「範囲更新(ギャップロック)」「外部キー制約」が絡むことで、直感に反して広い範囲にロックがかかり、デッドロックが誘発されるケースが多々あります。

2. Laravelにおけるデッドロック対策

Laravelのエロクエント(Eloquent)は便利ですが、裏側で発行されるSQLを意識しないと思わぬトラブルを招きます。ここでは、Laravelならではの解決策と実装パターンを紹介します。

【基本】自動リトライの実装(推奨)

もっとも手軽で効果が高い方法は、「デッドロックが起きたら自動でやり直す」ことです。LaravelのDB::transactionメソッドは、第2引数にリトライ回数を指定できます。

use Illuminate\Support\Facades\DB;

// 第2引数の '5' は、デッドロック発生時の最大リトライ回数
DB::transaction(function () use ($request) {

// 1. Aデータの更新
$user = User::find($request->user_id);
$user->update(['status' => 'active']);

// 2. 関連する日報データの更新
// ここで他者と競合しても、Laravelが自動でロールバック&再実行してくれる
DailyReport::where('user_id', $user->id)
   ->update(['checked_at' => now()]);

}, 5);
Point: これにより、ユーザーにはエラー画面を見せることなく、内部的に処理を完遂させることができます。業務システムでは必須の実装と言えます。

【応用】悲観的ロック(Pessimistic Locking)

在庫管理など「絶対に重複してはならない」シビアな処理では、読み込み時点で行をロックするlockForUpdate()を使用します。

DB::transaction(function () {
// SELECT ... FOR UPDATE が発行される
// この行はトランザクションが終わるまで他者は読み込みも更新もできない
$stock = Stock::where('product_id', 100)->lockForUpdate()->first();

if ($stock->quantity > 0) {
$stock->decrement('quantity');
}
});

注意点: ロック時間が長くなると、システム全体のレスポンスが低下します。本当に必要な箇所にのみ限定して使用してください。

3. PHP + MySQL 汎用的なベストプラクティス

フレームワークを問わず、RDBMSを利用するすべてのバックエンドエンジニアが知っておくべき「デッドロックを起こさない設計・実装」のポイントです。

① ロック順序の固定(最重要)

複数のレコードを更新する場合、「必ずIDの昇順(小さい順)に処理する」というルールを徹底することで、デッドロックの発生確率を劇的に下げることができます。

もしAさんが「1→2」の順、Bさんが「2→1」の順でロックしようとすると衝突しますが、両者が「1→2」の順であれば、Bさんは単に「1が開くのを待つ」だけで済みます。

// 悪い例:受け取った配列順に処理(順序がバラバラになる可能性)
foreach ($ids as $id) { ... }

// 良い例:必ずソートしてから処理する
sort($ids);
foreach ($ids as $id) {
// IDの小さい順にロックを獲得していくため、相互ロック(デッドロック)になりにくい
$item = Item::find($id);
$item->update([...]);
}

② インデックスを適切に貼る

MySQL(InnoDB)は、「インデックスが使われない更新クエリは、テーブル内の全レコードをロックする(場合がある)」という怖い仕様を持っています。

  • WHERE句で指定するカラムには必ずインデックスを貼る。
  • インデックスがないと、更新対象以外の行までロック(ネクストキーロック)され、無関係なユーザーを巻き込んでデッドロックが発生します。

③ トランザクションは「短く、速く」

トランザクションの中に、以下のような処理を含めてはいけません。

  • 外部APIへのHTTPリクエスト(Slack通知、決済処理など)
  • 重いファイル処理
  • メール送信

これらは通信待ちなどで時間がかかるため、その間ずっとDBのロックを握り続けることになります。これらはトランザクションの外(あるいはキュー処理)に出しましょう。

まとめ

複数人が利用する業務システムにおいて、デッドロックを「完全にゼロ」にすることは困難ですが、適切な対処で「実害」をゼロにすることは可能です。

  1. Laravelの自動リトライ機能DB::transaction(func, 5))を活用する。
  2. 更新処理はID昇順など、一貫した順序で行う。
  3. 更新条件のカラムには必ずインデックスを貼る。

これらの基本を押さえ、堅牢なデータ更新処理を実装しましょう。

この記事が役に立ったら、ぜひチームのメンバーにも共有してください。

 
※参考にされる場合は自己責任でお願いします。

この記事の運営者(IT部長)からのお知らせ

PCトラブルは解決しましたか?

もし「会社のPCが全部遅い」「Office 365のエラーが多発する」「ネットワークが不安定」といった、調べても解決しない「会社全体」のお悩みがありましたら、ぜひご相談ください。

「Windows11 高速化」といったお悩み検索で毎月1,200人以上が訪れる、
このサイトの運営者
(建設会社IT部長)が、川崎・横浜・東京城南エリアの法人様限定で「無料ITお困りごと診断」を行っています。