実行計画には現れないロック競合

最大1000並列のGoワーカーが、それぞれ自分のユーザーの行だけをMySQLにupsertしているのに、InnoDBはデッドロックを検出し続けた。ギャップロックの仕組み、修正案の比較、そしてあえて選んだ地味な解決策。

これまでデバッグしてきたバグの中で一番のお気に入りは——直った後にしか愛せないタイプの——データベースが約束どおりに動いているのに、こちらがそれを信じられなかったバグです。状況はこうでした。夜間のレコメンド通知ジョブ(Go製)が最大1000のgoroutineに分散し、それぞれが MySQL (InnoDB) のテーブルに「自分のユーザーの行だけ」を書き込む。ユーザーごとのスケジュール行のupsertと数件のログINSERTを短いトランザクションで実行する構成です。ワーカーもユーザーも行も別々で、設計上、重複はあり得ない。それでも負荷がかかると、ジョブはInnoDBが検出するデッドロックにつまずき続けました。犠牲者に選ばれたトランザクションはロールバックされ、誰も触っていないはずの行のロックをワーカーたちが待っている。そんなはずはない——InnoDBは行レベルロックのはずです。

「行レベルロック」という言葉を、私は中身を理解しないまま使っていたのでした。

症状を正確に

  • 各ワーカーのトランザクションは控えめなもの:自分のユーザーIDをキーにしたスケジュールテーブルへの INSERT ... ON DUPLICATE KEY UPDATE、続いてログのINSERT。
  • 低並列では問題なし。ファンアウトが増えるとデッドロックエラーが出現——InnoDBがサイクルを検出して片方をロールバック——加えて単純なロック待ちも。
  • 犠牲者をリトライすれば「動く」。これがまさに罠で、リトライは同じ抽選に再参加するだけ。ジョブは処理し、ロールバックされ、やり直すことに並列性を浪費しながら、なんとか完走していました。

直感的な容疑者(長いトランザクション、コミット漏れ、アプリ側のロック)はすべてシロ。衝突は本物で、InnoDBの内部で起きていました。

実際に何がロックされていたのか

診断の出発点は、憶測ではなくエンジン自身の報告を読むこと——SHOW ENGINE INNODB STATUS は直近に検出されたデッドロックの全容を、各トランザクションが保持していたロックと待っていたロックまで含めて出力します。

まず核心の誤解から。InnoDBは行をロックするのではなく、インデックスのエントリと、その間のギャップをロックします。user_id のユニークインデックスを、既存エントリが並ぶ数直線として想像してください:

... [517] ---gap--- [519] ---gap--- [524] ---gap--- [530] ...

この数直線上には3種類のロックが存在します:

  • レコードロック — インデックスエントリ1件。世間で「行ロック」と呼ばれているものです。
  • ギャップロック — エントリ間の空白。役割はファントムの防止:デフォルトの REPEATABLE READ 分離レベルでは、あるトランザクションが調べた範囲に対して、そのトランザクションが終わるまで誰もINSERTできません。ネクストキーロックはレコードロック+直前のギャップで、この分離レベルでステートメントが実際に取得するのはこれです。
  • インサートインテンションロック — ギャップへのINSERTを予告するシグナル。これ同士は互換ですが、その位置を覆うギャップロックがあれば待たされます。

そしてupsert。INSERT ... ON DUPLICATE KEY UPDATE は点の書き込みではありません。ユニークキーの存在を確認し、INSERTまたはUPDATEし、その結果をトランザクションの残りの間防御する必要がある。その防御こそが、キーの位置周辺のネクストキー/ギャップロックです。これを隣接するキーに対して何百も同時に走らせると、教科書どおりのサイクルが組み上がります:

ワーカーA (user 519):  (517, 524) を覆うギャップロックを保持
ワーカーB (user 524):  (519, 530) を覆うギャップロックを保持

A: Bの範囲内へのインサートインテンションが欲しい → Bを待つ
B: Aの範囲内へのインサートインテンションが欲しい → Aを待つ  → サイクル

互いに相手の持ち物を必要としている。InnoDBのデッドロック検出器は数ミリ秒でサイクルを見つけ、犠牲者を選び、エラー1213でロールバックします。「別々の行」は論理レベルでは正しく、インデックスレベルでは無意味でした。ワーカーたちは行を奪い合っていたのではなく、同じ密なユニークインデックスの重なり合う範囲を奪い合っていたのです。1000ワーカーを密な user_id 範囲に放てば、重なりは保証されます。

修正案の比較

