MySQLにおけるデッドロックの診断と解消:InnoDBログ分析と相互ロック防止テクニック

MySQL tutorial - IT technology blog
MySQL tutorial - IT technology blog

実際の現場:深夜の悪夢

深夜2時。ベッドサイドテーブルのスマートフォンがPagerDutyのアラートで激しく振動しました。監視画面は真っ赤に染まり、チェックアウトAPIはダウン、HTTP 500エラー率は35%に達し、データベースのコネクションプール(上限200接続)は完全に枯渇していました。

慌ててノートPCを開き、本番サーバーにSSHログインすると、アプリケーションログには見慣れた例外エラーが大量に出力されていました。

Deadlock found when trying to get lock; try restarting transaction (errno: 1213)

このエラーに初めて直面した開発者は、MySQLがクラッシュしたのか、あるいはSQLの構文エラーではないかと疑いがちです。しかし、原因はまったく異なります。デッドロックは、2つ以上のトランザクションがそれぞれ異なるリソースのロックを保持したまま、互いに相手が保持しているリソースの解放を待ち合う循環待ち(circular wait)状態に陥ることで発生します。

わかりやすく例えると、トランザクション1がレコードAを保持してレコードBを待ち、同時にトランザクション2がレコードBを保持してレコードAを待っている状態です。双方がロックを握り合って譲らないため、システムが永久に停止しないよう、InnoDBストレージエンジンが介入を余儀なくされます。そしてInnoDBは、ロールバックのコストが小さい方(undo logに基づき、変更行数が少ない方のトランザクション)を強制終了(ロールバック)します。

以前、私たちのチームでordersテーブルとwalletsテーブルのデータ件数が1,500万件に達した際、セール期間中にシステムは毎秒約1,800リクエストの負荷に直面しました。並行書き込みの負荷が急増すると、2つのスレッドがロックを取得する順序がわずか1ミリ秒ズレただけでも、データベースは瞬時に詰まって身動きが取れなくなってしまいます。

障害現場での迅速な一次診断

障害の発生中、最初に使用すべきツールはInnoDBの内部ステータス情報です。

ターミナルからMySQLサーバーにログインします。

mysql -u root -p

現在のステータスレポートを確認するため、直ちに次のコマンドを実行します。

SHOW ENGINE INNODB STATUS\G

画面をスクロールしてLATEST DETECTED DEADLOCKのセクションを探します。ここには、直近で検知されたデッドロックの詳細な情報が記録されています。

  • (1) TRANSACTION & (2) TRANSACTION: スレッドID、アクティブ時間、ロックされた行数、undo logのサイズ。
  • WAITING FOR THIS LOCK TO BE GRANTED: 待機中のSQL文と要求しているロックの種別(通常はlock_mode X waiting:排他ロック)。
  • HOLDS THE LOCK(S): 競合相手のトランザクションが現在保持しているリソース。
  • WE ROLLBACK TRANSACTION: もう一方の処理を救うためにロールバック対象として選ばれたトランザクションの最終決定。

ただし、致命的な欠点があります。SHOW ENGINE INNODB STATUSには直近の1件のデッドロックしか保存されません。システムが毎分数件から数十件のデッドロック嵐に見舞われると、新しいログが古いログを次々と上書きしてしまいます。これでは、翌朝になって調査しようとしても、根本原因の痕跡が消え去ってしまいます。

ログを確実に収集するための詳細設定

本番環境で発生した競合を見逃さないためには、すべてのデッドロックイベントをMySQLの標準エラーログファイルに出力させる必要があります。

1. ランタイムでの即時有効化(MySQLの再起動不要)

ユーザーのトラフィックを停止させることなく、本番環境で直接次のコマンドを実行します。

SET GLOBAL innodb_print_all_deadlocks = ON;

システム変数が新しい値に変更されたかを再確認します。

SHOW VARIABLES LIKE 'innodb_print_all_deadlocks';

2. my.cnfへの設定の永続化

root権限でMySQLの設定ファイルを開きます。

sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf

[mysqld]ディレクティブ内に次の2行を追加します。

[mysqld]
# すべてのデッドロック情報をエラーログ(/var/log/mysql/error.log)に出力
innodb_print_all_deadlocks = 1

# Webアプリ向けにロック待ちタイムアウトをデフォルトの50秒から5〜10秒へ短縮
innodb_lock_wait_timeout = 10

なぜinnodb_lock_wait_timeoutの短縮を推奨するのでしょうか?デフォルトのMySQLでは、トランザクションのロック待機時間が50秒に設定されています。Webアプリケーションにおいて、コネクションを50秒も保持したまま待機させると、あっという間にコネクションプールが枯渇してしまいます。これを10秒に短縮することで、クエリをフェイルファスト(fail-fast)させ、他のリクエストへ迅速にリソースを解放し、クライアント層でのリトライを可能にする明確なエラーを返せるようになります。

