【実務・中級編】 読み書きトランザクション – Cloud Spanner

Cloud Spannerの読み書きトランザクション:極限のスループットを引き出す分散トランザクションの作法

こんにちは。テクニカルリードの私だ。
今日のコードレビュー、あるいはアーキテクチャ設計レビューで、君たちが持ち込んだCloud Spannerの設計書を見て少し気になったことがある。

「とりあえず、データ整合性を担保するために読み書きトランザクション(Read-Write Transaction)を貼りました」

……その設計、本当にプロダクションのトラフィックに耐えられるか?
分散RDBであるCloud Spannerにおいて、読み書きトランザクションは諸刃の剣だ。そのメカニズムと分散原理を正しく理解していなければ、高負荷時にレイテンシが跳ね上がり、最悪の場合はアボート(Abort)の嵐でシステムが沈没する。

今日は、Spannerのコアアーキテクチャの深淵に踏み込み、「なぜ競合が起きるのか」「どうすれば競合を最小化し、極限のパフォーマンスを引き出せるのか」をロジカルかつシャープに伝授しよう。

—

1. 読み書きトランザクションの正体:なぜ「再試行(Retry)」が前提なのか

まず、公式ドキュメントに書いてあるような「ACID特性を完全に保証します」というお題目はおさらい程度にしておこう。プロフェッショナルが見るべきなのは、その裏で動いている分散合意アルゴリズムとロックの物理モデルだ。

悲観的ロックとTwo-Phase Locking (2PL) の現実

Cloud Spannerの読み書きトランザクションは、内部的にPessimistic Locking(悲観的ロック)と2Phase Commit (2PC) / Paxosを組み合わせて実現されている。

1. 読み取り時: 対象のデータ範囲に対してリーダーレプリカでロック(またはIntent Lock)を獲得する。
2. 書き込み時: トランザクション内で変更されたデータは、コミット時に2PCを用いて全関連スプリット(Split)に原子的に適用される。

ここで何が起きるか?
複数のトランザクションが同一のキーレンジ、あるいはホットスポットとなっている行に対して同時に書き込みを行おうとすると、ロックの獲得待ち(Lock Contention)が発生する。Spannerはデッドロックを検知するか、あるいはタイムアウト・競合検知のタイミングで、トランザクションを強制的にアボート(Abort)させる。

これが、読み書きトランザクションに「リトライループ」が必須である根本的な理由だ。

> チーフアーキテクトの教訓:
> 「競合したらリトライすればいいや」という甘い考えでコードを書くな。リトライは最後の防衛線であり、設計段階で競合の発生確率を極限までゼロに近づけるのがプロの仕事だ。

—

2. アンチパターン:やってはいけない設計・実装

実際のコードレビューで私が即座に差し戻す、典型的なアンチパターンを挙げる。

アンチパターン A: トランザクション内での外部API呼び出しや重い演算

// 【NG例】トランザクション内で外部サービスを叩いている
_, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn spanner.ReadWriteTransaction) error {
// 1. データを読み込む
row, err := txn.ReadRow(ctx, “Users”, spanner.Key{“user_id_123”}, []string{“Balance”})
// …

// 2. 外部の決済APIを同期呼び出し(ネットワークレイテンシが乗る!)
err = callExternalPaymentAPI()

// 3. 書き込む
return txn.BufferWrite([]spanner.Mutation{…})
})

なぜNGか?
トランザクションが保持しているロックの期間が、外部APIの応答時間分だけ引き延ばされる。その間、同じ行にアクセスしようとする他のトランザクションはすべてブロックされ、システム全体のスループットが劇的に低下する。

アンチパターン B: 単調増加キー(オートインクリメント等)によるホットスポット

分散データベースにおいて、`1, 2, 3, 4…` とインクリメントするIDや、タイムスタンプをそのままプライマリキーの先頭に置くと、データは単一のスプリットに集中する。そのスプリットを管理するPaxosグループに書き込みが集中し、読み書きトランザクションの競合が爆発的に発生する。

—

3. 堅牢な設計パターン:スループットを極限まで高める実装

では、どう設計すべきか。実務で使える実践的なパターンをコード(Go言語ベース)で示そう。

パターン 1: トランザクションのスコープは極限まで短くする

読み込み、ビジネスロジックの計算、書き込み(Mutationのバッファリング)のみをトランザクション内に閉じ込め、I/Oや重い処理はすべてトランザクションの外で行う。

// 【GOOD例】トランザクション内は純粋なDB操作のみにする
func UpdateBalance(ctx context.Context, client spanner.Client, userID string, amount int64) error {
// 外部API呼び出しなどはトランザクション外で済ませておく

_, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn spanner.ReadWriteTransaction) error {
// 1. 読み取り
row, err := txn.ReadRow(ctx, “Accounts”, spanner.Key{userID}, []string{“Balance”, “Version”})
if err != nil {
return err
}
var currentBalance int64
var version int64
if err := row.Columns(&currentBalance, &version); err != nil {
return err
}

// 2. 計算
newBalance := currentBalance + amount
if newBalance < 0 { return status.Error(codes.InvalidArgument, "insufficient funds") } // 3. 書き込み(Mutationのバッファリング) // SpannerのGoクライアントは、競合時のリトライを内部で自動的にハンドリングする m := spanner.Update("Accounts", []string{"UserId", "Balance", "Version"}, []interface{}{userID, newBalance, version + 1}) return txn.BufferWrite([]spanner.Mutation{m}) }) return err }

パターン 2: ビット反転(Hash Prefix)によるキーの分散

どうしても単調増加するようなデータ構造を扱わなければならない場合、キーの先頭にハッシュ値を付与してキーレンジを意図的に分散させろ。

[Bad] PrimaryKey: (Timestamp, EventID) -> 常に最新のタイムスタンプの末尾スプリットに書き込みが集中
[Good] PrimaryKey: (ShardID, Timestamp, EventID) (ShardID = Hash(EventID) % 16 など)

これにより、書き込み負荷が16個のPaxosグループに綺麗に分散され、読み書きトランザクションの衝突確率を劇的に下げることができる。

—

4. パフォーマンス上の注意点とモニタリング指標

Cloud Spannerを運用する上で、SREやテックリードが常に監視すべきCloud Monitoringのメトリクスがある。これを見落としているようでは、アーキテクト失格だ。

1. `Transaction Aborts` (トランザクションアボート数)

  • これが急増している場合、アプリケーション層でのトランザクション競合が激化しているか、ホットスポットが存在する。

2. `Lock Wait Time` (ロック待ち時間)

  • トランザクションがロック獲得のために待機している時間。ここが高い場合、トランザクションのスコープが長すぎるか、アクセス集中が起きている。

3. CPU Utilization (CPU使用率)

  • SpannerのノードあたりのCPU使用率は、読み書きトランザクションの負荷を測る最も確実な指標だ。ホットスポットが発生すると、全体のCPUに余裕があっても「特定の1ノードだけCPU 100%(そしてスロットリング)」という現象が起きる。

—

結びにかえて

Cloud Spannerの読み書きトランザクションは、正しく使えば「最強の整合性と水平スケーラビリティの両立」をもたらす魔法の杖だ。しかし、分散データベースの物理原則を無視した雑な設計をすれば、真っ先にシステムを崩壊させるブーメランにもなる。

今日のレビューからは、以上の原則に照らし合わせてコードを見直してほしい。
「なぜこのトランザクションが必要なのか」「もっとスコープを狭められないか」「キー分散の設計は完璧か」。

この問いに淀みなく答えられる者だけが、真のSpanner使いと名乗ることを許される。
次のレビューを楽しみにしているぞ。

コメント

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