高並列環境でのトランザクション分離レベル活用
DBが同時に発生する読み取りと書き込みリクエストをどのように処理しているか、気になったことはありますか?
例えば、トランザクションAがあるユーザーのポイント残高を更新している最中に、別のトランザクションBが予約処理のために同じ行を読み取っている場合、何が起きるのでしょうか?
更新中のトランザクションAが自身の更新結果を読み取れるようにしつつ、トランザクションBには更新前の値を見せるには、DBエンジンはどのようにして整合性を保つのでしょうか?
先日、予約システムの決済フローを開発していたとき、ロックとDB並列処理に細心の注意が必要な場面に遭遇しました。E2Eテストを書いている最中、フェイクデッドロックという問題に直面しました。同じ行を更新しているが、更新対象のカラムは異なる2つのプロセスが原因でした。
この問題がきっかけで上記の疑問を持つようになり、DBエンジンが実際に何をしているのか、トランザクションの分離レベルとロックがどのように連携しているのかを理解することができました。
同じ疑問を持っていた方や、答えを知らない方に向けて、調べた内容をまとめます。
ここではPostgreSQLについて話しますが、MySQLにも似たような仕組みがあると思います。
用語を明確にしておくと、トランザクションとは、一括してDBにコミットされる一連のDBクエリのことです。
※本記事の日本語訳は Claude を使って作成しています。
MVCC(Multi-Version Concurrency Control): 読み取りが書き込みをブロックしない仕組み
トランザクション間の分離が必要な場合、DBエンジンはDB全体のスナップショットを各トランザクションに提供しているわけではありません。
実際には、行が更新されるたびに異なるバージョンを生成することで、より細かい粒度で管理しています。
タプル – バージョン管理の仕組み
バージョンの単位はタプルと呼ばれます。
┌─────────────────────────────┐
│ xmin=99 xmax=0 id=1 ... │
└─────────────────────────────┘
タプルには以下のフィールドが含まれます:
-
xmin: このバージョンを作成したトランザクションのID -
xmax: このバージョンを削除したトランザクションのID -
id ...: 行バージョンのデータ
これにより、トランザクションが行にアクセスしようとする際、そのXID(トランザクションID)に対応するバージョンのみが参照可能となり、トランザクション開始時点のDBデータ全体のスナップショットは不要になります。
ただし、これにより次の疑問が生じます。複数のトランザクションが同一のデータを同時に参照・更新している場合、それぞれの更新をどのように分離するのでしょうか?あるトランザクションが行を更新中に、自身の更新結果を読み取れるようにしながら、他のトランザクションには古いデータを見せるにはどうすればいいのでしょうか?
メタデータスナップショット
この分離は、各トランザクションに付与されるメタデータスナップショットによって実現されます。
XID=100 のスナップショット:
{
xmin: 98,
xmax: 103,
active: {99, 101}
}
新しいトランザクションごとに、スナップショット取得時点の以下のメタデータが割り当てられます:
- 次に割り当てられるXIDの番号
-
xmin: 最も古いアクティブなトランザクション(未コミット)。xmin < 98のタプルは確実に可視と判断できます(xmaxが問題なければ) -
xmax: 次に割り当てられるXID。103はまだ存在しないため、xmin >= 103のタプルは不可視です -
active: スナップショット取得時に進行中(開始済み・未コミット)だったトランザクション
これらのメタデータをもとに、XID=100 に対してどのバージョンが可視かを判断し、分離を実現します:
あるxminを持つタプルが可視となる条件:
tuple.xmin < 103 → スナップショット取得前に存在していた
AND tuple.xmin ∉ {99, 101} → 進行中ではない
AND tuple.xmin がコミット済み
コミット済みかどうかの判断:
tuple.xmin < snapshot.xmin (98) → 確実にコミット済み、追加チェック不要
OR
tuple.xmin ∈ [98, 103) → activeリストに含まれない(上記で確認済み)
AND
tuple.xmin が中断されていない → このチェックは `pg_xact` を使用(本記事では割愛)
この最後のチェックが必要な理由は、途中のXIDが中断(ロールバック)されている可能性があるためです。中断されたトランザクションもタプルバージョンを残します(高速ロールバックのため)が、現在のXIDには見えてはいけません。
これで低レベルな部分は完了です。軽量なスナップショットによって各トランザクションを分離できるようになりました。
ただし、独立した2つの並列トランザクション間では、分離が必ずしも必要でない場合もあります。
分離レベル
実際のMVCCの挙動は、トランザクションに関連付けられた分離レベルによって決まります。Prismaでは以下のように指定します:
prisma.$transaction(
(tx) => executePointTransactionBody(tx, params),
{
isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
timeout: 5000,
}
)
利用可能な分離レベルは3つです:
| レベル | スナップショット取得タイミング | 説明 | 非反復読み取り | ファントム読み取り | ライトスキュー |
|---|---|---|---|---|---|
READ COMMITTED(デフォルト) |
ステートメントごと | 同じクエリがトランザクション内で異なる結果を返す可能性がある | あり | あり | あり |
REPEATABLE READ |
トランザクション開始時 | 同じクエリは常に同じ結果を返す | なし | なし | あり |
SERIALIZABLE |
トランザクション開始時 | 2つの並列トランザクション間の読み取り/書き込み依存サイクルを検出して中断する | なし | なし | なし |
非反復読み取り: 別のトランザクションが途中で行を更新したため、同じ行のクエリがトランザクション内で異なる値を返すこと。
ファントム読み取り: 別のトランザクションが途中でテーブルを変更したため、同じクエリがトランザクション内で異なる行セットを返すこと(例: A: count → B: insert → A: count)。
ライトスキュー: 2つのトランザクションがそれぞれ異なる行を更新した結果、不変条件が壊れること(後述のSERIALIZABLEセクション参照)
READ COMMITTED(デフォルト)
表示目的の読み取りや、読み取りに依存しない純粋な書き込みなどには十分です。
REPEATABLE READ
実は、先ほど説明した挙動はREPEATABLE READ分離レベルのものでした。
ほとんどの場合、デフォルトのREAD COMMITTEDで十分ですが、今回の決済システムフローでは以下のような処理が必要です:
値を読み取る → 残高を確認する → 書き込む → コミットする
トランザクション間のさらなる分離が必要です。これは、非反復読み取りを防ぐために行アクセスの制御が求められる重要なケースです。
SERIALIZABLE
ライトスキューを防ぐことができる唯一のレベルです。
例として、以下のテーブルを考えます:
-
usageLimitカラムを持つCouponsテーブル - 予約ごとのクーポン利用を記録する
BenefitCouponUsageテーブル
Tx A(ユーザー1) Tx B(ユーザー2)
───────────────────────────────────────────────────────────────────────────────
SELECT COUNT(*) FROM BenefitCouponUsage
WHERE couponId = 1
→ 0, usageLimit=1, 使用可能
SELECT COUNT(*) FROM BenefitCouponUsage
WHERE couponId = 1
→ 0, usageLimit=1, 使用可能
INSERT INTO BenefitCouponUsage
(reservationId=10, couponId=1)
INSERT INTO BenefitCouponUsage
(reservationId=11, couponId=1)
COMMIT COMMIT
結果: usageLimit=1 のクーポンに対して、BenefitCouponUsage に2行が挿入されてしまいます。
今回のケース
私がE2Eテストで「フェイクデッドロック」に遭遇した原因はまさにここにあります。独立したカラムを更新するだけの他トランザクションに対して、行を過剰に保護していたのです。
具体的には、念のためSERIALIZABLEを使用していたのですが、実際には何の理由もなく行アクセスを詰まらせていました:
- 予約作成フローが
users.nameを読み取っていた - 決済フローが
users.point_balanceを更新していた
この2つの並列トランザクションが、互いに独立した処理をしているにもかかわらずデッドロックを引き起こしていました。
SERIALIZABLEは広範囲に適用され誤検知を引き起こしやすいですが、中断時のリトライロジックを実装すれば使用可能です。
REPEATABLE READのように行の精度が必要でした。
ただし、REPEATABLE READも今回のケースでは潜在的なデッドロックのリスクを下げるものの、リトライロジックが必要です。
つまり、SERIALIZABLEとREPEATABLE READには以下の2つの問題があります:
- リトライロジックのオーバーヘッド
- 独立した並列トランザクションによるフェイクデッドロック
そのため、最終的にはデフォルトであるREAD COMMITTEDレベルを使用しました。これは許容度が高く、特に非反復読み取りに対して脆弱です。
しかし、ロックと組み合わせることで、これまで挙げたすべての問題を粒度よく防ぐ手段が得られます。
ロック
ロックにはさまざまな種類がありますが、今回のケースで実際に必要なのは行レベルロックです。
構文は以下の通りです:
SELECT ... FOR UPDATE
今回のケースでは、ユーザーのUsers行をロックします:
SELECT id, point_balance FROM users WHERE id = ${userId} FOR UPDATE
行レベルロックとREAD COMMITTEDの組み合わせにより、先述したすべての問題が解決されます:
- リトライロジック不要: 並列トランザクションはロック済みリソースを待機するだけ
- フェイクデッドロックを避けるための分離粒度: MVCCにより読み取りはデフォルトでロックされた行にアクセス可能(独立した並列トランザクションがリソースを待機しなくて済む)。ただし読み取りが依存している場合は、読み取りトランザクション内で事前にロックを使って待機させることも可能
- 反復可能な読み取りレベル: 行をロックすることで読み取り値への更新を防止
ファントム読み取りやライトスキューも、クエリの起点となるアンカー行をロックすることで解決できます(例: クーポンの例では SELECT id FROM coupons WHERE id = 1 FOR UPDATE;)。
つまり、潜在的なデッドロックが予測できる場合、リソースに明示的なロックを使用することで、トランザクション分離レベルを細かい粒度でコントロールできます。
まとめ
MVCCは、並列トランザクションの読み取り/書き込みが互いにブロックしない仕組みを提供します。一般的に、MVCCがダーティリードを防いでくれるため、デフォルトのREAD COMMITTED分離レベルで十分です。
ただし、予約システムや決済システムのような読み取り/書き込みが依存するフローでは、ある程度の分離が必要になることがあります。
その場合、可能であればREAD COMMITTEDレベルと行レベルロックを組み合わせることで、MVCCの助けを借りながら非反復読み取りを防ぎつつ、他の分離レベルのデメリット(リトライロジックや誤検知)を解消できます。
REPEATABLE READやSERIALIZABLEが最適なケースもありますが、まだ実際に遭遇したことはありません。
この深い知識が実践で本当に役に立つかどうかはわかりませんが、Postgresがすべて内部で処理してくれているとはいえ、仕組みを知ることはとても面白いと感じます。
漠然とした疑問に答えが出て、すっきりした気持ちになれます。
参考: