Spannerのアボート(Aborts)と心中しないための実践的トランザクションリトライ設計
こんにちは。テックリードの私だ。
今日のコードレビューで、Cloud Spannerを使っているチームのプルリクエストにこんなコードを見つけた。
❌ やってはいけないアンチパターン
with client.database(instance_id, database_id).snapshot() as snapshot:
# トランザクション制御すらしていない、あるいは例外ハンドリングが雑
pass
def bad_update(transaction, user_id, new_status):
# 生のトランザクション関数。アボート対策がゼロ。
transaction.execute_update(
“UPDATE Users SET Status = @status WHERE UserId = @id”,
params={“status”: new_status, “id”: user_id}
)
「おっと、待ってくれ」と私はレビューを止めた。
Cloud Spannerは、世界規模の水平スケーリングと外部一貫性(External Consistency)を両立させた類まれなデータベースだ。しかし、その魔法のような仕組みの裏側には、物理法則ならぬ分散トランザクションの物理法則が存在する。
その筆頭が 「アボート(Abort)」 だ。
今回は、Spannerの楽観的並行性制御の本質を紐解き、プロダクション環境で絶対に破綻しない堅牢なトランザクションリトライの設計パターンを授けよう。
—
1. なぜSpannerはトランザクションをアボートさせるのか?
まず、教科書的な説明は省く。本質的な事実を言おう。
Spannerの書き込みトランザクションは、デフォルトで楽観的並行性制御(OCC: Optimistic Concurrency Control)を採用している。
読者諸賢も知る通り、OCCは「競合は少ない」という楽観的な前提のもと、ロックを獲得せずにトランザクションを進め、コミットの瞬間に衝突(コンフリクト)がなかったかを検証する方式だ。
ここでSpanner特有の事情が絡む。
SpannerはTrueTime APIを用いて、世界中のデータセンター間で絶対時間に近い因果関係を保証する。同一の行、あるいは同一のロック範囲に対して、複数の書き込みトランザクションが同時に突撃してきたとき、シリアライザビリティ(直列化可能性)を維持できなくなったトランザクションは、Spannerのストレージノードによって強制的にアボート(中止)させられる。
エラーコードで言うところの `ABORTED` だ。
これはバグではない。Spannerがデータ整合性を守るために戦った結果の勲章なのだ。したがって、「アボートを完全にゼロにする」という設計は幻想である。我々アプリケーションエンジニアがやるべきは、「アボートが発生することを前提とした、エレガントで堅牢なリトライ機構の構築」に他ならない。
—
2. アプリケーション層でのリトライ:実装の要件
では、どうリトライを実装すべきか?
ここで重要なのは、単に「エラーが出たらループで巻き戻す」という安易な実装をしてはならないということだ。以下の3要件を満たす必要がある。
1. 冪等性(Idempotency)の担保:
アボートしたトランザクションは、コミットに失敗しているためデータベース側はロールバックされている。しかし、トランザクションの「内部」で外部APIを叩いたり、メールを送信したりしている場合、それらは巻き戻らない。トランザクション内で行う副作用(Side-effects)の管理には細心の注意が必要だ。
2. エクスポネンシャルバックオフ + ジッター(Jitter):
競合が起きた瞬間に一斉にクライアントがリトライ(Thundering Herd現象)すると、データベースはさらなる高負荷に見舞われ、アボートの連鎖が起きる。必ずランダムな揺らぎ(ジッター)を入れたバックオフを入れること。
3. 読み取り・書き込みの分離:
トランザクションの処理が長ければ長いほど、コミット時のコンフリクト確率は跳ね上がる。「データを読むフェーズ」と「計算するフェーズ」をトランザクション外(または読み取り専用トランザクション)で行い、「書き込みフェーズ」のトランザクションのスコープを極限まで短くするのがプロの技だ。
—
3. 実装パターン:Python (Google Cloud Client Library) による堅牢なリトライ
言葉だけでは伝わらないだろう。プロダクション品質のリトライラッパーを含めた実装例を示す。
import time
import random
from google.cloud.spanner_v1 import transaction
from google.api_core.exceptions import Aborted
def update_user_status_with_retry(database, user_id: str, new_status: str, max_retries: int = 5):
“””
ABORTED エラーに対して、ジッター付きエクスポネンシャルバックオフで
トランザクションを安全にリトライする関数。
“””
# 読み取りから書き込みまでをカプセル化するコールバック関数
def transaction_logic(tx):
# 1. 読み取り
row_iter = tx.execute_sql(
“SELECT Status FROM Users WHERE UserId = @user_id”,
params={“user_id”: user_id},
param_types={“user_id”: spanner.param_types.STRING}
)
row = list(row_iter)
if not row:
raise ValueError(f”User {user_id} not found”)
current_status = row[0][0]
if current_status == new_status:
return “No change needed”
# 2. 書き込み(DMLの実行)
tx.execute_update(
“UPDATE Users SET Status = @status, UpdatedAt = CURRENT_TIMESTAMP() WHERE UserId = @user_id”,
params={“status”: new_status, “user_id”: user_id},
param_types={“status”: spanner.param_types.STRING, “user_id”: spanner.param_types.STRING}
)
return “Updated successfully”
retries = 0
base_delay = 0.05 # 初期遅延 50ms
while True:
try:
# Spannerの run_in_transaction は、内部で ABORTED を検知すると
# 自動的にトランザクションブロックを再実行してくれる機能を持つ場合があるが、
# 複雑なビジネスロジックや外部連携を含む場合は明示的な制御が安全。
return database.run_in_transaction(transaction_logic)
except Aborted as e:
retries += 1
if retries > max_retries:
# 規定回数を超えたら諦めて上位へ例外を投げる(アラート発報対象)
raise RuntimeError(f”Transaction failed after {max_retries} retries due to continuous aborts.”) from e
# エクスポネンシャルバックオフ + フルジッター (0 から 1 のランダム)
sleep_time = base_delay (2 retries)
jittered_sleep = random.uniform(0, sleep_time)
# ログにはリトライ回数と待機時間を必ず残す
print(f”[WARN] Spanner transaction aborted. Retrying ({retries}/{max_retries}) after {jittered_sleep:.3f}s…”)
time.sleep(jittered_sleep)
このコードの優れている点
- `run_in_transaction` の活用:Google公式クライアントライブラリが提供するトランザクションヘルパーをベースにしつつ、明示的な例外捕捉とバックオフを組み合わせている。
- ジッターの導入:`random.uniform(0, sleep_time)` により、同時多発的なリトライ要求の波(衝突波)を綺麗に散らしている。
- スコープの極小化:トランザクション内で重い外部API呼び出しや複雑なJSONパースを一切行っていない。純粋にDBの読み書きだけに集中しているため、ロック保持期間が最小化され、アボート率が劇的に下がる。
—
4. パフォーマンスとアーキテクチャ上の注意点:アンチパターン集
最後に、レビューで私がよく指摘する「Spannerを殺しかける設計ミス」をいくつか挙げておく。
1. ホットスポット(Hotspotting)の放置
例えば、`Counters` テーブルのような単一の行に対して、毎秒何千回もインクリメントトランザクションを飛ばす設計。これはSpannerのシャーディング機構を完全に無効化し、アボートの嵐を引き起こす。
- 対策:カウンターをシャード化する(例:10個のバケットに分散して書き込み、読むときに集約する)。
2. トランザクション内での外部サービス呼び出し(RPC / HTTP)
トランザクションの内部で決済APIや外部マイクロサービスを同期呼び出ししていはないか? ネットワークの遅延によってトランザクションの寿命が延び、その間に他のトランザクションとの衝突確率が跳ね上がる。
- 対策:副作用はトランザクションの外(コミット後)に実行する。
3. 巨大なトランザクション(Batch mutationsの肥大化)
1つのトランザクションで何万行ものデータを処理しようとすると、メモリ制限に引っかかるだけでなく、ロック範囲が広がりすぎて確実にアボートする。
- 対策:適切なサイズ(通常は数行〜数百行程度)にバッチを分割する。
—
結論
Cloud Spannerにおけるトランザクションリトライは、単なる「エラーハンドリングのボイラープレート」ではない。分散システムの現実と向き合い、システム全体の高可用性を担保するためのアーキテクチャの一部である。
「なぜアボートが起きるのか」のメカニズムをコードの隅々まで意識し、リトライとバックオフを正しく設計すること。それこそが、真のSpanner使いへの第一歩だ。
さて、君たちのプロジェクトのコードは、この基準をクリアできているか?
今すぐリポジトリを確認したまえ。
コメント