【実務・中級編】 トランザクションの種類 – Cloud Spanner

【Cloud Spanner極意】トランザクションの深層:読み取り専用、読み書き、そして真の限界突破パターン

テックリードの私だ。コードレビューや設計レビューで、こんなコードや設計を見かけるたびに私は赤べこのように首を縦に振るか、あるいは冷や汗をかくことになっている。

  • 「とりあえず全部、読み書きトランザクション(Read-Write Transaction)に突っ込んでおけば整合性は保たれるよね」
  • 「読み取り専用なんだから、ロックなんて意識しなくていいでしょ?」
  • 「パケッタイズド・リード(分割読み取り)?なにそれ美味しいの?」

Cloud Spannerは、リレーショナルデータベースの強靭な整合性と、NoSQLの水平スケーラビリティを極限高い次元で融合させたモンスターマシンだ。しかし、その「トランザクションの特性」を解き明かさずにアクセルを踏み抜けば、システムはスループットの壁に激突するか、レイテンシの悪化という名の崖から転げ落ちる。

今回は、Cloud Spannerのトランザクションが持つ「3つの顔」について、実務の現場で即座に使える設計判断の基準とともに、私の知見のすべてを叩き込む。

—

1. 読み書きトランザクション(Read-Write Transaction):その強さと「見えざる代償」

すべての基本であり、最強の整合性保証機関だ。
Cloud Spannerの読み書きトランザクションは、2段階コミット(2PC)とPaxosコンセンサスを裏でフル回転させ、外見的には「直列化可能(Serializable)」な世界観を提供する。

内部の動き:何が起きているのか?

読み書きトランザクションの実行中、Spannerはデータ行に対して排他ロック(Pessimistic Locking)を裏で獲得していく。そしてコミットの瞬間、関与するすべてのスプリット(データシャード)間で2PCを執行する。

[クライアント]
│
├─ 1. データの読み込み (ロック取得) ──> [Spanner Split A]
├─ 2. データの書き込み (ロック保持) ──> [Spanner Split B]
│
└─ 3. Commit (2PC / Paxos合意形成) ────> [全関与スプリット]

実務での設計アンチパターン:ホットスポットの生成

「トランザクション内でカウンターをインクリメントする」「全件のステータスを一括更新する」。これらは読み書きトランザクションの性能を殺す最悪のアンチパターンだ。

連続した連番(例: `YYYYMMDD-00001`のような単調増加キー)をプライマリキーの先頭に据えたテーブルに対して読み書きトランザクションを大量発行すると、特定のスプリットに書き込みが集中し、ホットスポット(Hotspotting)が発生する。CPU使用率が100%に張り付き、レイテンシが跳ね上がる惨劇を、君たちも見たことがあるはずだ。

【知見】
読み書きトランザクションは「必要最小限の行数」「最短の時間」で駆け抜けろ。トランザクション内で外部APIを呼び出すなどの愚行は、ロック保持時間をいたずらに引き延ばし、ロック競合(Aborts)を誘発する最大の癌だ。

—

2. 読み取り専用トランザクション(Read-Only Transaction):無敵のスケールとスナップショット

もし君のシステムが「データを書き換えない」のであれば、読み書きトランザクションを使うのは犯罪的なまでのリソースの無駄遣いだ。ここで登場するのが「読み取り専用トランザクション」である。

内部の動き:ロックフリーの世界

読み取り専用トランザクションの真髄は、マルチバージョン同時実行制御(MVCC)とTrueTime APIの組み合わせにある。
Spannerは、過去の任意のタイムスタンプにおけるデータのスナップショットを、一切のロックを取得せずに読み出すことができる。

Python (google-cloud-spanner) による理想的な読み取り専用トランザクションの例
with database.snapshot(exact_staleness=datetime.timedelta(seconds=5)) as snapshot:
# ロックなし!他の書き込みをブロックせず、並列度限界突破で読み出す
results = snapshot.execute_sql(
“SELECT user_id, balance FROM accounts WHERE status = ‘ACTIVE'”
)
for row in results:
process(row)

実務での設計ポイント:正確な鮮度(Staleness)の選択

読み取り専用トランザクションでは、データの鮮度をどう定義するかでパフォーマンスが変わる。

1. 強い読み取り(Strong Read): 現在時刻の最新データを読む。ロックは不要だが、タイムスタンプの同期(TrueTimeの不確実性ウィンドウ分、数ミリ秒の待機)が発生する場合がある。
2. 正確な古さ(Exact Staleness): 「5秒前の状態を見たい」といった指定。これを使うと、レプリカ側で完全にローカルな読み取りが可能になり、リーダーノードへの負荷をゼロにできる。
3. バウンドされた古さ(Bounded Staleness): 「最大10秒以内の遅延を許容しつつ、最もレイテンシが低いレプリカから読む」。大規模なバッチ処理や分析クエリの嵐から、トランザクション処理系を守るための最強の盾となる。

—

3. パケット化された読み取り(Partitioned Read / Partitioned Query):限界を超えた並列処理

