【実務・中級編】 Generated Columns内部実装 – Cloud Spanner

Cloud SpannerのGenerated Columns(生成列)は「魔法の糖衣」ではない:内部実装から逆算する極限のデータモデリング

テックリードの私だ。今日のコードレビューで、あるジュニアエンジニアがこんなテーブル定義を出してきた。

— ❌ やってはいけないアンチパターンの一例
CREATE TABLE UserActivityLogs (
UserId STRING(36) NOT NULL,
EventTime TIMESTAMP NOT NULL,
RawPayload STRING(MAX) NOT NULL,
— JSONから毎回抽出する生成列
ActionType STRING(MAX) AS (JSON_VALUE(RawPayload, ‘$.action’)) STORED,
) PRIMARY KEY(UserId, EventTime);

「お、Generated Columns(生成列)を使ってクエリを簡素化しているね。スマートだ」——と思ったそこのあなた。Cloud Spannerのコアアーキテクチャ、特に分散ストレージとトランスポート層の挙動を理解していなければ、このコードは本番環境で確実にスループットのボトルネックを踏み抜く。

Cloud SpannerにおけるGenerated Columnsは、単なる「便利なシンタックスシュガー」ではない。分散ストレージ層における物理レイアウトの制御機構であり、書き込みコストと読み取りレイテンシのトレードオフを支配する強力な武器だ。

今回は、SpannerのGenerated Columnsが内部でどう評価され、どうストレージに配置され、クエリ実行時にどう振る舞うのか。その深淵を解き明かしていく。

—

1. 生成列の基本と、Spannerにおける2つの「魂」

まず前提を合わせよう。Cloud Spannerの生成列には2つのタイプがある。

1. `STORED` 生成列: 計算結果が物理ストレージに永続化される。
2. 非 `STORED`(デフォルトの仮想列): ストレージには持たず、クエリ実行時にオンザフライで計算される。

多くのエンジニアが「ストレージ容量をケチりたい」「常に最新のロジックで計算させたい」という安易な理由でデフォルト(非 `STORED`)を選びがちだが、分散データベースの文脈においてこれは大きな間違いを孕んでいる。

それぞれの内部挙動を、Spannerのアーキテクチャから逆算して見ていこう。

—

2. 内部実装の真実:評価タイミングとストレージの物理配置

`STORED` 生成列:書き込み時のコストと、極限まで最適化された読み取り

`STORED` を指定した場合、Spannerはその列を「普通の物理カラム」と全く同じように扱ってストレージに書き込む。

  • 評価タイミング: `INSERT` または `UPDATE` のトランザクションコミット時(正確にはミューテーションの適用時)。
  • ストレージへの保存: 分散キー-バリュー・ストア(Dremel / Colossusベースのストレージ階層)の該当スプリット(Split)内に、物理データとしてシリアライズされ格納される。

ここで重要なのは、「計算コストが書き込み時に一度だけ前払いされる」という点だ。
計算結果がインデックスのキー(Primary KeyやSecondary Index)に含まれる場合、その威力を極限まで発揮する。

非 `STORED`(仮想)生成列:CPUバウンドな地雷原

では、`STORED` をつけない場合はどうなるか。

  • 評価タイミング: クエリの実行フェーズ(イテレータがデータをスキャンするその瞬間)。
  • ストレージへの保存: 一切されない。

「なんだ、ストレージを節約できるじゃないか」と思った君。甘い。
Spannerのクエリエンジン(Riegeliベースの分散分散クエリ・オプティマイザ)は、スキャンした行ごとに式(Expression)を評価する。もしその生成列が `JSON_VALUE` や複雑な文字列操作、UDF(ユーザー定義関数)を含んでいればどうなるか?

大規模なスキャン(Full Table Scanやセカンダリインデックスの広範囲スキャン)が発生した瞬間、ストレージノードのCPUが焼き切れる。分散データベースにおいて、ストレージ層での不要なCPU消費は、リーダーレプリカの負荷増大、ひいてはRead Latencyの全般的な悪化を招く。

—

3. インデックス連携:真のパフォーマンスを引き出す設計パターン

テックリードとして私が推奨する、Generated Columnsの最も美しいユースケースは 「セカンダリインデックスとの組み合わせ」 だ。

例えば、ユーザーのメールアドレスの「ドメイン部分だけ」で頻繁に検索するシステムがあるとしよう。素朴に書くとこうなる。

