【Cloud Spannerの真実】ロックの仕組みと、実戦で踏み抜かないためのリトライ戦略
こんにちは。チーフアーキテクトの私だ。
レビュー会で「なぜSpannerでデッドロックが起きるのか」「なぜトランザクションがアボートするのか」を正確に説明できず、モゴモゴしているエンジニアを見かけるたびに、私は少し悲しくなる。
「Google Cloudのマネージドだから、ロックや排他制御なんて意識しなくていいんでしょ?」
——もし君のチームにそんな甘い認識のメンバーがいるなら、今すぐこの記事を読ませてほしい。
Cloud Spannerは無限に近いスケールと強整合性(External Consistency)を両立させた化け物のようなデータベースだが、物理法則の制約、そして分散トランザクションの宿命から逃れることはできない。その中核にあるのが「ロックの仕組み」だ。
今回は、Spannerのロックが内部でどう動き、どうしてデッドロックが起きるのか、そして我々アプリケーションエンジニアが「絶対に踏み抜いてはならない実装上の鉄則」について、実務の現場目線でシャープに解説しよう。
—
1. Spannerのロックの正体:悲観的ロックとリアルタイム性
まず大前提として、Cloud Spannerの書き込みトランザクションは悲観的ロック(Pessimistic Locking)をベースに動いている。データを更新する際、Spannerはスパン・サーバー間で協調し、対象データに対してロックを獲得しにいく。
共有ロック(Read Lock)と排他ロック(Write Lock)
- 排他ロック(Exclusive Lock / Write Lock)
- `UPDATE`、`INSERT`、`DELETE`を実行する際に獲得される。
- このロックがかかっている間、他のトランザクションはそのデータへの書き込みはもちろん、明示的な読み取り(後述のロックを伴う読み取り)もブロックされる。
- 共有ロック(Read Lock)
- 通常の読み取り(Snapshot Read)ではロックは取得されない(これがSpannerの最大の特徴であり、読込性能が爆発的に高い理由だ)。
- しかし、リーダーボードの更新や、厳密な残高確認などで `SELECT … FOR UPDATE` に相当する構文(Spannerでは読み取りモードの制御や、Pessimistic Locking Read)を使用した場合に共有/排他ロックが取得される。
ここで重要なのは、Spannerのロックは「行単位(Row-level)」ではなく、正確には「キー範囲(Key Range)」に対してかかるという点だ。スプリット(Spannerのデータ分割単位)の境界や、インターリーブ構造の親子関係を理解していないと、意図しない範囲のロック競合(False Contention)を引き起こす。
—
2. なぜデッドロックが起きるのか?(発生条件の解剖)
Spannerでデッドロック(Deadlock)が発生するのは、何も珍しいことではない。高ス負荷なシステムでは日常茶飯事だ。
Spannerにおけるデッドロックは、主に複数のトランザクションが、異なる順序でリソース(キー範囲)のロックを獲得しようと膠着状態に陥ったときに発生する。
最悪のアンチパターン例:口座振替の競合
以下のコードを見てほしい。よくある口座Aから口座Bへの送金処理だが、ここに致命的な設計ミスがある。
// 【アンチパターン】順序が考慮されていない送金トランザクション
public void transfer(DatabaseClient dbClient, String fromAccount, String toAccount, long amount) {
dbClient.readWriteTransaction().run(transaction -> {
// 1. 順番にロックを獲得しようとする
Struct fromRow = transaction.readRow(“Accounts”, Key.of(fromAccount), Arrays.asList(“Balance”));
long fromBalance = fromRow.getLong(0);
Struct toRow = transaction.readRow(“Accounts”, Key.of(toAccount), Arrays.asList(“Balance”));
long toBalance = toRow.getLong(0);
// 残高計算と更新
transaction.buffer(Mutation.newUpdateBuilder(“Accounts”)
.set(“AccountId”).to(fromAccount)
.set(“Balance”).to(fromBalance – amount)
.build());
transaction.buffer(Mutation.newUpdateBuilder(“Accounts”)
.set(“AccountId”).to(toAccount)
.set(“Balance”).to(toBalance + amount)
.build());
return null;
});
}
何が起きるか?
- トランザクション1が「口座A」をロックし、次に「口座B」をロックしようとする。
- 同時刻、トランザクション2が「口座B」をロックし、次に「口座A」をロックしようとする。
- 結果: 互いに相手が放すのを待ち続け、Spannerの内部検知メカニズムによってどちらか(あるいは両方)がアボート(Abort)させられる。
Spannerはデッドロックを検知すると、トランザクションを強制終了し、`ABORTED` エラーを返却する。これが、アプリケーション側でリトライを実装しなければならない最大の理由である。
—
3. 堅牢な設計パターン:デッドロックを防ぐ「2つの鉄則」
コードレビューでこれを検知したら、私は即座に差し戻しを命じる。デッドロックや不要なアボートを防ぐためには、以下の設計パターンを絶対に守らなければならない。
鉄則1:ロック獲得の順序を完全に入れ替える(リソースの正準順序付け)
複数のレコードを更新する場合、必ず一意なキーの昇順(あるいは降順)など、全トランザクションで同一の順序でロックを取得するようにコードを統制せよ。
先ほどの口座振替であれば、以下のように書き換えるべきだ。
// 【堅牢な実装】キーの大小を比較し、常に順序を固定してロックを取得する
public void transferSafe(DatabaseClient dbClient, String fromAccount, String toAccount, long amount) {
// アカウントIDの文字列比較で順序を決定
String firstLock = fromAccount.compareTo(toAccount) < 0 ? fromAccount : toAccount;
String secondLock = fromAccount.compareTo(toAccount) < 0 ? toAccount : fromAccount;
dbClient.readWriteTransaction().run(transaction -> {
// 常に決まった順序(辞書順が早い方から)でアクセス
transaction.readRow(“Accounts”, Key.of(firstLock), Arrays.asList(“Balance”));
transaction.readRow(“Accounts”, Key.of(secondLock), Arrays.asList(“Balance”));
// ビジネスロジックとMutationのバッファリング…
// (省略)
return null;
});
}
これだけで、デッドロックの発生確率は劇的に低下する。
鉄則2:ホットスポットを作るな(キー設計の妙)
「単一のカウンター行」や「直近のタイムスタンプをキーにした親テーブル」など、特定の行にトラフィックが集中する設計(ホットスポット)は、ロック待機の列(キューイング)を作り出し、レイテンシーを悪化させる。
カウンターが必要なら、シャード化(複数のキーに分散して書き込み、後から集計する)を検討せよ。
—
4. アプリケーション側でのリトライ戦略(実務レベルの作法)
Cloud Spannerを使う上で、「トランザクションのアボート(`ABORTED`)は、例外ではなく通常のフローの一部である」というマインドセットを持つことが極めて重要だ。競合が発生した際、Spannerはトランザクションをアボートさせることで整合性を担保している。
したがって、アプリケーション側(Client Library)では適切なリトライ戦略(Exponential Backoff with Jitter)が必須となる。
Google公式クライアントのデフォルト動作と注意点
JavaやGoなどのGoogle Cloud公式クライアントライブラリは、`ABORTED` エラーを検知した場合、デフォルトで自動リトライを行う機能を持っている。
しかし、以下の点に注意しなければならない。
1. トランザクション全体の副作用(冪等性)の担保
トランザクション内で外部APIの呼び出し(メール送信や決済APIの叩きなど)を行っている場合、リトライによって二重実行が発生する。
鉄則: 読み書きトランザクション内に副作用を持つ処理を絶対に書くな。外部連携はトランザクションの外(Commit後)で行うか、Outboxパターン等を用いよ。
2. リトライ回数とタイムアウトのチューニング
高トラフィック時にリトライが雪崩(Thundering Herd現象)を起こさないよう、ジッター(ランダムな遅延の揺らぎ)を入れたバックオフ設計を行うこと。
—
5. まとめ:プロフェッショナルとしてSpannerに向き合うために
Cloud Spannerは魔法の箱ではない。リレーショナルデータベースとしての厳格な制約と、分散システムとしての物理的限界の上で成り立っている。
- 書き込みは悲観的ロックであり、キー範囲単位で競合する。
- マルチレコードの更新時は、常にロック獲得順序を統一(正準化)する。
- `ABORTED` は失敗ではなく、分散合意のための仕様である。冪等性を担保した上で正しくリトライを設計する。
この3点をチーム全員が共通認識として持てば、君たちのシステムはCloud Spannerのポテンシャルを100%引き出し、極限の負荷耐性と信頼性を手に入れることができるはずだ。
設計レビューで怪しいコードを見つけたら、今日の話を思い出してほしい。健闘を祈る。
コメント