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

Spannerのトランザクションリトライは「お祈り」ではない:数百万QPSを支える分散排他の真実

こんにちは。テックリードの私だ。
今日のコードレビュー、または設計レビューでこんなコードを見かけなかったか?

最悪のアンチパターン:何も考えていないベタ書きリトライ
for _ in range(3):
try:
run_transaction()
break
except Exception:
time.sleep(1)
continue

即座にマージリクエストをRejectしてほしい。

Cloud Spannerは、世界規模の水平スケーリングと外部一貫性(External Consistency)を両立させた怪物的なデータベースだ。しかし、その強烈な整合性の代償として、高負荷時やホットスポットへの書き込み集中時には「トランザクション競合(ABORTED)」が必ず発生する。

これを「運悪くエラーになったから、数秒待ってリトライすれば治るだろう」という甘い認識で設計していると、本番環境でレイテンシが雪崩を打って悪化し、最終的にシステム全体が沈没する。

今回は、Spannerのトランザクションリトライロジックの裏側にある分散アーキテクチャの物理法則を紐解き、プロフェッショナルが実装すべき「堅牢なリトライ戦略」を叩き込む。

—

1. なぜSpannerはトランザクションをアボート(ABORT)するのか?

まず、Spannerの分散トランザクションがどう動いているかを思い出そう。
Spannerは、データを複数のPaxosグループ(スプリット)に分散配置している。読み取り・書き込みトランザクション(Read-Write Transaction)では、2段階コミット(2PC)と悲観的同時実行制御(一部最適化はあるが本質は排他制御)を用いて、グローバルな直列化可能性(Serializability)を保証する。

ここで何が起きるか?

1. ロックの獲得: トランザクションが走ると、対象のスプリット上でロックが取得される。
2. 競合の検出: 別のトランザクションが同じ行や同じレンジに対して書き込もうとすると、デッドロックを防ぐため、または直列化順序を維持するために、Spannerは躊躇なくどちらか一方をアボート(`ABORTED`エラーを返却)する。

この「アボート」は、Spannerが壊れているわけではない。「システム全体の整合性を守るための正常な防衛機制」なのだ。

したがって、開発者が向き合うべき問いは「どうやったら競合を防げるか(もちろんスキーマ設計での工夫は必要だが)」ではなく、「競合が発生したときに、いかにシステムに負荷をかけず、美しくリトライを成功させるか」である。

—

2. 指数バックオフ(Exponential Backoff)とジッターの数学的必然性

リトライを入れる際、多くのエンジニアがやりがちなミスが「固定間隔(例: 1秒ごとに3回)」だ。これがなぜ最悪なのか、分散システムの観点から説明しよう。

100のクライアントが同時に競合を起こし、全員が「1秒後」に一斉にリトライをかけたとしよう。何が起きるか?
そう、「雷鳴効果(Thundering Herd Problem)」だ。1秒後に再び全く同じタイミングで100のトランザクションがスパイクし、再び全員がアボートする。無限ループの完成である。

これを防ぐ唯一の解が、「指数バックオフ + 完全ランダムジッター(Full Jitter)」だ。

正しいバックオフ計算モデル

リトライ回数を $n$ としたとき、待機時間 $T$ は以下のように計算する。

$$T = \text{random}(0, \min(T_{\text{max}}, T_{\text{base}} \times 2^n))$$

  • $T_{\text{base}}$: 初期待機時間(例: 10ms〜50ms)
  • $T_{\text{max}}$: 最大待機時間(例: 10s)
  • Jitter: 完全にランダムな揺らぎを入れることで、クライアント群のリトライタイミングを完全に分散させる。

—

3. 実装パターン:プロダクション品質のGoコード

口で言うだけなら誰でもできる。プロダクションでそのまま使える、Cloud Spanner Go Clientを活用した「堅牢なトランザクション実行ラッパー」のコードを示せ。

package main

import (
“context”
“math/rand”
“time”

“cloud.google.com/go/spanner”
“google.golang.org/grpc/codes”
“google.golang.org/grpc/status”
)

// Recommended limits
const (
maxRetries = 5
initialBackoff = 20 time.Millisecond
maxBackoff = 5 time.Second
)

