【Spannerの深層】マルチリージョンにおける「レプリケーションラグ」の幻想と、真のエンジニアリング
こんにちは。テクニカルリードの私だ。
今日のコードレビュー、あるいはアーキテクチャ設計レビューで、こんな質問を受けたとしよう。
> 「マルチリージョン構成でリーダーからフォロワーへデータがレプリケーションされる時、ラグ(遅延)が発生しますよね? その間のリードで古いデータを読まないための対策や、書き込みのコンフリクト制御はどう書けばいいですか?」
……この質問が出た瞬間、私はこう言わざるを得ない。
「君は本当にCloud Spannerを使っているのか、それとも昔ながらの分散DBのトラウマを引きずっているだけか?」と。
一般的な分散データベース(MySQLのレプリカ、MongoDB、あるいは一部のNoSQL)では、非同期レプリケーションによるラグが設計の悪夢となる。だが、Cloud Spannerの土俵はそこではない。
今回は、マルチリージョン構成における「レプリケーションラグ」の正体を暴き、Spannerの強整合性モデルを前提とした『真に堅牢な設計パターン』を叩き込む。
—
1. 「レプリケーションラグ」という名の幻影
まず、前提を破壊しよう。
Cloud Spannerのマルチリージョン構成(例: `nam-region-3` や `asia1`)において、アプリケーションが意識すべき「レプリケーションラグ」は、トランザクションの文脈においては存在しない。
なぜか?
Spannerは、Paxosアルゴリズムを用いた合意形成と、TrueTime APIによる絶対時刻の同期を組み合わせることで、グローバル規模での外部整合性(External Consistency / 直列可能性)を担保しているからだ。
読み取りにおけるラグの不在
マルチリージョン構成で、仮に「リーダー(Write Leader)」が米国東部にあり、「フォロワー(Read-Write / Read-Only Replica)」が日本(アジア)にあったとしよう。
日本から書き込み(Commit)を行った場合、Paxosグループの過半数(Quorum)の合意形成が必要なため、物理的な光速の壁によるネットワークレイテンシ(数ミリ秒〜数十ミリ秒)はもちろん発生する。
しかし、ひとたびコミットが完了した瞬間、そのデータはグローバルで「読見込み可能」になる。フォロワーへのデータ同期が遅れているからといって、古いデータが読まれてしまうような「結果整合性(Eventual Consistency)」のモデルは、Spannerの標準(強整合性トランザクション)には存在しない。
[クライアント (日本)]
│
▼ 書き込み (Commit)
[Spanner リーダー (米国東部)] ──(Paxos合意形成)──► [フォロワー (日本)]
│ │
└────────────── 同期完了 (TrueTimeで保証) ─────────────┘
│
▼ 読み取り (Read-Write Transaction / 強整合性)
【絶対に古いデータは読まない(ラグゼロの世界)】
—
2. パフォーマンス上の注意点:じゃあ、何が「遅い」のか?
「ラグがないなら、マルチリージョンでもシングルリージョンと同じスピードで動くのか?」と問われれば、答えは「No」だ。
ここで多くのエンジニアが混同するのが、「データの同期遅延(レプリケーションラグ)」と「物理的なネットワーク遅延(レイテンシ)」である。
トランザクションにおけるレイテンシの代償
マルチリージョン構成では、コミット時に異なる大陸間のラウンドトリップが発生する。
例えば、東京から米国リージョンへの書き込みを含むトランザクションを実行する場合、Paxosの合意形成のために物理的な往復レイテンシ(約100ms〜150ms)が確実にかかる。
これを「レプリケーションラグのせいだ」と勘違いし、非同期的な救済措置をアプリケーション層で書こうとするアンチパターンが後を絶たない。
❌ 悪い設計例:非同期を気にして無駄なリトライやキャッシュを入れる
// アンチパターン: 「レプリケーションが追いついていないかも」という恐怖から、
// アプリケーション側で独自のStale読み取りやリトライループを実装してしまう
public UserData getUserWithRetry(DatabaseClient dbClient, String userId) {
int retries = 3;
while (retries > 0) {
// 独自に古いデータを弾こうとする無意味なロジック
UserData data = readFromSpanner(dbClient, userId);
if (data.isUpToDate()) {
return data;
}
Thread.sleep(50); // ラグを待つつもりでスリープ
retries–;
}
throw new RuntimeException(“Replica lag timeout!”);
}
【リードの評価】
Spannerにおいてこのコードは完全に無意味であり、単にレイテンシを悪化させ、コネクションを枯渇させるだけの毒でしかない。Spannerを信じろ。強整合性読み取りであれば、リトライしなくても最新データが取れる。
—
3. 実務で使うべき設計パターン:真の最適化アプローチ
では、マルチリージョン環境でパフォーマンスを極限まで引き出すにはどう設計すべきか。
ここで登場するのが、「Stale読取り(古くなったデータの読み取り)」と「ローカライゼーション」だ。
パターンA:許容できるなら「Stale読取り」でレイテンシを殺す
もしあなたのユースケースが「ダッシュボードの集計」「プロフィールの参照」「商品の静的詳細表示」など、1秒や2秒の古さが許容されるものであるなら、強整合性読み取りを使う必要はない。
Spannerの「Stale読取り」を使えば、手元のローカルフォロワー(日本)から、ネットワークの往復を最小限に抑えてデータを爆速で取得できる。
// 正しい設計例: Stale読取りを活用してレイテンシを劇的に削減する
public void readProfileWithLowLatency(DatabaseClient dbClient, String userId) {
// 厳密なリアルタイム性を求めない場合、過去のタイムスタンプを指定、
// または「正確にN秒前のデータ」を要求する
TimestampBound bound = TimestampBound.ofExactStaleRead(
Timestamp.now().minus(Duration.ofSeconds(5)) // 5秒前のスナップショット
);
try (ReadOnlyTransaction tx = dbClient.readOnlyTransaction(bound)) {
Struct row = tx.readRow(“Users”, Key.of(userId), Arrays.asList(“Name”, “Email”));
// ローカルのフォロワーからミリ秒単位の応答でデータを取得
System.out.println(“User: ” + row.getString(“Name”));
}
}
パターンB:マルチリージョン構成の適切な選択
アーキテクチャ設計の段階で、ワークロードに応じたリージョン構成を選ぶことこそが最大の対策だ。
1. デュアルリージョン / マルチリージョン(Read-Write分散型)
- 書き込みリーダーを特定のリージョンに固定せず、アクセス元のトラフィックに応じて動的にルーティング、あるいはアプリケーション側で適切なリージョンのエンドポイントを叩く。
2. テーブル設計でのコロケーション(Colocation)
- 親子関係にあるテーブル(例:`Customers` と `Orders`)を `INTERLEAVE IN PARENT` で定義し、同じ物理ストレージブロックに配置する。これにより、マルチリージョン環境下でもクロスリージョンのJOINやスキャンを最小限に抑えることができる。
—
4. チーフアーキテクトからの最終提言
コードレビューで「レプリケーションラグ対策」という言葉が出てきたら、それは黄色信号だ。そのエンジニアは、Cloud Spannerを単なる「少し高価なリレーショナルデータベース」と勘違いしている。
Cloud Spannerにおけるマルチリージョン設計の要諦は以下の3点に集約される。
1. 書き込み(強整合性)の物理レイテンシは避けられない(光速の壁を受け入れ、書き込み頻度を設計で最適化する)。
2. 読み取りは強整合性であればラグはない(古いデータが読まれる心配は不要なので、無駄なリトライや同期待ちを書かない)。
3. レイテンシが命取りになるリードは「Stale読取り」でローカルフォロワーを叩け(ビジネス要件と整合性のトレードオフをコードで制御する)。
DBの底力を信じろ。アーキテクチャの原則を正しく理解していれば、Spannerは君の期待を裏切らない最強の武器となる。
さて、次のプルリクエストのレビューに戻るとしよう。
コメント