さて、ここからが本記事のハイライトだ。数千万、数億行に及ぶ巨大テーブルを、通常のクエリで読み込もうとしてタイムアウトやメモリ枯渇(OOM)を起こしたことはないだろうか?

それを解決するのが、パケット化された読み取り(Partitioned Read / Partitioned Query)である。

仕組み:クエリの「断片化」と分散ワーカーへのディスパッチ

パケット化された読み取りは、巨大な読み取り専用トランザクションを論理的な「パーティション(パケット)」に分割し、それぞれを独立したトークン(Partition Token)として切り出す仕組みだ。

[巨大クエリ/テーブル]
│
├─ パークション生成 (Partition Query)
│ ├─ Token 1 ──> [Worker A で並列実行]
│ ├─ Token 2 ──> [Worker B で並列実行]
│ └─ Token 3 ──> [Worker C で並列実行]
│
└─ 結果の統合 (Client / Dataflow)

これにより、単一のプロセスや単一のスレッドの限界を超えて、数十・数百のワーカー(Google Cloud Dataflowや、自前の分散ワーカー群)でSpannerの全データを並列かつ安全に爆速スキャンすることが可能になる。

実装パターン:Pythonでのパケット化クエリの概念コード

実務で大規模バッチやデータエクスポート基盤を構築する際、私はこのように設計する。

from google.cloud.spanner_v1 import Client

client = Client()
instance = client.instance(“my-instance”)
database = instance.database(“my-database”)

def run_partitioned_export():
# 読み取り専用トランザクション内でパーティションを生成
with database.snapshot() as snapshot:
# クエリをパーティションに分割(Partition Tokenの生成)
# ※注意: Partitioned QueryはSQLの構造に制限があります(集計関数や結合の複雑さに注意)
partition_tokens = snapshot.partition_query(
sql=”SELECT user_id, payload FROM massive_log_table WHERE created_at >= @target_date”,
params={“target_date”: “2023-10-01″},
# パーティションの粒度を制御するオプションを指定可能
)

print(f”Total partitions generated: {len(partition_tokens)}”)

# 各パーティションを並列ワーカー(ThreadPoolExecutorやCeleryなど)にばら撒く
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futures = [
executor.submit(process_partition, database, token)
for token in partition_tokens
]
concurrent.futures.wait(futures)

def process_partition(database, partition_token):
# 各ワーカーは自分の割り当てられたトークンだけで安全にデータを読み出す
# 親のトランザクションが終了していても、スナップショットのタイムスタンプが維持される
with database.snapshot() as snapshot:
rows = snapshot.execute_partition(partition_token)
for row in rows:
# 外部ストレージ(BigQueryやGCS)へバルクインサート等を行う
export_to_warehouse(row)

【知見】パケット化された読み取りの制約と運用上の注意

1. トランザクション分離の維持: パーティション化された読み取りは、生成元のスナップショットのタイムスタンプを共有する。そのため、長時間のバッチ処理であってもデータの整合性は完全に保たれる。
2. SQLの制約: すべてのSQLが `partition_query` に使えるわけではない。`GROUP BY` や複雑な結合(JOIN)を含む場合、パーティション分割がサポートされないか、効率が落ちることがある。基本的には「単一テーブルの全件スキャン / 範囲スキャン」に割り切るべきだ。
3. データスキューに注意: データの偏り(スキュー)がある場合、特定のパーティションだけが肥大化し、そのワーカーの処理が終わるまでバッチ全体が完了しない「ストラグラー問題」が起きる。プライマリキーの設計がここでもものを言う。

—

まとめ:テクニカルリードからの設計マトリクス

どのトランザクションを使うべきか、私のレビュー基準をマトリクスとして明文化しておく。設計書に迷ったらこれを貼れ。

| トランザクション種類 | 主な用途 | ロックの有無 | スケーラビリティ | 実務での鉄則 |
| :— | :— | :— | :— | :— |
| 読み書きトランザクション | データの挿入・更新・削除、厳密な残高計算など | 有り (排他ロック / 2PC) | ホットスポットに弱い | ロック時間を最小化。外部APIコールは絶対に排除する。 |
| 読み取り専用トランザクション | 通常の画面表示、参照系API、軽量な集計 | 無し (MVCC) | 高い (レプリカ分散可能) | 最新データが必要なければ `Exact Staleness` を使ってリーダー負荷を下げろ。 |
| パケット化された読み取り | 大規模バッチ処理、DWHへの全件エクスポート | 無し (MVCC + 分割) | 極限 (並列ワーカー分散) | 複雑なJOINを避け、単一テーブルのスキャンに特化させよ。 |

Cloud Spannerは、正しく使えばこれほど頼もしいデータベースはない。しかし、仕組みを理解せずに「なんとなく」でコードを書けば、その圧倒的なスケーラビリティの牙を剥くことになる。

次のレビューでは、君たちのコードがどのトランザクションを、どんな意図で選んでいるか、そのロジックを私に論理的に説明して見せてほしい。期待しているぞ。

コメント

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