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

Cloud Spannerの「デッドロック」と戦うな。システムを「アボート前提」で設計せよ。

Cloud Spannerを触り始めたエンジニアが必ずぶつかる壁がある。それが `Aborted` エラーだ。

「なぜリトライが必要なのか?」「なぜデッドロックが起きるのか?」と悩む前に、一つだけ言っておこう。Spannerにおいてトランザクションのアボートは「失敗」ではなく「仕様」であり「生存戦略」だ。

RDBMSの世界でデッドロック回避のためにインデックスを調整したり、ロック順序を血眼になって整理したりする苦労は、Spannerでは捨てていい。ここでは、Spannerがなぜデッドロックを検出し、どう立ち回るべきか、その本質的な設計思想を叩き込む。

—

1. なぜSpannerはトランザクションを「殺す」のか

Spannerは「外部整合性(External Consistency)」という、分散データベースにおいて最も過酷な制約を保証している。これを実現するために、Spannerは厳密なロック(Strict Two-Phase Locking)を用いている。

複数のトランザクションが同じ行のロックを奪い合った際、もし双方が相手のロック解除を待機すると、システムは停止する。これを防ぐために、Spannerのデッドロック検出器は一瞬で「どちらかのトランザクションをアボート(強制終了)させる」という判断を下す。

ここが重要だ:
Spannerのデッドロック検出は「悲観的」ではない。「システム全体のLiveness(生存性)を優先し、競合を検知したら即座にコストの低い側を切り捨てる」という、極めて合理的かつ冷徹なアルゴリズムだ。

2. アボートは「例外」ではなく「フローの一部」

多くのエンジニアが犯す最大のミスは、`Aborted` を `Internal Server Error` と同列に扱い、アプリケーションを停止させてしまうことだ。

Spannerのトランザクションは、必ずリトライ可能な状態で設計しなければならない。以下は、Go言語による「正しいリトライ」の作法だ。

// Spannerのライブラリは、アボートを検知すると自動的にリトライ可能な関数を呼び出す
_, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn spanner.ReadWriteTransaction) error {
// 1. 読み込み
row, err := txn.ReadRow(ctx, “Users”, spanner.Key{userID}, []string{“Balance”})
if err != nil {
return err
}

// 2. ビジネスロジック
// ここで複雑な計算をしてはいけない。計算はトランザクション外で済ませるのが鉄則。
newBalance := …

// 3. 書き込み
return txn.BufferWrite([]spanner.Mutation{
spanner.Update(“Users”, []string{“UserId”, “Balance”}, []interface{}{userID, newBalance}),
})
})
// もしアボートされた場合、ライブラリが内部でトランザクションを最初から再実行してくれる

このコードにおいて、`ReadWriteTransaction` 内のコールバック関数は「冪等(べきとう)」である必要がある。外部APIコールやメール送信をここに入れてはならない。トランザクション内は、純粋なデータベース操作に専念させること。

3. デッドロックを「劇的に減らす」堅牢な設計パターン

アボートはリトライできるとはいえ、頻発すればレイテンシが悪化する。デッドロックを避けるための「Spanner流の作法」を伝授する。

① 「読み取り」と「書き込み」の分離

可能な限り `Snapshot Read` (読み取り専用トランザクション)を活用せよ。読み取りの段階でロックを保持しないため、デッドロックの可能性をゼロにできる。

② ホットスポットを避ける「キー設計」

単一の行(例えば「全ユーザーの合計残高」を保持するレコードなど)を頻繁に更新していないか?

  • ダメな設計: 1行のレコードを更新し続ける。
  • 良い設計: カウンターを分散させる(Sharding)。例えば10個のカウンターに分散させ、合計が必要な時だけ集計する。

③ トランザクションを短く、小さく

トランザクション内で重い計算や外部リクエストを行えば、その分だけロック保持期間が延びる。ロック期間が延びる=デッドロックの確率は指数関数的に増大する。
「読み込み → 計算 → 書き込み」ではなく、「(読み込み)→ 計算 → (書き込み)」というように、トランザクションのスコープを最小限に切り詰めろ。

4. チーフアーキテクトからの忠告

実務の現場でトラブルシューティングをしていると、決まって「なぜこんなに頻繁にアボートするのか?」と相談を受ける。大抵の原因は以下のどれかだ。

1. トランザクション内で外部システムを呼んでいる: ネットワーク遅延がロックを保持し続け、デッドロックを誘発している。
2. 一つのトランザクションで広範囲を更新している: 更新対象の行数が多いほど、衝突確率は上がる。
3. バッチ処理がオンライン処理を阻害している: 大量更新のバッチは、トランザクションのサイズを調整するか、適切なインターバルを設けるべきだ。

最後に:恐れるな、制御せよ

Cloud Spannerは、分散システムにおける「正しさ」を極限まで追求したモンスターだ。そのモンスターが提示する「アボート」という仕様を、障害だと捉えてはいけない。

「分散環境では競合は必ず起きる。だからこそ、リトライを前提とした堅牢な設計を磨き上げる」。 これができるチームこそが、Spannerの真のパワーを引き出し、世界規模のトラフィックをさばくシステムを構築できるのだ。

さあ、コードを開け。君のトランザクションは、本当に「短く、純粋に」なっているか?

コメント

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