— ⭕ 堅牢な設計パターン:STORED生成列 + セカンダリインデックス
CREATE TABLE Users (
UserId STRING(36) NOT NULL,
Email STRING(255) NOT NULL,
— メールアドレスからドメインを抽出するSTORED生成列
EmailDomain STRING(100) AS (REGEXP_EXTRACT(Email, r’@(.+)$’)) STORED,
CreatedAt TIMESTAMP NOT NULL,
) PRIMARY KEY(UserId);

— ドメインごとの検索をミリ秒単位で返すためのインデックス
CREATE INDEX UsersByDomain ON Users(EmailDomain);

なぜこの設計が神がかっているのか?

1. 書き込み時の確定: `Email` が書き込まれた瞬間、`EmailDomain` が計算され、`Users` テーブルの物理行の一部として保存される。
2. インデックスへのシームレスな同期: `UsersByDomain` インデックスは、物理列である `EmailDomain` をキーとして持つため、Spannerは自動的かつ効率的にインデックスを構築・維持する。
3. クエリの完全なゼロ・コンピュート化:

SELECT UserId, Email
FROM Users@{FORCE_INDEX=UsersByDomain}
WHERE EmailDomain = ‘example.com’;

このクエリを発行した際、Spannerは実行時に一切の正規表現評価を行わない。インデックスのB-treeをダイレクトに走査し、一瞬で結果を返す。ストレージ層のCPU負荷はほぼゼロだ。

—

4. 陥りがちな罠とアンチパターン(コードレビューの視点から)

実務の現場で、私がジュニアやメンバーのPR(Pull Request)を容赦なく差し戻すポイントを共有しよう。

1. 非 deterministic(非決定的)な関数を生成列に使う

Spannerの生成列の式は、決定的(Deterministic)でなければならない。
例えば、以下のような定義はコンパイルエラーか、あるいは論理破綻を起こす。

— ❌ 絶対にNG:CURRENT_TIMESTAMP()は実行時に値が変わるため生成列に使えない
CreatedAt TIMESTAMP NOT NULL,
InvalidCol STRING(MAX) AS (CAST(CURRENT_TIMESTAMP() AS STRING)) STORED;

生成列の式に使用できるのは、純粋な関数のみだ。`CURRENT_TIMESTAMP()`, `RAND()`, `UUID()` などの実行時コンテキストに依存する関数は指定できない。これをやろうとするとビルドすら通らないが、カスタムロジックを無理やり複雑化させて規約をすり抜けようとするエンジニアがたまにいるので注意が必要だ。

2. 「とりあえず仮想列(非 STORED)」にしてインデックスを貼ろうとする

「ストレージ容量がもったいないから `STORED` は使わず、仮想列にインデックスを貼ろう」——これはSpannerの仕様上、不可能だ。
Spannerでは、セカンダリインデックスのキーとして指定できるのは `STORED` 生成列のみである(非STORED列にインデックスを貼ろうとするとエラーになる)。
ストレージをケチってパフォーマンスの牙城を崩すような設計は、分散DBの思想に真っ向から反する。

—

5. まとめ:プロフェッショナルのための設計チェックリスト

Cloud SpannerでGenerated Columnsを設計・実装する際は、以下のチェックリストを自分に問いかけてほしい。

1. その生成列は検索条件(WHERE句やJOIN条件)やインデックスのキーに使われるか?

  • YES 👉 迷わず `STORED` を選べ。
  • NO(単にSELECT句でフォーマットを統一したいだけ等) 👉 アプリケーション層で処理するか、本当にDB側で持つべきか再考せよ。

2. 生成列の式に重い関数(JSON操作や複雑な文字列処理)が含まれているか?

  • 含まれている場合、頻繁にスキャンされるテーブルであれば、`STORED` にして計算コストを「書き込み時」に分散させることがマスト。

3. スキーマ変更(ALTER TABLE)時の影響を考慮したか?

  • 既存の巨大なテーブルに対して後から `STORED` 生成列を追加する場合、バックグラウンドでのデータ書き換え(Backfill)が発生する。テーブルサイズに応じた綿密なデプロイ計画を立てよ。

ストレージ容量の単価は安いが、高騰したCPUコストとレイテンシの代償はシステム全体の死を招く。Spannerのアーキテクチャの息吹を感じ取り、ハードウェアの限界を引き出す美しいコードを書こう。

今日のレビューはここまでだ。さあ、コードを直したまえ。

コメント

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