【Cloud Spanner】真のシリアライザブル:現場のエンジニアが勘違いしがちなトランザクション分離の罠と極意
こんにちは。チーフアーキテクトの私だ。
今日のコードレビュー、あるいは基本設計のレビューで、こんなセリフを口にしていないだろうか?
- 「とりあえずトランザクション貼っておけば整合性は担保されるよね」
- 「競合したらリトライすればいいんでしょ?」
- 「ANSI SQLのアイソレーションレベルで言えば、REPEATABLE READ相当だよね?」
甘い。 その認識のままCloud Spannerの本番環境を叩けば、高負荷時に静かに、しかし確実にシステムは崩壊する。あるいは、意図しないレイテンシの嵐に巻き込まれることになるだろう。
今日は、Cloud Spannerが提供する「シリアライザブル(Serializable)」という最強にして唯一のトランザクション分離レベルの本質を、実務の現場でどう活かし、どう設計すべきか、私の知見を余すところなく伝授する。
—
1. Spannerの「シリアライザブル」は、他のDBのそれとは格が違う
多くのエンジニアが勘違いしているが、一般的なRDB(例えばPostgreSQLやMySQL/InnoDB)の「Serializable」と、Spannerのそれとは、物理的な裏付けが根本から異なる。
一般的なRDBは、オプティミスティック(楽観的)あるいはペシミスティック(悲観的)なロック機構をベースにしつつ、SSI(Serializable Snapshot Isolation)などを実装している。そのため、デッドロックの検知とロールバックのハンドリングに頭を悩ませる。
一方、Cloud Spannerは、TrueTime APIというGoogleの誇る原子時計とGPSを用いた同期インフラをベースにしている。
> チーフアーキテクトの洞察:
> Spannerのシリアライザブルとは、「世界中のどこで起きたトランザクションであっても、そのコミット順序が絶対的な物理時間(TrueTimeの不確かさの範囲内)の順序と完全に一致する(External Consistency)」という性質を指す。
つまり、トランザクション $A$ がコミットした後にトランザクション $B$ が開始された場合、$B$ は $A$ の変更を100%確実に見ることができる。アプリケーション層で「あれ、さっきの更新が反映されてない?」という幽霊読み(Phantom Read)や、書き込みのロストに怯える必要は一切ない。これがSpannerのシリアライザブルの正体だ。
—
2. 現場で頻発するアンチパターン:なぜあなたのコードは遅いのか?
では、すべてがシリアライザブルで安全・快適かと言うと、答えは「No」だ。この厳格すぎるほどの整合性ゆえに、書き方の作法を誤ると激しいコンテンション(競合)とレイテンシの悪化を招く。
以下のコードを見てほしい。レビュー対象として、どこに問題があるか即答できるだろうか?
❌ 悪い例:ホットスポットを生む典型的なカウンタ更新
// Go言語によるSpannerトランザクションの例(アンチパターン)
_, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn spanner.ReadWriteTransaction) error {
// 1. 現在の総アクセス数を読み込む
row, err := txn.ReadRow(ctx, “Metrics”, spanner.Key{“total_access”}, []string{“Count”})
if err != nil {
return err
}
var count int64
if err := row.Column(0, &count); err != nil {
return err
}
// 2. カウントをインクリメント
count++
// 3. 書き戻す
m := spanner.Update(“Metrics”, map[string]interface{}{
“Key”: “total_access”,
“Count”: count,
})
return txn.BufferWrite([]spanner.Mutation{m})
})
何が起きているか?
このコードを秒間数千リクエストが走るシステムで動かした瞬間、`Metrics` テーブルの `total_access` という単一の行(Row)に対して、世界中のスレッドがロックとシリアライズの順番待ちを強制される。
結果、「ABORTED」エラーが頻発し、リトライの嵐によってのスループット急降下、そしてレイテンシの天井張り付きが発生する。これが「ホットスポット」の悪夢だ。
—
3. 堅牢な設計パターン:シリアライザブルを使いこなす技術
では、どう設計すべきか。Spannerで高スループットを維持しながらシリアライザブルな整合性を保つための、実務で使える2つのパターンを伝授する。
パターンA:カウンターは「集約とマージ(またはスプリット)」で戦え
もし単一の数値を厳密にインクリメントし続ける必要があるなら、単一の行に依存してはならない。
1. シャード化カウンター(Sharded Counters):
キーを `total_access#0` から `total_access#9` のように分散させ、ランダムなシャードに対して書き込みを行う。読み出し時はそれらをSUMする。
2. コミut時の演算(Mutationでのインクリメント):
SpannerのMutationには、実は強力な機能がある。トランザクション内でわざわざ「読み込んでから計算して書き戻す」をせずとも、SQLやCloud Spannerの機能を利用してアトミックに処理を流し込む。
パターンB:読み込みを行わない「ブラインド・ライヴライト(Blind Write)」
もし「前の値がどうであれ、とにかくこの値を書き込む」というユースケースであれば、トランザクション内で `Read` を挟んではならない。
`ReadWriteTransaction` の中で `Read` を実行すると、Spannerはその行に対してロック(厳密にはリードロックに相当する競合検知)を構える。
単に書き込むだけなら、読み込みをスキップして `BufferWrite` だけを実行せよ。これにより、無駄な競合検知のコストを劇的に下げることができる。
—
4. コードレビューの視点:私ならここを見る
もし君たちが私にコードレビューを依頼してきたら、私は以下の点を容赦なく突っ込む。
1. 「本当にそのトランザクションで Read が必要か?」
- 読み込みを伴わない書き込みなら、トランザクションの粒度を見直せ。
2. 「ホットスポットになり得る単一キー(マスタデータやカウンター)を同時に更新していないか?」
- キー設計がマルチテナントやハッシュ化を考慮しているか確認させてもらう。
3. 「ABORTED エラーに対するリトライハンドリングは適切に実装されているか?」
- Spannerのトランザクションは競合によってアボートすることが正常な挙動として設計されている。アプリケーション層で適切な指数バックオフ(Exponential Backoff)付きのリトライが入っているか?
—
5. まとめ
Cloud Spannerのシリアライザブルなトランザクション分離レベルは、魔法の杖ではない。しかし、その物理的な特性(TrueTimeと分散シリアライズ)を正しく理解したエンジニアが扱うことで、「強整合性と水平スケーラビリティの両立」という、かつて不可能とされた聖杯をもたらしてくれる。
設計の基本に立ち返ろう。
- 無駄な読み込みを排除し、コンテンションを最小化する。
- ホットスポットを生まないキー設計を徹底する。
- アボートを恐れず、堅牢なリトライ機構を実装する。
この3つを頭に叩き込み、明日の設計レビューに臨んでほしい。君たちのシステムの健闘を祈る。
コメント