【実務・中級編】 ストレージノードの内部アーキテクチャ – Cloud Spanner

【極限の深層】Cloud Spannerストレージエンジンの正体――LSMツリー、Ressi、そしてメモリキャッシュの物理ダイナミクス

「Cloud Spannerは、強整合性を保ったままグローバルに水平スケールする魔法のデータベースである」

もしあなたがSpannerをこのように一段高い抽象レイヤだけで理解しているなら、実務のミッションクリティカルな局面で必ずパフォーマンスの壁にぶち当たることになります。Spannerは魔法ではありません。その極めて冷徹で合理的な分散ストレージアーキテクチャの上に成り立つ、巨大な物理システムです。

特に、Spannerの「計算(Spannerノード)とストレージ(Colossus)の完全分離」という大前提と、その上で駆動するLSMツリー派生ストレージエンジン(Ressi)、そしてメモリ上のBlock Cacheの挙動を理解することは、スループットを極限まで引き出し、レイテンシのスパイクを防ぐために不可欠です。

本稿では、一般的なリファレンスには書かれていない、Spanner内部の低レイヤなストレージダイナミクスを解剖します。プロジェクトのテクニカルリードとして、設計・コードレビューで「本当に通る設計」を提示するためのディープな知見をお届けします。

—

1. 物理アーキテクチャの基本:計算とストレージの分離

Spannerのパフォーマンス特性を理解する大前提は、「Spannerノード自体はデータを永続保持しない(ほぼステートレスである)」という事実です。

+————————————————————-+
| Client Application |
+————————————————————-+
|
v
+————————————————————-+
| Spanner Compute Nodes (Memory: MemTable, Block Cache, etc.) |
+————————————————————-+
| | |
[gRPC/Network] [gRPC/Network] [gRPC/Network]
| | |
+————————————————————-+
| Colossus Distributed File System (SSTable) |
+————————————————————-+

実際のデータ(SSTableファイルなど)は、Googleの堅牢な分散ファイルシステムであるColossusに格納されています。Spannerノードは、計算リソース(CPU)と、MemTableや各種キャッシュ(Block Cache、Schema Cacheなど)を保持するメモリリソースを提供します。

この分離アーキテクチャこそが、ノードの追加・削減を数分で完了させ、ノード障害時にもデータを一切失わずに瞬時にフェイルオーバーできる秘密です。しかし同時に、「ディスク(Colossus)からのデータ読み込みは、常にネットワーク(gRPC)を介したリモートI/Oになる」という宿命を意味しています。

だからこそ、Spannerノード内の「メモリ管理(キャッシュ)」と「LSMツリーの構造」を最適化することが、システム全体の死活問題となるのです。

—

2. Spannerの心臓部:独自LSMエンジン「Ressi」

Spannerのストレージエンジンは、歴史的なLSM(Log-Structured Merge)ツリーの思想を受け継ぎつつ、分散SQLデータベースおよびMVCC(多バージョン並行処理制御)に特化してGoogleが独自に開発した「Ressi」と呼ばれるカラム型/行型ハイブリッドレイアウトを採用しています。

書き込みのライフサイクル:MemTableからColossusへ

Spannerへの書き込み(Write/Mutation)が発生した時、データは以下のステップをたどります。

[Write Request]
|
+—> 1. Paxosによるレプリケーション合意
|
+—> 2. Commit Log の Colossus への永続化(WAL)
|
+—> 3. Spannerノードのメモリ上「MemTable」への書き込み(即時応答)
|
v (MemTableが一定サイズに達するとフラッシュ)
4. Colossus上に「Ressiファイル(SSTable)」として書き出し

1. PaxosレプリケーションとCommit Log:
書き込みはまずPaxosグループ内で合意され、コミットログがColossusに書き込まれます。
2. MemTable(メモリ):
同時に、Spannerノード上のメモリ内構造体である`MemTable`に書き込まれます。この時点でクライアントには書き込み完了が返ります。
3. FlushとCompaction:
`MemTable`が一定サイズに達すると、Colossus上に不変(Immutable)なファイルとしてバックグラウンドで書き出されます。これがLSMにおける「Flush」です。
Colossus上に細切れになったファイルが増えると、読み込み時に複数のファイルを探索(マルチバージョン・マージ)しなければならず、リードパフォーマンスが低下します。そのため、バックグラウンドでこれらのファイルをマージ・再整理する「Compaction(コンパクション)」が常に実行されています。

Ressiのデータレイアウト:MVCCとカラムナの融合

Ressiが一般的なRocksDBなどのLSMエンジンと異なるのは、「タイムスタンプ(MVCCバージョン)を第一級オブジェクトとして内包している点」と、「行指向と列指向(カラムナ)をハイブリッドに配置している点」です。

