【実務・中級編】 トランザクションデッドロック – Cloud Spanner

Cloud Spannerのトランザクションデッドロック:その「不可避な壁」を設計でねじ伏せる

Spannerを触り始めたエンジニアが必ず一度は直面する壁、それが「Abort(トランザクションの中断)」だ。特に、デッドロックや競合によるトランザクションの失敗は、開発初期には「Spannerの機嫌が悪い」と誤解されがちだが、実際は違う。

Spannerは、分散環境における強整合性を実現するために、「楽観的並行性制御(OCC)」と「悲観的ロック」を高度に組み合わせたモデルを採用している。なぜデッドロックが起きるのか、そしてそれをどう「設計」で封じ込めるのか。現場で生き残るための知見を共有する。

—

1. なぜSpannerでデッドロックが起きるのか

Spannerのロックは、データ行(Row)単位で管理される。複数のトランザクションが複数の行を異なる順序でロックしようとした際、分散システム特有の待ち合わせが発生し、デッドロックを検出するとSpannerは躊躇なくそのトランザクションを `Aborted` としてキックする。

重要なのは、「Spannerはデッドロックを許容し、検知した瞬間に殺すことでシステム全体の整合性を守る」という設計思想だ。

デッドロックを誘発する「典型的なアンチパターン」

  • アクセス順序の不統一: トランザクションAが `Table_X -> Table_Y` の順でロックを取り、トランザクションBが `Table_Y -> Table_X` の順でアクセスする。これは教科書通りのデッドロック要因だ。
  • 高頻度な更新の集中(ホットスポット): 特定の行(例えばカウンターや親レコード)に複数のトランザクションが殺到すると、ロック待ちが発生し、結果としてデッドロックやタイムアウトの引き金になる。

—

2. 実務で守るべき「デッドロック回避の黄金律」

コードを書く前に、設計でこれらを強制せよ。

① ロック順序の正規化(Total Ordering)

全てのトランザクションにおいて、対象テーブルや行へのアクセス順序を厳格に固定する。
例えば、ユーザーテーブル(Users)とウォレットテーブル(Wallets)を更新する場合、必ず `Users -> Wallets` の順でアクセスさせる。これを規約ではなく、DAO層の設計として強制させるのがリードの責務だ。

② トランザクションの短縮化

Spannerのトランザクション内で重い外部API呼び出しや、CPU負荷の高い計算を行ってはならない。
「ロックを保持する時間」と「デッドロックの発生確率」は比例する。トランザクションは「読み込み → 計算 → 書き込み」を最小限の単位で行え。

// 悪い例:トランザクション内で重い処理をしてしまう
_, err := spannerClient.ReadWriteTransaction(ctx, func(ctx context.Context, txn spanner.ReadWriteTransaction) error {
// 外部APIを叩く(遅い!)
data := callExternalAPI()

// この間、行ロックが保持され続け、他からのアクセスを阻害する
return txn.BufferWrite(…)
})

③ 読み取りと書き込みの分離

可能な限り、読み取りは `ReadOnlyTransaction`(Snapshot Read)で行い、書き込み専用のトランザクションを切り離す。Snapshot Readはロックを一切取らないため、デッドロックの可能性をゼロにできる。

—

3. 実践:リトライ戦略の設計

Spannerでデッドロック(Abort)は「防ぐもの」であると同時に、「発生することを前提としたエラーハンドリング」が必要だ。Spannerのクライアントライブラリはデフォルトでリトライ機能を持っているが、それを信じすぎるな。

指数バックオフ付きのリトライ

単なる即時リトライは、競合している相手をさらに追い詰める(LiveLockの誘発)。必ず指数バックオフ(Exponential Backoff)を組み込むこと。

// 実務レベルのリトライ設計の概念
for i := 0; i < maxRetries; i++ { err := client.ReadWriteTransaction(ctx, func(...) { ... }) if err != nil { if spanner.ErrCode(err) == codes.Aborted { // 指数バックオフで待機 time.Sleep(backoff(i)) continue } return err } return nil } ---

4. 伝説のアーキテクトからのアドバイス

「デッドロックが頻発する」という報告を受けた時、私はコードを直す前にスキーマ設計とホットスポットの特定を確認する。

  • インターリーブ(Interleaving)を正しく使っているか? 親子関係にあるテーブルをインターリーブすることで、物理的な局所性が向上し、ロックの挙動が安定するケースが多い。
  • シークエンシャルなIDを使っていないか? UUID v4のようなランダムなキーにすることで、挿入時のホットスポットを回避でき、結果としてトランザクション競合が激減する。

Spannerを使いこなすということは、分散システムの制約を「コスト」ではなく「パズル」として楽しむことだ。

デッドロックに怯える必要はない。ロックの順序を支配し、トランザクションを極限まで削ぎ落とし、リトライ戦略を洗練させれば、Spannerは君の期待を裏切らない最強のバックエンドとして君臨するはずだ。

設計レビューで、「そのトランザクション、本当に必要か?」「ロックの順序は保証されているか?」と問いかけ続けること。それが、堅牢なシステムを作る唯一の道である。

コメント

タイトルとURLをコピーしました