3. 相互ロックを防ぐトランザクション設計手法

深夜2時の障害ログを精査した結果、原因が判明しました。2つの電子ウォレット間での送金・残高移動モジュールが元凶だったのです。

ユーザー10がユーザー20へ送金する際、コードはレコード10をロックしてからレコード20をロックしていました。しかし同じタイミングで、ユーザー20がユーザー10へ送金すると、コードはレコード20を先にロックしてからレコード10をロックしようとします。この2つのプロセスが衝突し、デッドロックが発生していたのです。

この問題を根本から解決するアプローチはコード層にあります。それが「決定論的ロック順序(Deterministic Lock Ordering:リソースを固定のID順でロックする手法)」です。送金の向きがどちらであっても、常にIDの小さいレコードを先にロックし、IDの大きいレコードを後にロックするよう強制します。

処理フローを正規化したPythonコードの例:

def transfer_funds(connection, from_account_id: int, to_account_id: int, amount: float):
    if from_account_id == to_account_id:
        raise ValueError("送金元と送金先のアカウントを同一にすることはできません")

    # ロック順序の正規化:常に小さいIDを先に、大きいIDを後にロック
    first_id, second_id = sorted([from_account_id, to_account_id])

    cursor = connection.cursor()
    try:
        connection.begin()

        # 1. 循環待ちを排除するため、昇順でシーケンシャルにロック
        cursor.execute("SELECT id, balance FROM accounts WHERE id = %s FOR UPDATE", (first_id,))
        acc1 = cursor.fetchone()
        cursor.execute("SELECT id, balance FROM accounts WHERE id = %s FOR UPDATE", (second_id,))
        acc2 = cursor.fetchone()

        # 2. 送金元アカウントの残高確認
        sender_balance = acc1["balance"] if acc1["id"] == from_account_id else acc2["balance"]
        if sender_balance < amount:
            raise ValueError("残高不足のため送金処理を続行できません")

        # 3. 両アカウントの残高を更新
        cursor.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s", (amount, from_account_id))
        cursor.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", (amount, to_account_id))

        connection.commit()
    except Exception as e:
        connection.rollback()
        raise e
    finally:
        cursor.close()

トランザクションを記述する際に徹底すべき2つの鉄則:

  • トランザクションを極めて短く保つ:トランザクションのオープン中に外部API(決済ゲートウェイ、SMS/メール送信など)の呼び出しや重い計算処理を行うことは厳禁です。外部APIの呼び出しを完了させてからデータベースのトランザクションを開始し、レコードの更新を行いましょう。
  • UPDATE/DELETE文で確実にインデックスを使用する:UPDATE ... WHERE ...文がインデックスを使用しない場合、InnoDBはフルテーブルスキャンを実行せざるを得ません。その結果、すべての行および行間のギャップ(Gap Lock)にロックが適用され、デッドロックの発生リスクが跳ね上がります。

検証と継続的なモニタリング

適切なコードを書くことは第一歩にすぎません。修正パッチが実際に効果を発揮しているかを確認するための監視体制が必要です。

1. エラーログの直接確認

tailコマンドを使用して、MySQLのエラーログからデッドロック関連の行をリアルタイムにフィルタリングします。

sudo tail -f /var/log/mysql/error.log | grep -A 20 "InnoDB: transactions deadlock detected"

2. メトリクスステータスによるデッドロックカウンタの確認

Percona ServerやMariaDBをご利用の場合は、以下で迅速に照会できます。

SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';

標準のMySQL(Community Edition 5.7 / 8.0以降)を実行している場合、上記のステータス変数は利用できません。代わりにInnoDB Metricsテーブルを有効化します。

-- 未有効の場合はデッドロックメトリクスのカウンタを有効化
SET GLOBAL innodb_monitor_enable = 'lock_deadlocks';

-- サーバー起動以降の累計デッドロック発生回数を照会
SELECT name, count FROM information_schema.innodb_metrics WHERE name = 'lock_deadlocks';

3. Prometheus Alertmanagerによるアラート通知設定

ユーザーからの問い合わせで初めて障害に気づくような事態は避けなければなりません。mysqld_exporterを介して、Prometheusに以下のアラートルールを設定しましょう。

- alert: MySQLDeadlockSpike
  expr: rate(mysql_info_schema_innodb_metrics_lock_deadlocks_total[5m]) > 0
  for: 1m
  labels:
    severity: warning
  annotations:
    summary: "MySQL {{ $labels.instance }} でデッドロックが発生しました"
    description: "過去5分間にデッドロック率が0を超過しました。直ちにエラーログを確認してください。"

デッドロックは、マルチスレッドで動作するリレーショナルデータベースにおいて不可避ともいえる自然な現象です。私たちがなすべきことは「発生しないよう祈ること」ではなく、ロック順序を厳格に制御し、ロック保持時間を可能な限り短縮し、早期に検知・対処できるよう高精度なモニタリング体制を整えることです。

Share: