トランザクション分離レベル
Transaction Isolation Level ・ とらんざくしょんぶんりれべる
同時に走るトランザクション同士が、互いの変更をどこまで見てよいかを段階で定める基準。
✦ 30秒で試す — 航海データベースは、書き換え中の行でも読み手を待たせないT1の2回目の読みで見える残高は?予想してみる概要
トランザクション分離レベルは、同時に走る複数のトランザクションが、互いの変更をどこまで見てよいかを段階的に定めた基準です。ACID の I(Isolation=分離性)を、完全に守るか、性能のために少し緩めるかを選ぶ「つまみ」だと考えると分かりやすいでしょう。SQL 標準では、緩い順に READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ、SERIALIZABLE の4段階が定義されています。
このつまみが実務で顔を出すのは、たいてい「テストでは正しく動くのに、本番の同時アクセスで数字が合わない」という場面です。在庫が二重に引き当てられる、集計の途中で合計がずれる、といった現象の多くは、選んだ分離レベルが許している異常(アノマリー)が実際に起きた結果です。どのレベルがどの異常を許すのかを知っていれば、この種のバグは「仕様どおりの挙動」として説明でき、対策も選べます。
なぜ生まれたか
すべてのトランザクションを一列に並べて順番に実行すれば、途中経過が見える問題は原理的に起きません。しかし実際のデータベースには何百もの接続が同時に読み書きに来るので、完全な直列実行では性能が成り立ちません。かといって無秩序に並行実行すれば、確定前の値を読む、同じ行を2回読んだら値が変わっている、といった異常が噴き出します。
1976年、IBM のジム・グレイらは、ロックの掛け方の強さに応じて「一貫性の度合い」を段階に分ける考え方を示しました。これを受け継いだ SQL 標準(SQL-92)は、「どの異常を許すか」で4つのレベルを定義します。正しさと性能の取引を「全か無か」ではなくメニューにしたことが要点で、残高の計算には厳しいレベル、多少ずれても困らない閲覧数には緩いレベル、と要件ごとに選べるようになりました。
ただしこの定義には穴がありました。1995年にベレンソンらが論文「A Critique of ANSI SQL Isolation Levels」で、標準の文言があいまいなこと、そして当時すでに使われていたスナップショット分離(トランザクション開始時点の状態を読み続ける方式)が4段階のどこにも当てはまらないことを指摘しました。実際、Oracle の SERIALIZABLE はこのスナップショット分離で、標準の3つの異常はすべて防ぐのに、後で述べるライトスキューは防げません。レベルの名前と実際の保証がずれている現状は、この歴史の名残です。
詳細
3つの読み取り異常
分離レベルを理解する近道は、レベルより先に「防ぎたい異常」を押さえることです。SQL 標準は代表的な異常を3つ挙げています。
ダーティリードは、ほかのトランザクションがまだ COMMIT していない変更を読んでしまうことです。読んだ後にその変更が ROLLBACK されると、「存在しなかったデータ」を根拠に処理を進めたことになります。
ノンリピータブルリードは、1つのトランザクションの中で同じ行を2回読んだとき、あいだにほかのトランザクションの COMMIT が挟まって値が変わることです。残高チェックと引き落としのあいだに別の引き落としが確定すると、チェックの前提が崩れます。
ファントム(ファントムリード)は、行の値ではなく「行の集合」が変わる異常です。同じ条件で範囲検索を2回実行したとき、あいだにほかのトランザクションが条件に合う行を挿入・削除すると、2回目には幽霊のように行が現れたり消えたりします。「該当0件を確かめてから挿入する」処理は、この異常があると一意性が壊れます。
4つのレベルと許される異常
4つのレベルは、3つの異常をどこまで防ぐかの組み合わせとして定義されています。
READ UNCOMMITTED はダーティリードすら許すレベルで、実務で意図的に使うことはほぼありません(PostgreSQL では指定しても READ COMMITTED として動きます)。READ COMMITTED は「確定済みの値しか見えない」ことだけを保証し、PostgreSQL・Oracle・SQL Server の既定です。REPEATABLE READ は同じ行の再読み取りを安定させ、MySQL(InnoDB)の既定です。SERIALIZABLE は「すべてのトランザクションを何らかの順で1本ずつ実行したのと同じ結果」を保証する最も厳しいレベルで、その代わり競合したトランザクションの片方がエラーで中断されます。
表は標準が定める最低ラインで、実装ごとの差が大きい領域です。PostgreSQL の REPEATABLE READ はスナップショット分離として実装されていて、ファントムも起きません。使っている RDBMS のドキュメントで実際の保証を確かめる習慣が大切です。
正体は「どの時点のスナップショットを読むか」
現代の主要な RDBMS では、READ COMMITTED と REPEATABLE READ の違いは「スナップショット(ある時点で確定していたデータの姿)をいつ撮るか」の違いです。READ COMMITTED は文を実行するたびに撮り直し、REPEATABLE READ はトランザクションの最初の文で1回だけ撮って、最後までそれを読み続けます。
これを支えるのが MVCC(Multi-Version Concurrency Control=多版型同時実行制御)です。行を上書きする代わりに、更新のたびに新しい版を作り、古い版もしばらく残します。版には「どのトランザクションが作ったか」が記録されていて、読む側は自分のスナップショットの時点で確定していた版だけを選びます。書き込み中の行でも、読み手には1つ前の確定済みの版を見せればよいので、「読みは書きを待たず、書きは読みを待たせない」が成り立ちます。ただし書き同士は別です。2本のトランザクションが同じ行を更新しようとすると、後から来た側は先の側が COMMIT か ROLLBACK するまで行のロックで待たされます。
版の選ばれ方を、自分で順番を並べ替えて確かめてください。T2 のブロックを T1 の①と②のあいだへドラッグし、レベルを切り替えると、スナップショットの点線が動き、①②から行の版へ伸びる線がつなぎ替わります。T2 の終わりを ROLLBACK にすると、ダーティリードも作れます。
上の段の T2 のブロック(UPDATE・INSERT・COMMIT)を、下の段の T1 の①と②のあいだへドラッグしてください。下の「行の版」で、①②がどの版を読んだかが線で分かります。
T1 はどちらも SELECT * FROM 口座 WHERE 残高 >= 100 を実行します。口座2 などの変わらない行は省いています。版の扱いは PostgreSQL の MVCC に近い単純化です。
古い版はどこかで掃除しなければなりません。PostgreSQL では VACUUM が、どのトランザクションからも見えなくなった版を回収します。長時間開きっぱなしのトランザクションがあると、その古いスナップショットのために版を捨てられず、テーブルが膨らみ続けるのは MVCC の典型的な運用トラブルです。
標準の3分類に収まらない異常
ベレンソンらが指摘したとおり、3つの読み取り異常だけでは足りません。実務で問題になるのは、読んだ値をもとに書き戻す処理で起きる次の2つです。
ロストアップデートは、2本のトランザクションが同じ行を読み、それぞれ計算して書き戻した結果、先の更新が上書きされて消える異常です。在庫 10 を2人が同時に読んで 9 を書けば、2個売れたのに在庫は 9 になります。PostgreSQL の REPEATABLE READ は、同じ行への並行した更新を見つけると後の側をエラーにしますが、MySQL の REPEATABLE READ ではそのまま上書きされます。対策は楽観ロックと悲観ロックの記事で扱います。
ライトスキューは、2本のトランザクションが同じ条件を読み、それぞれ別の行を書き換えた結果、条件が破れる異常です。「当直医は最低1人」というルールで、当直中の2人が同時に「もう1人いるから自分は抜けられる」と確認してそれぞれ自分の行を更新すると、当直が0人になります。書いた行が違うので行のロックでは衝突せず、スナップショット分離でも防げません。防げるのは本物の SERIALIZABLE だけで、PostgreSQL は9.1(2011年)から、読み書きの依存関係を追跡して危ない組み合わせを中断する SSI(Serializable Snapshot Isolation)でこれを実現しています。
実務での選び方
出発点は「既定のレベルのまま、どの異常が業務的に致命的かを考える」ことです。多くの Web アプリケーションは READ COMMITTED か REPEATABLE READ で動きますが、「読んだ値を前提に書き戻す」処理(在庫の引き当て、残高の更新、席の予約)は、分離レベルだけでは守り切れないことが多く、SELECT ... FOR UPDATE による悲観ロックや、バージョン列による楽観ロックを組み合わせるのが定石です。UPDATE 在庫 SET 数 = 数 - 1 WHERE 数 > 0 のように1文で読み書きを済ませられるなら、それが最も単純です。
SERIALIZABLE に上げる場合は、シリアライズエラー(PostgreSQL の SQLSTATE 40001)を受けたらトランザクションごと最初からやり直す実装が前提です。分離レベルは接続やトランザクションの単位で切り替えられるので、「基本は既定値、危ない処理だけ厳しく」という使い分けが現実的です。
試問
読む側の T1 は、同じ SELECT(残高100以上の口座を数える)を①②の2回実行します。書く側の T2 は口座1を1000から200に更新し、口座3を挿入して確定します。T2 のブロックを左右にドラッグすると T1 との実行の順番が変わり、上のスイッチで T1 の分離レベルを切り替えられます。下の「行の版」の線が、①②がどの版を読んだかを示します。
上の段の T2 のブロック(UPDATE・INSERT・COMMIT)を、下の段の T1 の①と②のあいだへドラッグしてください。下の「行の版」で、①②がどの版を読んだかが線で分かります。
T1 はどちらも SELECT * FROM 口座 WHERE 残高 >= 100 を実行します。口座2 などの変わらない行は省いています。版の扱いは PostgreSQL の MVCC に近い単純化です。