Ressiファイル内部では、データは「ブロック(通常は数KB〜数十KB)」単位で区切られています。

  • キー構造: `[UserKey, Timestamp] -> Value`
  • ブロック化: 同一の主キーに対する異なるバージョンのデータや、隣接する主キーのデータが物理的に近くに配置され、高効率な圧縮が施されます。

—

3. メモリキャッシュの極限管理:Block Cache と SSTable Index

ColossusへのリモートI/Oを極限まで減らすため、Spannerノードは潤沢なメモリを以下のキャッシュレイヤに割り当てています。

+————————————————————-+
| Spanner Node Memory |
| |
| +——————+ +———————————+ |
| | MemTable | | Block Cache | |
| | (Active Writes) | | [Uncompressed Data Blocks] | |
| +——————+ +———————————+ |
| +———————————+ |
| | SSTable Index Cache | |
| | (Key Ranges -> File Offsets) | |
| +———————————+ |
+————————————————————-+

SSTable Index Cache

Colossus上の各Ressiファイル(SSTable)のどの位置にどのキー範囲があるかを示すインデックスをメモリ上に保持します。これがメモリから溢れると、キーの検索場所を特定するためだけにColossusへのI/Oが発生し、レイテンシが致命的に悪化します。

Block Cache

Colossusから読み込んだ「展開済み(解凍済み)のデータブロック」をメモリ上に保持する領域です。
Spannerへの読み込みリクエストが届いたとき、エンジンは以下の順にデータを探索します。

1. MemTable: 最新の未フラッシュデータを検索。
2. Block Cache: メモリ上にキャッシュされたデータブロックを検索(ミリ秒未満の超高速応答)。
3. Colossus: キャッシュミスした場合、ネットワーク経由でRessiファイルから該当ブロックを読み込み、展開してBlock Cacheに載せる(数ミリ秒〜数十ミリ秒のペナルティ)。

【超重要】キャッシュの「冷え(Cold Cache)」とスプリット移動

Spannerは負荷やデータ量に応じて、データを管理する範囲(Split:スプリット)を動的に他のノードへ移動(Rebalance)させます。
スプリットが新しいノードに移動した瞬間、移動先のノードのBlock Cacheにはそのスプリットのデータが載っていません(Cold Cache状態)。このため、スプリット直後の一時的なレイテンシのブレ(Tail Latencyのスパイク)が発生することがあります。

—

4. 実務で直面するパフォーマンスの罠と「極限の設計パターン」

LSMツリーとBlock Cacheの物理構造を理解した今、我々が設計レビューで排除すべき「本質的なアンチパターン」とその対策をロジカルに解説します。

アンチパターン1:シーケンシャルな主キー(AUTO_INCREMENT、Timestamp)による特定ノードへの負荷集中とキャッシュ排他

LSMツリーは、キー順にソートされた状態を維持します。そのため、主キーに「現在時刻のタイムスタンプ」や「単純増加するID」を使用すると、すべての書き込みが常に同一のスプリット(=特定の1台のSpannerノード)に集中します。

物理レベルで起きること:

1. 特定ノードの`MemTable`だけが異常に肥大化し、頻繁にFlushが発生。
2. そのノードのBlock Cacheが、書き込まれたばかりの最新データだけで埋め尽くされ、他の静的なデータのキャッシュが押し出される(Cache Thrashing)。
3. 結果として、システム全体のキャッシュヒット率が劇的に低下する。

対策:ビット反転(Bit-reversal)またはハッシュプレフィックスの強制

コードレビューでは、主キーの定義を厳しくチェックしてください。

— アンチパターン:書き込みが右端のノードに集中する
CREATE TABLE Orders (
OrderId INT64 NOT NULL, — 単純増加ID
OrderDate TIMESTAMP NOT NULL,
CustomerId STRING(36),
) PRIMARY KEY (OrderId);

— 推奨パターン:ハッシュ値またはシャードIDを先頭に付与して分散させる
CREATE TABLE Orders (
ShardId INT64 NOT NULL, — Hash(OrderId) % N などで求めたシャードID
OrderId INT64 NOT NULL,
OrderDate TIMESTAMP NOT NULL,
CustomerId STRING(36),
) PRIMARY KEY (ShardId, OrderId);

> リードからのアドバイス:
> 「UUID v4」を主キーにすることもデータの分散には有効ですが、完全にランダムなUUIDは逆に「あらゆる読み込みがBlock Cacheをかすりもしない(キャッシュミス率100%)」という別の地獄を招きます。データ量がBlock Cache容量を大きく超えるシステムで完全ランダム読み出しを行うと、ColossusへのリモートI/Oが多発します。
> データの「局所性(Locality)」を意識し、同一の顧客、同一のテナント内のデータは同一スプリットに集まるよう、`ShardId` や `CustomerId` を主キーの第一要素に据える設計(インターリーブの活用)を徹底してください。

—

アンチパターン2:巨大な行(Wide Row)と不要な複数バージョンの温床