デッドロックには「競合するロックを同時に持つ2つのトランザクション」が必要で、実効性のある修正はどれかの要素を取り除きます。今の私が手に取る順に:

  1. プロセス内で書き込みを直列化する — sync.Mutex でクリティカルセクションを一度に1ワーカーに。3行で、プロセス内のデッドロックは不可能になります。
  2. プロデューサー/コンシューマー:Nワーカー、DBライター1つ。 重い処理の並列ファンアウトは残し、結果はチャネルで単一のライターgoroutineに送ってバッチでフラッシュ(「500行ごと、または2秒ごと」)。ミューテックスと同じ単一ライター性を、ロックの中に隠すのではなくアーキテクチャとして表現でき、バッチ化のぶん安全なだけでなく速くなります。
  3. フェーズ分割+バルク書き込み。 並列処理を先に全部終わらせ、複数行の INSERT ... ON DUPLICATE KEY UPDATE 1本(チャンク化し、キーでソート——順序の異なる2本のバルクupsertはそれ自体が古典的なデッドロックです)でまとめて書く。チャンクごとに1ステートメントなのでラウンドトリップが減り、ワーカー間競合はゼロ。ジョブが将来複数コンテナになっても正しさが保たれます。
  4. INSERTとUPDATEを分離する。 ギャップロックの嵐は、upsertがキーの存在を知らないから起きます。行を先に一括作成しておけば(ジョブ開始時にバルクINSERT1回)、各ワーカーの書き込みは素の UPDATE ... WHERE user_id = ? になり、エントリ1件のレコードロックだけ。ギャップロックなし、並列性は完全温存。ただし微妙な点も:安全なのは事前作成があるからで、次のリファクタリングで誰かがupsertに「簡素化」した瞬間、バグが静かに復活します。
  5. ジョブのトランザクションを READ COMMITTED に落とす。 ギャップロックの大半が無効になります。1行の変更——そしてトランザクション内のファントム保護が消えるという意味論の変更でもあり、コネクションごとの適用ミスが起きやすく、誰にも何も教えてくれません。
選択肢デッドロック耐性スループット複雑さ複数インスタンスで生存
1 · ミューテックスプロセス内のみ十分約3行✗
2 · 単一ライターチャネルプロセス内のみより良い(バッチ化)小✗
3 · フェーズ分割バルク構造的に安全最良中程度の改修✓
4 · 事前作成+UPDATE安全(ただし繊細)並列で最良小さいが壊れやすい✓
5 · 分離レベル変更おおむね十分1行(隠れコスト)△

地味な選択と、それが持ちこたえた理由

私たちは選択肢1を選びました。ファンアウトに上限を設け、プロセス全体で共有するミューテックスでDB書き込みを直列化する——そして重要なのは、プッシュ通知の送信をクリティカルセクションの外に出したことです。ロックが守るのはメッセージングプラットフォームへのネットワーク呼び出しではなく、数ミリ秒のSQLだけ。各イテレーションの高価な部分(メッセージの組み立てと送信)は完全に並列のまま、安価な部分(SQL2文)だけが一列に並びました。

正直な判断材料はこうです。これは「朝までに終わればいい」夜間バッチで、レイテンシが問われる経路ではない。守ろうとしていた並列性はすでにマイナスサムだった——ワーカーたちは互いのギャップロックを待ち、ロールバックされたトランザクションをやり直すことに並列性を費やしていたので、実際にはSQLを一列に並べたほうが速かった。そして選択肢3〜5は、誰も必要としていない性能上限のために、動いているジョブへよりリスクの高い変更を加えることを意味しました。

ミューテックスの既知の上限は設計に明記してあります:直列化はプロセス内でしか効かないので、ジョブが複数の並列コンテナで動く日が来れば機能しなくなる。単一のスケジュールタスクにはそれで十分であり、フェーズ分割バルク書き込み(選択肢3)が後継として記録に残っています——プロセス内で規模が育つだけなら、低コストな中間段階として選択肢2があります。

このバグから持ち帰ったもの

  • 「行レベルロック」はインデックスレベルロックである。 問うべきは「このステートメントはどの行を変えるか」ではなく「どのインデックスを歩き、その歩みが何の範囲をロックするか」。upsertとINSERTは変装した範囲操作です。
  • エンジンは何をロックしたか教えてくれる。 SHOW ENGINE INNODB STATUS は検出したデッドロックのインデックス、ロックモード、待ちの内容を名指しします。performance_schema.data_locks は現在進行形の様子を見せてくれます。そこでの20分は、データベースの上で理論を捏ねる1日に勝ります。
  • ギャップロックは設計どおりに動く機能である。 REPEATABLE READ は、あなたが依存していると自覚していない一貫性を守っています。それを切るのは本物の意思決定であって、チューニングのつまみではありません。
  • 修正は必要な性能枠に合わせる。プライドに合わせない。 ミューテックス+並列上限は、レビュー可能な数行でバッチの締め切りを満たしました。上のランキング表は、性能枠が変わる日のために設計ノートに残してあります。

このバグに悩まされた時間が長かったのは、メンタルモデルがほぼ正しかったからです。書き残す価値があるのはまさにこの種のバグです:修正は小さく、理解こそが成果物でした。