TrueTimeがもたらす魔術:Cloud Spannerはいかにして「ロックなしのスナップショット分離」を実現しているのか
こんにちは。チーフアーキテクトの私だ。
今日のコードレビュー、あるいはアーキテクチャ設計レビューで、こんな質問を受けたことはないだろうか?
- 「Spannerって、なんでリードロック(Shared Lock)を取らないのに、整合性が一瞬でグローバルに揃うんですか?」
- 「バッチ処理で過去の特定の秒数時点のデータを一貫性を持って読みたいんだけど、他トランザクションをブロックしちゃいませんよね?」
もし、君のチームのメンバーが「リレーショナルデータベースだから、重い参照系クエリは排他制御やロック競合を考慮して…」などと口走っていたら、即座にそのホワイトボードを止めてほしい。Cloud Spannerは、君たちが長年苦しめられてきた「ロックの呪縛」を、物理法則(正確にはアトミック時計とGPS)への信頼によって断ち切ったシステムなのだから。
今回は、Spannerのコアキテクチャの真髄である「TrueTimeとスナップショット分離(Snapshot Isolation)」のメカニズムを、実務で戦うエンジニアの視点から徹底的に解剖する。
—
1. 概念の破壊:Spannerにおける「時間」の再定義
分散システムにおける最大の敵は何か? それは「各ノード間の時計のズレ(Clock Skew)」だ。
「Aのサーバーで12:00:00.000に起きたイベント」と「Bのサーバーで12:00:00.001に起きたイベント」、どちらが先か? ネットワーク遅延とクロックの誤差がある限り、分散環境でこれを厳密に保証することは理論上極めて困難だった。
従来のデータベースは、これを解決するために「ロック」を使った。読み取りと書き込みが衝突すれば、どちらかを待たせる。結果、スケールアウトの限界(ボトルネック)にぶつかる。
しかし、Spannerの発想は違う。
「時間は不確実なもの(Uncertainty)として扱えばいい。その代わり、その不確実性の幅を極限まで狭め、世界中のデータセンターで同期させれば、ロックなど不要になる」
これを具現化したのが TrueTime API だ。
TrueTimeの正体:絶対時間ではなく「幅($\epsilon$)」を返すAPI
TrueTimeは、現在時刻を単一の値として返すのではなく、誤差の幅($\epsilon$: エプシロン)を持った時間範囲(Time Interval)として返す。
- `TrueTime.now()` が返す値:`[t.earliest, t.latest]`
- ここで保証されるのは、「真の絶対時間は、必ずこの `earliest` と `latest` の間に存在する」という物理的保証である。
Googleのデータセンターには、各ラックにGPS受信機と原子時計(ルビジウム原子時計)が配置されている。GPSが狂えば原子時計が補正し、原子時計がドリフトすればGPSが補正する。これにより、世界中のSpannerノード間における時間の不確実性 $\epsilon$ は、通常 1〜7ミリ秒程度 に抑え込まれている。
—
2. スナップショット分離(Snapshot Isolation)の内部実装
このTrueTimeの仕組みがあるからこそ、Spannerは「ロックなしでの厳密な外部一貫性(External Consistency / Linearizability)」を達成している。
書き込み(Commit)と読み取り(Read)の内部フローを見てみよう。
① 書き込み時のタイムスタンプ割り当て
トランザクション $T_1$ がコミットする際、Spannerはリーダーレプリカで `TrueTime.now()` を呼び出す。
そして、そのコミットメントに割り当てられるタイムスタンプ $s$ は、`latest` よりも未来の時刻に設定される。
> 【実務的ポイント:Commit Wait】
> Spannerは、TrueTimeの不確実性($\epsilon$)の分だけ、コミットを意図的に待機させる。これを Commit Wait と呼ぶ。
> 「トランザクション $T_1$ がコミットした」という事実が世界中のどのノードから見ても過去のものになるまで、わずか数ミリ秒($2\epsilon$ 分)待つことで、グローバルな因果関係の逆転を物理的に防いでいるのだ。
② 読み取り時の「ロックフリー・スナップショット」
さて、ここからが本題だ。過去の特定のタイムスタンプ $T_{read}$ におけるデータを読み取る処理(Stale Read)や、現在のトランザクション内でのリードを考えてみよう。
Spannerのストレージエンジン(Colossus上に構築されたLSMツリーベースの構造)には、すべてのデータにタイムスタンプが付与されている(マルチバージョン concurrency control: MVCC)。
1. クライアントがタイムスタンプ $T_{read}$ での読み取りを要求する。
2. Spannerは、該当する行のバージョン履歴から、$T_{read}$ 時点において有効だった最新のデータバージョンを特定する。
3. ロックは一切取得しない。 書き込み中のトランザクションが存在しようとも、過去のバージョンはイミュータブル(不変)としてストレージに存在するため、競合することなく安全にスキャンできる。
[タイムライン]
—(T1 Commit: t=10.05)—————————————->
Read Request (T_read = 9.00) –> ロックなしで t=9.00 時点のデータを即座に取得
Stale Read (T_read = 11.00) –> Commit Wait完了後のため、T1の変更が「見える」
このメカニズムにより、「どれだけ重いアナリティクス・集計クエリを走らせようとも、同時実行されている高頻度の書き込みトランザクションを一切ブロックしない」という、極上のスケーラビリティが担保される。
—
3. 実務での設計パターン:Stale Readを使い倒せ
このアーキテクチャの恩恵を最も受けるのが、Stale Read(過去読み取り)の活用だ。設計レビューで私がよく提案する具体的なユースケースとコードパターンを見ていこう。
パターンA:整合性が保証されたダッシュボード・レポーティング
「リアルタイム性よりも、全社集計やダッシュボードの整合性(データがチグハグにならないこと)が重要」という要件は多い。通常のトランザクションでこれをやると負荷が高いが、Spannerなら過去の固定されたタイムスタンプで安全に抜ける。
package main
import (
“context”
“fmt”
“time”
“cloud.google.com/go/spanner”
)
// RunStaleReadExample は、5分前の正確なスナップショットに対してクエリを実行する例
func RunStaleReadExample(ctx context.Context, client spanner.Client) error {
// 5分前の正確なタイムスタンプを指定
targetTime := time.Now().Add(-5 time.Minute)
// ExactStaleRead(t) を使用することで、ロックなしでその瞬間の完全な一貫性スナップショットを得る
txn := client.ReadOnlyTransaction().WithTimestampBound(spanner.ExactStaleRead(targetTime))
defer txn.Close()
stmt := spanner.Statement{SQL: `SELECT SUM(amount) FROM orders`}
iter := txn.Query(ctx, stmt)
defer iter.Stop()
for {
row, err := iter.Next()
if err == iterator.Done {
break
}
if err != nil {
return err
}
var totalAmount int64
if err := row.Column(0, &totalAmount); err != nil {
return err
}
fmt.Printf(“5分前の総売上 (at %v): %d\n”, targetTime, totalAmount)
}
return nil
}
パターンB:CQRS(Command Query Responsibility Segregation)のリード側最適化
書き込み系(OLTP)と参照系(OLAP/ダッシュボード)を分離する際、従来のDBならリードレプリカへのレピュケーション遅延(Replication Lag)に悩まされていたはずだ。「レプリカによってデータが古かったり新しかったりする」という不整合にアプリケーション側で対処しなければならなかった。
SpannerのStale Readであれば、グローバルに同期された単一のデータベースに対して、遅延の概念なく「過去の特定時点」を指定して安全に参照できる。レプリカ遅延の設計に頭を悩ませる時間は、今日で終わりにしよう。
—
4. チーフアーキテクトからの警告:パフォーマンス上の罠
最後に、この強力なスナップショット分離を実務で扱う際の「落とし穴」を伝えておく。
罠1:GC(ガベージコレクション)の限界を超える古いタイムスタンプ
Spannerは、過去のデータバージョンを無限に保持しているわけではない。デフォルトでは、過去最高 1時間(Oldest Version Retention Period)までのバージョンが保持され、それを過ぎた古いデータはガベージコレクションによって物理削除される。
もし、`spanner.ExactStaleRead` で1時間以上前のタイムスタンプを指定した場合、次のようなエラーが発生する。
> `Flipping to past failed because the timestamp is too old.`
設計上の教訓:
バッチ処理などで過去データを参照する場合でも、保持期間(デフォルト1時間、最大7日まで拡張可能だがコストに注意)の範囲内に必ず収めること。
罠2:不適切なタイムスタンプバウンドの選択
読み取りトランザクションを作成する際、用途に応じたBoundを正しく選択しなければならない。
- `spanner.StrongRead()`: 現在の最新状態を保証して読む(内部でTrueTimeの最新値を確認)。
- `spanner.ExactStaleRead(t)`: 指定した正確な時刻 $t$ で読む。
- `spanner.MaxStaleRead(duration)`: 「現在時刻から最大これだけ過去であれば、新しさは問わない」というモード。スループットを極限まで高めたい場合、ローカルレプリカのキャッシュヒット率が上がるため強力だが、データの鮮度が落ちる。
「とりあえず全部 StrongRead にしておく」という安易な設計は、システムのポテンシャルを殺す。クエリの要件(厳密なリアルタイム性が必要か、数秒・数分の古さが許容されるか)を精査し、適切なバウンドを選択してほしい。
—
結びにかえて
Cloud Spannerのスナップショット分離とTrueTimeの組み合わせは、分散データベースにおける「可用性と整合性のジレンマ(CAP定理)」に対するGoogleからの美しき回答だ。
「ロックをしない。しかし、時間は物理的に厳密に共有する」
このアーキテクチャの底流にある思想を理解していれば、もはや君たちの設計レビューで「パフォーマンスのために整合性を妥協する」という選択肢は消え去るはずだ。
さあ、コードを開き、真のグローバルスケールと一貫性を両立したシステムを構築しよう。
コメント