Cloud Spannerの「直列化可能(Serializable)」はなぜ破綻しないのか? — 分散世界における最強の整合性を手懐ける
設計レビューの場において、若手エンジニアからこんな質問を受けたことはないだろうか。
「Spannerって、世界中のリージョンにデータを分散させながら、なぜACID特性、しかも最強の『直列化可能(Serializable)』を完全に担保できるんですか? パフォーマンスのためにどこかで整合性を妥協しているはずですよね?」
私はこう答える。「妥協などしていない。物理法則の限界に挑みながら、数学とハードウェアの力で正面突破しているのだ」と。
多くのエンジニアが「NoSQLはスケールする代わりに整合性を捨てる」「RDBは整合性を保つ代わりにスケールしない」という古いジレンマに囚われている。しかし、Cloud Spannerはその常識を打ち破った。
今回は、Spannerのコアキテクチャの心臓部であり、すべての設計・運用の土台となる「直列化可能分離(Serializable Isolation)」の保証メカニズムについて、実務で戦うエンジニア向けに容赦ない深掘りをお届けしよう。
—
1. 概念の再定義:Spannerにおける「直列化可能」とは何か
ANSI/ISO SQLで定義される「直列化可能性(Serializable)」は、理論上最高位の分離レベルだ。複数のトランザクションが同時に実行されても、その結果が「それらを完全に1つずつ順番に実行した(逐次的実行)」結果と数学的に完全に一致することを保証する。
一般的な分散データベース(あるいは分散トランザクションをうたうミドルウェア)では、これを実現しようとしてスループットが激減したり、分散ロックのデッドロック地獄に陥ったりする。
しかし、Spannerは異なる。「2相ロック(2PL)とタイムスタンプ順序付け(Timestamp Ordering)」のハイブリッドという、極めてエジニアリングのロマンに満ちたアプローチを採用している。
なぜ両方使うのか?
- 2相ロック(2PL)の課題: 競合するデータに対して排他・共有ロックを取るため、ホットスポットでロック競合が起きるとスループットが急降下する。
- タイムスタンプ順序付けの課題: 分散システムにおいて「真のグローバルな現在時刻」を正確に同期することは、光速の制約(ネットワーク遅延)があるため理論上不可能に近い。
Spannerはこの「不可能」をTrueTime APIというハードウェア(GPS + 原子時計)の裏技で解決し、その上で2PLを極限まで洗練させることで、低レイテンシと直列化可能性の両立を実現している。
—
2. 内部メカニズム:TrueTimeと分散トランザクションの舞踏
Spannerのトランザクションが裏側でどのように処理されているか、その生命線をコードとシークエンスの視点で見てみよう。
2.1 読み取り・書き込みトランザクションの裏側
Spannerの読み取り・書き込みトランザクション(Read-Write Transaction)は、以下のステップで厳密に裁定される。
1. 実行フェーズ(Read Phase):
アプリケーションからのクエリ実行中、データはリーダーレプリカから読み込まれる。この際、競合を防ぐために読み取りロック(Shared Locks)または楽観的並行制御の準備が進められる。
2. コミットフェーズ(Commit Phase / 2PL + Two-Phase Commit):
トランザクションが完了すると、変更内容がパキッと固められる。ここで2相コミット(2PC)が発動する。
3. TrueTimeによるタイムスタンプ割当:
ここが神髄だ。コーディネータースパナノードは、TrueTimeから「絶対的な現在時刻の幅($[t_{earliest}, t_{latest}]$)」を取得する。そして、その不確実性の幅($\epsilon$、通常数ミリ秒以内)が収束した後の安全な未来のタイムスタンプ $s$ をコミットタイムスタンプとして強制的に割り当てる。
— 【実務的アンチパターンに繋がる例】
— 単一のホットスポット行(例:カウンターやアグリゲーション用レコード)を
— 頻繁にRead-Writeトランザクションで更新する設計。
— これをやると、2PLのロック競合によりスループットが致命的に落ちる。
UPDATE Inventory
SET Stock = Stock – 1
WHERE ItemId = ‘item-001’;
この時、Spannerの内部では次のような順序保証がなされる。
> 「トランザクション $T_2$ の開始リアルタイムが、トランザクション $T_1$ のコミット完了リアルタイムより後であれば、Spanner内部のコミットタイムスタンプも必ず $T_2 > T_1$ となる」
これを外部一貫性(External Consistency)または線形化可能性(Linearizability)と呼ぶ。直列化可能性のさらに上位に位置する性質だ。
—
3. 堅牢な設計パターン:競合を避け、直列化の恩恵を最大化する
「Spannerは何でも自動でよしなにやってくれる」という誤解のもと、何も考えずにテーブル設計を行うと、思わぬレイテンシ悪化(トランザクションアボートの頻発)に直面する。
実務で堅牢なシステムを構築するための設計パターンを授けよう。
パターンA: 競合を分散する「シャードキー(分散キー)」の導入
もしあなたが「1つのアカウントの残高」や「1つの在庫アイテム」を毎秒数千回更新するシステムを作っているなら、Spannerであってもロック競合によるアボート(`ABORTED` エラー)の嵐に見舞われる。
アンチパターン:
Table: AccountBalance
- AccountID (PK)
- Balance
(`AccountID = ‘user_123’` にトラフィックが集中し、ロック待ちで直列化が詰まる)
堅牢な設計(シャーディング):
カウンターを論理的に分割し、最後に集約する。あるいは、書き込みのパスを分散させるためにハッシュプレフィックスを付与する。
Python (Google Cloud Spanner Client) のリトライハンドリングを含む堅牢なトランザクション例
from google.cloud import spanner
def update_balance_safely(database, account_id, amount):
# スパナークライアントはトランザクションのアボートを自動リトライする仕組みを持つが、
# アプリケーション層でも競合を意識した設計が不可欠。
def transaction_block(transaction):
# データの読み取り
row = transaction.read(
table=”Accounts”,
columns=[“Balance”],
key_set=spanner.KeySet(keys=[[account_id]])
).one()
current_balance = row[0]
new_balance = current_balance + amount
# データの書き込み
transaction.update(
table=”Accounts”,
columns=[“AccountID”, “Balance”],
values=[[account_id, new_balance]]
)
database.run_in_transaction(transaction_block)
パターンB: 読み取り専用トランザクション(Read-Only Transaction)の徹底活用
実務において、システムの負荷の8割以上は「読み取り」である。Spannerの真価は、読み取り専用トランザクションであれば、ロックを一切取得せずに実行できる点にある。
- バウンド読み取り(Bounded Stale Reads):
「過去の特定のタイムスタンプ」や「許容できる最大遅延(例: 5秒以内)」を指定して読み取る。これにより、最新の書き込みブロックを完全にバイパスし、地球の裏側のレプリカからでもゼロロックで超高速にデータを引っこ抜くことができる。
過去10秒時点のスナップショットをロックフリーで取得(高スループット・低レイテンシ)
with database.snapshot(exact_staleness=datetime.timedelta(seconds=10)) as snapshot:
results = snapshot.execute_sql(“SELECT FROM LargeTable WHERE …”)
for row in results:
process(row)
ダッシュボードやレポーティング機能など、ミリ秒単位の最新性を求めないクエリには、必ずこのバウンド読み取りを強制すべきだ。これを使わずにすべての参照をRead-Writeで行っているとしたら、それはフェラーリで砂利道を走っているようなものだ。
—
4. パフォーマンス上の注意点とトラブルシューティング
直列化可能分離を維持代償として、開発者が踏みがちな地雷がいくつかある。
1. ホットスポット(Hotspotting)の盲点:
連番(オートインクリメント)やタイムスタンプをそのままプライマリキーの先頭に置くと、すべての新しい書き込みが単一のスプリット(データシャード)に集中する。Spannerは自動分割・自動ロードバランスを行うが、急激なトラフィック増には追いつかず、書き込みが直列化のボトルネックで直撃を受ける。
対策: UUIDv4や、逆転ビットを付与したキー、ハッシュ化されたプレフィックスを使用すること。
2. トランザクション内の非決定的な処理(I/Oや外部API呼び出し):
トランザクションブロック内で外部のREST APIを叩いたり、現在時刻(ローカルのシステム時計)を直接取得してロジックに組み込んではならない。トランザクションがアボートしてリトライされた際、外部状態が変わっていると、直列化の整合性が破綻するか、予期せぬバグを生む。
3. ロングランニング・トランザクション(長寿命トランザクション)の弊害:
トランザクションを開いたまま重い集計処理や外部通信を行うと、ロックやタイムスタンプの維持コストが増大し、他のトランザクションのコミットをブロックし続ける。最悪の場合、デッドロックやタイムアウト(`ABORTED`)の連鎖を引き起こす。トランザクションは「短く、太く、素早く」が鉄則だ。
—
5. チーフアーキテクトからの提言
Cloud Spannerの「直列化可能分離」は、魔法の杖ではない。それは、物理学(TrueTime)と分散システム理論(2PL/2PC)の極限の融合によって築き上げられた「厳格なルールの上で成り立つ高速道路」だ。
ルールを無視して逆走(ホットスポットへの集中、長寿命トランザクション、不要なRead-Writeの乱用)すれば、どれほど高価なインスタンスを並べてもクラッシュする。しかし、アーキテクチャの本質を理解し、「書き込みは最小限に、読み取りはスナップショットを活用し、キー設計で負荷を分散する」という原則をコードに落とし込めば、これほど信頼性が高く、スケールの天井が見えないデータベースは他に存在しない。
次の設計レビューでは、チームメンバーが書いたクエリの裏側にある「タイムスタンプとロックの軌跡」に目を凝らしてほしい。そこに美しい直列化の秩序が見えた時、あなたのシステムは真のグローバルスケールへと到達する。
コメント