// ExecuteWithRetry は、SpannerのABORTEDエラーに対して指数バックオフ+ジッター付きでリトライを行う
func ExecuteWithRetry(ctx context.Context, client spanner.Client, f func(ctx context.Context, txn spanner.ReadWriteTransaction) error) error {
backoff := initialBackoff

for attempt := 0; ; attempt++ {
// 1. トランザクションの実行
_, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn spanner.ReadWriteTransaction) error {
return f(ctx, txn)
})

// 2. 成功またはリトライ不可能なエラーの場合は即リターン
if err == nil {
return nil
}
if !isAbortError(err) {
return err // ABORTED以外のエラー(NotFound, InvalidArgument等)はリトライしても無駄
}

// 3. 最大試行回数を超えた場合
if attempt >= maxRetries {
return status.Errorf(codes.Aborted, “max retries (%d) exceeded: %v”, maxRetries, err)
}

// 4. 指数バックオフ + フルジッターの計算
// sleep = random(0, min(maxBackoff, backoff 2^attempt))
sleepDuration := time.Duration(rand.Int63n(int64(backoff)))

select {
case <-ctx.Done(): return ctx.Err() case <-time.After(sleepDuration): } // 次のバックオフを倍増(上限あり) backoff = 2 if backoff > maxBackoff {
backoff = maxBackoff
}
}
}

// isAbortError はエラーがSpannerのABORTED(競合)に該当するかを判定する
func isAbortError(err error) bool {
st, ok := status.FromError(err)
if !ok {
return false
}
// gRPCのCodes.AbortedがSpannerのトランザクション競合を示す
return st.Code() == codes.Aborted
}

このコードの優れたポイント

1. エラーコードの厳密な判定: `codes.Aborted` のみをキャッチしてリトライしている。バリデーションエラーやnot-foundなどを巻き込んでリトライしない。
2. フルジッターの実装: `rand.Int63n(int64(backoff))` により、同期的な再突撃を完全に防いでいる。
3. コンテキストの伝搬: スリープ中も `ctx.Done()` を監視しており、タイムアウトやリクエストキャンセルに即座に応答する。

—

4. チーフアーキテクトからの警告:パフォーマンス上の罠

最後に、リトライロジックを実装する上で陥りがちな「アーキテクチャ上の罠」を3つ共有しておく。

① トランザクション内で外部APIを叩くな

これが一番多いバグだ。SpannerのRead-Writeトランザクションの内部で、HTTPリクエストや外部マイクロサービスの呼び出しを行ってはならない。
外部APIのレイテンシ変動によってトランザクションの保持時間が延び、ロックが長引き、システム全体のスループットが劇的に低下する。トランザクション内で行うのは「純粋なDB読み書き」のみに絞り、外部連携はトランザクション外(コミット後)に非同期(Pub/Sub等)で行え。

② ホットスポットへの書き込み集中をリトライでごまかすな

例えば、全ユーザーのカウンターを1つの行に集約し、そこに対して毎秒1万回の書き込みを行えば、どんなに優秀なリトライロジックを組んでもアボートの嵐になり、最終的にシステムは破綻する。
カウンターであればシャード化(分散カウンタ)を導入し、書き込み先を物理的に異なるスプリットへ分散させることが根本的な解決だ。リトライはあくまで「避けられない一瞬の競合」を吸収するためのものであり、設計の不備を隠す魔法の杖ではない。

③ クライアント側のタイムアウト設定の整合性

Spannerのトランザクション全体に適用するクライアント側のタイムアウト(ContextのDeadline)は、「最大リトライ回数とバックオフ時間の総和」を十分に考慮した長さに設定しなければならない。リトライが3回目で成功するポテンシャルがあったのに、クライアント側のタイムアウトが短すぎて途中で強制切断されては本末転倒だ。

—

まとめ

Cloud Spannerを使いこなすということは、分散システムの物理制約と美しく向き合うことと同義だ。
トランザクションの `ABORTED` エラーを恐れる必要はない。それはSpannerが正しく仕事をしている証拠だ。

今日からあなたのプロジェクトでも、「お祈りリトライ」を捨て、数学的裏付けのある「指数バックオフ+ジッター」のパターンを標準装備してほしい。コードレビューで不適切なリトライを見つけたら、この記事のリンクをそっと貼ってやるといい。

コメント

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