【実務・中級編】 読み取り書き込みトランザクション – Cloud Spanner

Cloud Spannerの「読み書きトランザクション」を制する者が、分散システムを制する

諸君、設計レビューで「SpannerならACIDだから安心だよね」という甘い言葉を聞くたびに、私は身が引き締まる思いがする。

確かにCloud Spannerは、TrueTimeという物理的な時計同期技術を基盤に、グローバル規模で外部整合性(External Consistency)を保証する唯一無二のデータベースだ。しかし、「ACIDだから何もしなくていい」というのは幻想に過ぎない。 分散トランザクションにおいて、アプリケーション層がその特性を正しく理解していないと、性能は泥沼化し、レイテンシは跳ね上がる。

今回は、Spannerの「読み書きトランザクション(Read-Write Transaction)」の本質に切り込む。教科書的な説明は省く。現場で戦うエンジニアのための「極限の知見」を共有しよう。

—

1. 読み書きトランザクションの正体:楽観的並行制御の真実

Spannerの読み書きトランザクションは、基本的に楽観的並行制御(OCC)で動いている。

1. Read Phase: データを読み込み、ローカルで変更をバッファリングする。
2. Commit Phase: 変更内容をリーダーに送信し、ロックを獲得してコミットを試みる。

ここで重要なのは、「競合は悪ではない」ということだ。Spannerは競合を検出すると、自動的にトランザクションをアボート(中止)し、クライアントライブラリ側で再試行(Retry)を試みる。

「再試行」を設計の一部として組み込め

多くのエンジニアが犯す最大のミスは、再試行のコストを無視することだ。

  • アンチパターン: 巨大なトランザクションの中に、外部API呼び出しや重い計算を混ぜる。
  • 結果: トランザクションが長引くほど「競合確率」は指数関数的に増大する。アボートが頻発し、スループットは崩壊する。

鉄則: トランザクションは「短く、速く、美しく」保て。データ操作以外のロジックは、トランザクションの外に出せ。

—

2. 堅牢な設計パターン:Client-Side Retrierの活用

Spannerのクライアントライブラリは賢い。`runTransaction` メソッドを使用すれば、アボート時の再試行を自動でハンドルしてくれる。我々がやるべきは、このブロック内を「決定論的(Deterministic)」に保つことだ。

// 正しいトランザクションの設計例
public void updateAccountBalance(DatabaseClient dbClient, String accountId, long amount) {
dbClient.readWriteTransaction().run(transaction -> {
// 1. 必要なデータのみを読み込む(Read-Modify-Write)
Struct row = transaction.readRow(“Accounts”, Key.of(accountId), Arrays.asList(“Balance”));
long currentBalance = row.getLong(“Balance”);

// 2. ビジネスロジックを適用
long newBalance = currentBalance + amount;

// 3. 変更をバッファリング
transaction.buffer(Mutation.newUpdateBuilder(“Accounts”)
.set(“AccountId”).to(accountId)
.set(“Balance”).to(newBalance)
.build());

return null; // コミット時に自動的に適用される
});
}

ここでのポイント:

  • `run()` ブロック内には、副作用(外部APIへのリクエストなど)を決して記述してはいけない。再試行のたびにAPIが二重に叩かれることになる。
  • 読み込むデータは、更新に必要な最小限の列に絞れ(`SELECT ` は論外だ)。

—

3. パフォーマンスを劇的に改善する「ホットスポット」回避

Spannerの読み書きトランザクションで最大の敵は「ロック競合」である。特に、単一の行(例えば、ランキングの順位や共有カウンター)に頻繁に書き込みを行う設計は、Spannerを最も殺しやすいパターンだ。

対策:書き込みの分散化

どうしてもカウンターのようなものを保持したい場合、以下のように設計を工夫せよ。

1. シャード化: カウンターを `Counter_A`, `Counter_B`, `Counter_C` のように分割し、書き込み時にランダムに選択する。
2. 集計: 読み取り時にそれらを合計する。

これはトランザクションの競合を劇的に減らし、書き込みスループットをスケーラブルにするための黄金律だ。

—

4. リード・オンリー・トランザクションという選択肢

「読み書きトランザクション」のコストを下げたいのであれば、「読み書きしなくて良いタイミング」を徹底的に排除せよ。

  • 強い整合性が必要なら: `ReadOnlyTransaction` を使え。これはロックを取得しないため、書き込みトランザクションと競合せず、スループットを制限しない。
  • 古いデータでも良ければ: `TimestampBound` を指定して、ステイル(Stale)な読み取りを行え。これはSpannerの強みを最大限に活かす方法だ。

—

チーフアーキテクトからの助言

Spannerを扱うということは、「分散システムにおける一貫性のコスト」を正しくコントロールするということと同義だ。

コードレビューで以下の質問を投げかけてみてほしい。
「このトランザクションの実行時間は何ミリ秒か?」
「もし競合が起きたとき、再試行は何回まで許容するか?」
「このロジックは、トランザクションの外に出せないのか?」

これらに即答できるエンジニアだけが、Spannerの真のパワーを引き出し、止まらないシステムを構築できる。Spannerは魔法の杖ではない。しかし、君たちの設計と思想を正しく反映させる、最強のエンジンにはなり得るのだ。

健闘を祈る。次はインデックスとテーブルインターリーブの戦略について話そうか。

コメント

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