Cloud Spanner 読み取り専用トランザクションの極意:ロックフリーの世界で秒間数十万クエリをさばく設計最適化
こんにちは。テクニカルリードの私だ。
今日のコードレビュー、あるいはアーキテクチャ設計レビューで、こんなクエリやトランザクション実装を見かけてため息をついていないか?
「とりあえず `ReadWrite` トランザクションでデータを取ってきて、アプリケーション側で加工して……」
おいおい、ちょっと待て。そのデータ、本当に書き込みを伴うか?
Cloud Spannerの真骨頂は、分散環境における強整合性と水平スケーリングの美しさにある。そして、そのスケーリング能力の牙城を守っている最大のエースこそが「読み取り専用トランザクション(ReadOnly Transaction)」だ。
今回は、Spannerのコアアーキテクチャの根幹であるスナップショット読み取りの仕組みを紐解き、ロックを取得せずにリソース競合を完全にゼロへ追い込みながら、システムのスループットを極限まで引き上げるための設計パターンと最適化手法を伝授する。
—
1. コアアーキテクチャ:なぜ読み取り専用トランザクションはロックフリーなのか?
まず、Spannerの分散ストレージ層の基本を思い出してほしい。データはパクター(Paxos)グループに分割され、マルチリージョンやマルチゾーンで複製されている。
一般的なデータベースでは、データの整合性を担保するために「読み取りであっても共有ロック(Sロック)を取得する」というアプローチを取る。これにより、同時実行性(Concurrency)が低下し、ホットスポットでのレイテンシ悪化やデッドロックのリスクを常に抱えることになる。
しかし、Cloud Spannerの読み取り専用トランザクションは、ロックを一切取得しない。これがどういうことか、内部の仕組みを見ていこう。
真実の鍵:TrueTime とマルチバージョン同時実行制御(MVCC)
Spannerがロックなしで一貫性のあるデータ(External Consistency)を読み込める理由は、主に2つの技術の融合にある。
1. TrueTime API(原子時計とGPS):
グローバルに同期された絶対時間の不確実性($\epsilon$:イプシロン)を保証するインフラ。これにより、システム全体で「どの時点(Timestamp)の状態か」を曖昧さなく一意に定義できる。
2. MVCC(多版同時実行制御):
Spannerのストレージエンジンは、データ更新時に古いバージョンを即座に破棄せず、タイムスタンプ付きで保持している。
読み取り専用トランザクションが開始されると、システムはトランザクションに対して正確な読み取りタイムスタンプ(Read Timestamp)を割り当てる。ストレージノードは、そのタイムスタンプ時点で存在していたデータのバージョン(Multiversion data)を淡々とスキャンする。
書き込み側(Read-Write Transaction)はロックを獲得してデータを更新し続けるが、読み取り側は「過去の特定時点のスナップショット」を見ているだけなので、書き込みのブロックを一切受けない。CPUとメモリが許す限り、いくらでも並行実行できるのだ。
—
2. 実務で直面するアンチパターンと正しい設計パターン
設計レビューで私が最も厳しく指摘するポイントがここだ。以下のコードを見て、どこに問題があるか即座にわかるだろうか?
❌ アンチパターン:読み取りに Read-Write トランザクションを使う愚行
// 【NG例】参照系APIなのに Read-Write トランザクションを使っている
_, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, tx spanner.ReadWriteTransaction) error {
// ユーザー情報を取得するだけなのに排他ロックのスコープに入れている
row, err := tx.ReadRow(ctx, “Users”, spanner.Key{“user_id_123”}, []string{“Balance”, “Status”})
if err != nil {
return err
}
// アプリケーション側で少しロジックを挟む
// …
return nil
})
何がダメなのか?
- 無駄なロックと競合: 同一の行やパクターグループに対する書き込みと競合し、スループットが劇的に落ちる。
- レイテンシの増大: 2段階コミット(2PC)のオーバーヘッドが常にかかるため、レイテンシが跳ね上がる。
- コストの無駄遣い: Read-Write トランザクションはリソース消費が大きい。
⭕ 正しい設計パターン:明示的な ReadOnly トランザクションの活用
参照系クエリや、整合性の取れた複数テーブルの一括参照(ダッシュボード画面の描画など)は、必ず `ReadOnlyTransaction` を選択すべきだ。
// 【OK例】ReadOnlyTransaction でロックフリーなスナップショット読み取りを行う
txn := client.ReadOnlyTransaction()
defer txn.Close()
// トランザクション境界を明確にし、正確なタイムスタンプで一貫性を保証
row, err := txn.ReadRow(ctx, “Users”, spanner.Key{“user_id_123”}, []string{“Balance”, “Status”})
if err != nil {
return err
}
// データの処理…
—
3. パフォーマンスを限界突破させる高度な最適化テクニック
ここからが本題だ。チーフアーキテクトとして、実務で数百万QPSを捌くシステムを構築する際に使う、一歩進んだテクニックを公開しよう。
1. `ExactStaleness` と `MaxStaleness` によるキャッシュの最大活用
読み取り専用トランザクションでは、タイムスタンプの指定方法をチューニングできる。
- Bounded Staleness(境界付き古さ):
「過去数秒以内(例: 5秒前)」のデータを許容することで、ローカルレプリカ(ニアバイのリードレプリカ)からデータを読み込ませることが可能になる。マルチリージョン構成において、遠く離れたリーダーリージョンへのネットワークラウンドトリップを回避し、劇的なレイテンシ削減(レイテンシの局所化)を実現できる。
// 5秒以内の古さを許容し、ローカルレプリカから超高速に読み取る
tSpec := spanner.ExactStaleness(5 time.Second)
txn := client.ReadOnlyTransactionWithTimestampBound(tSpec)
defer txn.Close()
iter := txn.Query(ctx, spanner.Statement{SQL: “SELECT FROM LargeCatalog”})
// …
- Exact Staleness(厳密な古さ):
特定の過去のタイムスタンプを指定して読み取る。これにより、過去の任意の時点におけるデータの状態を完全な整合性を持って再現できる(タイムトラベルクエリ)。バッチ処理や監査ログの突合に極めて有効だ。
2. クエリのプレパレーションとインデックス設計の連動
読み取り専用トランザクションであっても、スキャン範囲が広すぎればCPUとメモリを圧迫する。
特に、インターリーブ(Interleaved)テーブル構造を用いた親子関係の設計は、読み取り専用クエリのパフォーマンスを決定づける。
— 親テーブル: Customers
CREATE TABLE Customers (
CustomerID INT64,
Name STRING(MAX),
) PRIMARY KEY(CustomerID);
— 子テーブル: Orders (物理的に親子のデータが同一スプリットに局所化される)
CREATE TABLE Orders (
CustomerID INT64,
OrderID INT64,
OrderDate DATE,
Amount FLOAT64,
) PRIMARY KEY(CustomerID, OrderID),
INTERLEAVE IN PARENT Customers ON DELETE CASCADE;
この構造に対し、読み取り専用トランザクションで親子のデータを結合(JOIN)して取得する場合、スキャンのオーバーヘッドが最小化される。なぜなら、Spannerのストレージ層で物理的にデータが近くに配置されているため、ネットワークを跨いだデータ転送が劇的に削減されるからだ。
—
4. チーフアーキテクトからの警告:注意すべき落とし穴
どれほど優れた機能であっても、使いどころを誤ればシステムを崩壊させる。以下の点だけは絶対に忘れないでほしい。
1. トランザクションの閉じ忘れ(資源リーク):
Goなどの言語で `client.ReadOnlyTransaction()` を呼んだ場合、必ず `defer txn.Close()` を忘れないこと。トランザクションを開いたまま放置すると、古いデータバージョン(GCの対象外となるタイムスタンプ)がガベージコレクションされずに保持され続け、ストレージの肥大化と性能劣化(Storage Bloat)を引き起こす。
2. リアルタイム性と staleness のトレードオフ:
`ExactStaleness` を用いてレイテンシを削減する場合、「今、書き込まれたばかりのデータが読めない」という結果整合性(Eventual Consistencyに近い挙動)をアプリケーションが許容できるか、プロダクト仕様レベルで必ず確認すること。決済直後の残高表示など、厳密なリアルタイム性が求められる箇所には使ってはいけない。
—
5. まとめ
Cloud Spannerの読み取り専用トランザクションは、単なる「参照用の機能」ではない。
TrueTimeとMVCCというモダン分散システムの結晶が生み出した、「ロックの呪縛から解放された究極のスケール手段」だ。
- 読み取りには迷わず `ReadOnlyTransaction` を選ぶ。
- マルチリージョン環境では `Staleness` を活用してレイテンシを最適化する。
- 物理レイアウト(インターリーブなど)を意識したクエリ設計を行う。
この原則をコードレビューの共通言語としてチームに浸透させれば、あなたのシステムはどんなトラフィックの波が来ようとも、涼しい顔でスケールし続けるはずだ。
設計の美しさをコードに宿せ。健闘を祈る。
コメント