Cloud Spannerで「二重書き」を撲滅する:分散トランザクションにおける冪等性の極意
エンジニア諸君。Cloud Spannerを単なる「SQLが動く大規模DB」と勘違いしていないか?
Spannerは、TrueTimeによる原子時計の恩恵で外部整合性を担保する怪物だ。しかし、ネットワークの瞬断やスプリットブレイン、クライアント側のタイムアウトは、分散システムの宿命として必ず発生する。「トランザクションの失敗=データの消失」ではない。最も恐ろしいのは「失敗したように見えて、実はコミットが成功していた」という曖昧な状態だ。
今回は、Cloud Spannerにおいて「冪等性(Idempotency)」をどう実装すべきか、現場で通用する設計指針を叩き込む。
—
1. なぜSpannerで「冪等性」が問題になるのか?
Spannerのクライアントライブラリは、デッドロックや一時的なエラーが発生した際に自動再試行(Retry)を行う。しかし、この再試行が「ビジネスロジックの二重実行」を誘発してはならない。
例えば、決済処理で口座から1,000円引く処理を考えよう。
1. リクエスト送信
2. Spanner側でコミット成功
3. ACK(応答)がネットワーク障害でクライアントに届かない
4. クライアントが「タイムアウトした」と判断し、再度リクエストを送る
この時、もし君のコードが「現在の残高から1,000円引く」という命令をそのまま実行していたら、ユーザーの口座から2,000円が消し飛ぶ。これが「冪等性が欠如したシステム」の末路だ。
—
2. 実践的パターン:冪等性キー(Idempotency Key)の導入
最も堅牢な手法は、アプリケーション層で「リクエストを一意に特定するトークン」を発行し、それをデータベースの状態として記録することだ。
アーキテクチャの設計指針
1. Idempotency Tableの作成: 処理済みリクエストのIDと結果を保持するテーブルを用意する。
2. アトミックな更新: 「処理済みフラグの確認」と「本来の更新処理」を、同一のトランザクション内で行う。
実装例(Goによる擬似コード)
func TransferFunds(ctx context.Context, client spanner.Client, reqID string, amount int64) error {
_, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn spanner.ReadWriteTransaction) error {
// 1. 冪等性チェック: このリクエストIDが既に存在するか確認
row, err := txn.ReadRow(ctx, “ProcessedRequests”, spanner.Key{reqID}, []string{“RequestID”})
if err == nil {
// すでに処理済みなら、二重実行を避けて正常終了させる
return nil
}
// 2. 本来のビジネスロジック: 口座の更新
// (更新処理の実装は省略)
// 3. 冪等性キーの記録: このトランザクションの一部として保存
m := spanner.Insert(“ProcessedRequests”, []string{“RequestID”, “ProcessedAt”}, []interface{}{reqID, spanner.CommitTimestamp})
return txn.BufferWrite([]spanner.Mutation{m})
})
return err
}
—
3. パフォーマンスとスケーラビリティの注意点
ここで「すべてのトランザクションでテーブルを参照するのはオーバーヘッドではないか?」という鋭い問いが飛ぶはずだ。
- ホットスポットの回避: `ProcessedRequests` テーブルの主キーに単調増加する値(タイムスタンプなど)を使わないこと。UUID v4のようなランダムな値をキーにせよ。さもないと、特定のサーバーにアクセスが集中し、Spannerの真価である水平スケーリングが死ぬ。
- TTL(Time To Live)の活用: 冪等性キーを永久に保持する必要はない。SpannerのTTL機能を使い、例えば「7日経過したレコードは自動削除」するように構成せよ。ストレージコストとインデックスの肥大化を同時に防げる。
—
4. 伝説的なエンジニアからのアドバイス:設計の落とし穴
多くのプロジェクトが陥る、よくある失敗例を挙げておく。
- 「とりあえずリトライ」の盲信: リトライ回数を増やすだけで解決しようとするのは素人のやることだ。指数バックオフを適切に設定し、かつ「成功の確認」を必ず行うこと。
- 外部APIとの整合性: Spannerのトランザクション内で外部API(決済ゲートウェイなど)を叩いてはならない。Spannerがロールバックしても、外部APIの決済はキャンセルされない可能性がある。「外部API呼び出し→Spannerへの結果保存」という流れをいかに一貫させるか、メッセージキュー(Pub/Sub)を用いた非同期処理の検討が不可欠だ。
—
まとめ
Cloud Spannerを使いこなすということは、分散システムの不確実性とどう付き合うかをデザインすることに他ならない。
1. あらゆる書き込みは「冪等であること」を前提にする。
2. リクエストIDをDBのトランザクション内で保持し、状態管理を完結させる。
3. キーの設計には常にスケーラビリティを考慮する。
Spannerは君たちが書いたコードが引き起こす「論理的な不整合」までは直してくれない。しかし、正しい設計さえあれば、世界で最も強固なデータ基盤として君たちを支えてくれるだろう。
さあ、コードを開け。君のシステムに「二重書き」の余地がないか、今すぐ見直してほしい。
コメント