SpannerはMVCCデータベースです。データを更新(Update)した際、元のデータがその場で書き換えられるのではなく、「新しいタイムスタンプを持つ新しい行(またはカラム)」がLSMツリーに追加されます。

物理レベルで起きること:

もし1行のサイズが数メガバイトに及ぶような巨大な行(大きなバイナリデータや、巨大なJSON、あるいは100以上のカラムを持つテーブル)に対して頻繁にUpdateを行うと、
1. LSMの同一キーに対して、大量の履歴バージョンがColossus上に蓄積される。
2. 読み込み時、Ressiは「どのバージョンが有効か」を判定するために、複数のブロックをまたいでマージ処理を行う必要が生じる。
3. Block Cacheのメモリ消費が、この「不要になった古いバージョン」や「巨大な1行の一部」によって圧迫され、実質的な有効キャッシュ容量が減少する。

対策:カラムファミリーの分離とスキーマの正規化

頻繁に更新されるステータスカラムやカウンタと、滅多に更新されない静的なプロファイルデータは、テーブルを分割(またはインターリーブ)して物理的に分離します。

— 悪い設計:頻繁に更新される項目と巨大なテキストが同居している
CREATE TABLE UserProfiles (
UserId STRING(36) NOT NULL,
BioText STRING(MAX), — 巨大なテキスト(めったに更新されない)
LastLoginAt TIMESTAMP, — 頻繁に更新される
StatusCount INT64, — 頻繁に更新される
) PRIMARY KEY (UserId);

— 良い設計:更新頻度とデータサイズでテーブルを分ける(インターリーブによる親子関係の構築)
CREATE TABLE Users (
UserId STRING(36) NOT NULL,
BioText STRING(MAX),
) PRIMARY KEY (UserId);

CREATE TABLE UserStats (
UserId STRING(36) NOT NULL,
LastLoginAt TIMESTAMP,
StatusCount INT64,
) PRIMARY KEY (UserId),
INTERLEAVE IN PARENT Users ON DELETE CASCADE;

> リードからのアドバイス:
> インターリーブ(Interleave)を使用すると、親行と子行のデータが物理的に同じColossusのブロック内(または極めて近い場所)にコロケーション(同居)されます。
> これにより、`UserStats` を読み込む際に `Users` のデータも同時にBlock Cacheに載るため、結合(JOIN)や同時取得の際のネットワークI/Oを完全にゼロに抑えることができます。

—

5. テクニカルリードの目:コードレビュー/設計レビューでのチェックリスト

あなたがプロジェクトのアーキテクトとしてメンバーの設計をレビューする際は、以下のチェックリストを頭に叩き込んでおいてください。

| チェック項目 | 物理的な着眼点 | 修正アクションの例 |
| :— | :— | :— |
| 主キーに連続値が使われていないか? | 特定のSpannerノードへの書き込み集中、MemTableの局所的バーストを防ぐ。 | `Id` の前にハッシュベースの `ShardId` を置く、あるいはビット反転(Bit-reversal)を適用させる。 |
| `STRING(MAX)` の多用や巨大なJSONを格納していないか? | Ressiファイルのブロックサイズを肥大化させ、Block Cacheを無駄に圧迫するのを防ぐ。 | 大容量バイナリはGCS(Google Cloud Storage)に保存し、SpannerにはそのURIのみを保持させる。 |
| インターリーブ(Interleave)が適切に設計されているか? | コロケーションを強制し、Colossusへの複数回I/OとBlock Cacheミスを排除する。 | 1対多の親子関係(例:`Customers` と `Orders`)がある場合、積極的に物理的な配置を親子にする。 |
| Stale Read(古いデータの読み取り)を許容できるクエリか? | 厳密な強整合性(Strong Read)が不要な画面(ダッシュボード等)での、Paxos合意や最新MemTableへのアクセス負荷を下げる。 | クエリ実行時に `ExactStaleness` や `MaxStaleness`(例:15秒前のデータでOK)を指定するように指示する。これにより、リーダーレプリカだけでなく、近くの読み取り専用レプリカのBlock Cacheから即座に応答可能になる。 |

—

まとめ:物理構造を知る者だけが、Spannerを100%手なずけられる

Cloud Spannerは、開発者に「分散システムの複雑さを意識させない」という最高の抽象化を提供してくれます。しかし、その抽象化の下では、「LSMツリー(Ressi)へのシーケンシャルな書き出し」「Colossusとの間のネットワークI/O」「Spannerノード上のBlock Cacheの奪い合い」という、極めて泥臭い物理現象が常に起きています。

主キーひとつ、カラムのデータ型ひとつを決める時、「このデータはどのようにRessiファイルに並び、どうBlock Cacheを消費するのか」を脳内に3Dで描き出せるようになってください。それこそが、システムを限界まで高速化し、どんな高負荷下でもビクともしないシステムを構築できる、伝説的アーキテクトへの第一歩です。

コメント

タイトルとURLをコピーしました