トランザクション分離レベル
Transaction Isolation Level
並行するトランザクション同士が互いの途中経過をどこまで見せ合うかを定める段階的な基準。
概要
トランザクション分離レベルは、同時に走る複数のトランザクションが「互いの作業の途中経過をどこまで見えてよいことにするか」を段階的に定めた基準です。ACID の I(Isolation=分離性)を、完全に守るか・性能のために少し緩めるかの「つまみ」だと考えると分かりやすいでしょう。SQL 標準では緩い順に READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ、SERIALIZABLE の4段階が定義されています。
このつまみが実務で顔を出すのは、たいてい「単体テストでは正しく動くのに、本番の同時アクセスで数字が合わない」という場面です。在庫が二重に引き当てられる、集計の途中で合計がずれる、といった現象の多くは、選んだ分離レベルが許容している「読み取りの異常(アノマリー)」が実際に起きた結果です。逆に言えば、どのレベルがどの異常を許すのかを知っていれば、この種のバグは「仕様通りの挙動」として説明でき、対策も選べるようになります。
なぜ生まれたか
すべてのトランザクションを一列に並べて順番に実行すれば、途中経過が見える問題は原理的に起きません。しかし実際のデータベースには何百・何千の接続が同時に読み書きしにいくため、完全な直列実行は性能面で成立しません。かといって無秩序に並行実行すれば、コミット前の値を読んでしまう、同じ行を2回読んだら値が変わっている、といった異常が噴出します。
そこで 1970〜80年代のデータベース研究では、「どんな異常が存在するか」を分類し、「どの異常までなら許容するか」を段階として定義するアプローチが取られました。これが SQL 標準(SQL-92)の分離レベルです。ポイントは、正しさと性能のトレードオフを「全か無か」ではなくメニューにしたことです。銀行の残高計算には最も厳格なレベルを、多少ずれても困らない閲覧数の表示には緩いレベルを、とアプリケーションの要件ごとに選べるようになりました。
詳細
3つの読み取り異常
分離レベルを理解する近道は、レベルそのものより先に「防ぎたい異常」を押さえることです。SQL 標準は代表的な異常を3つ挙げています。
ダーティリードは、他のトランザクションがまだコミットしていない変更を読んでしまうことです。読んだ後にその変更がロールバックされると、「存在しなかったはずのデータ」を根拠に処理を進めたことになります。
ノンリピータブルリードは、1つのトランザクション内で同じ行を2回読んだとき、間に他のトランザクションのコミットが挟まって値が変わってしまうことです。たとえば残高チェックと引き落としの間に別の引き落としがコミットされると、チェックの前提が崩れます。
ファントムリードは、行の値ではなく「行の集合」が変わる異常です。同じ条件で範囲検索を2回実行したとき、間に他のトランザクションが条件に合致する行を挿入・削除すると、2回目には「幽霊のように」行が現れたり消えたりします。「該当0件を確認してから挿入する」ような処理は、この異常があると一意性が壊れます。
4つのレベルと許される異常
4段階のレベルは、この3つの異常をどこまで防ぐかの組み合わせとして定義されています。
READ UNCOMMITTED はダーティリードすら許す最も緩いレベルで、実務で意図的に使うことはほぼありません。READ COMMITTED は「コミット済みの値しか見えない」ことだけを保証するレベルで、PostgreSQL や Oracle の既定値です。REPEATABLE READ はトランザクション開始時点のスナップショットを読み続けるため、同じ行の再読み取りが安定します(MySQL/InnoDB の既定値)。SERIALIZABLE は「すべてのトランザクションを何らかの順で直列に実行したのと同じ結果」を保証する最も厳格なレベルで、その代わり競合時にはトランザクションの中断・リトライが発生します。
なお標準の定義はあくまで最低ラインで、実装ごとの挙動差が大きい領域でもあります。たとえば PostgreSQL の REPEATABLE READ はスナップショットの仕組み上ファントムリードも実質的に防ぎますし、逆に「ロストアップデート」や「ライトスキュー」のように標準の3分類に収まらない異常も存在します。使っている RDBMS のドキュメントで実際の保証を確認する習慣が大切です。
分離レベルと異常の対応は、表を眺めるより自分で組み合わせを変えてみるのが早道です。レベルを切り替えながら3つの異常を再現してみてください。
MVCC — ロックせずに分離を実現する仕組み
素朴に考えると、分離性はロックで実現することになります。読む行に共有ロック、書く行に排他ロックをかければ異常は防げますが、「読む人が書く人を待たせ、書く人が読む人を待たせる」ため並行性が大きく落ち、デッドロックの温床にもなります。
そこで現代の主要な RDBMS(PostgreSQL、MySQL/InnoDB、Oracle など)が採用しているのが MVCC(Multi-Version Concurrency Control=多版型同時実行制御)です。行を上書きする代わりに複数のバージョンを保持し、各トランザクションには「自分が開始した時点で見えるべきバージョン」を返します。書き込み中の行でも、読み手には一つ前のコミット済みバージョンを見せればよいので、「読み取りは書き込みをブロックせず、書き込みは読み取りをブロックしない」が成立します。READ COMMITTED は文ごとに、REPEATABLE READ はトランザクションごとにスナップショットを取る、という違いとして両レベルを実装できるのも MVCC の利点です。ただし古いバージョンの掃除(PostgreSQL の VACUUM など)という運用コストが伴います。
実務での選び方
出発点は「既定値のまま、どの異常が業務的に致命的かを考える」ことです。多くの Web アプリケーションは READ COMMITTED か REPEATABLE READ で十分に動きますが、「読んだ値を前提に書き戻す」処理(在庫引き当て、残高更新、席の予約など)は、分離レベルだけでは守り切れないことが多く、楽観ロックと悲観ロックによる明示的な競合制御を組み合わせるのが定石です。SERIALIZABLE に上げる場合は、競合時のシリアライズエラーをアプリケーション側でリトライする実装が前提になることを忘れないでください。分離レベルは接続やトランザクション単位で切り替えられるため、「基本は既定値、危ない処理だけ厳格に」という使い分けが現実的です。
