はじめに
データベースを使っていると、「一連の処理をまとめて成功させるか、全部なかったことにするか」という仕組みが当たり前のように動いています。
でも、こう聞かれるとどうでしょう?
- 「もしここでクラッシュしたら、データはどうなる?」
- 「もし同じデータに同時にアクセスされたら?」
正直なところ、自分はうまく言語化できませんでした。「なんとなく安全」で済ませていた部分を、この記事で一つずつ紐解いていきます。
TL;DR
トランザクションが守ってくれていること(ACID):
┌──────────────────────────────────────────────┐
│ Atomicity(原子性) 全部成功 or 全部取り消し │
│ Consistency(一貫性) ルール違反を起こさない │
│ Isolation(分離性) 並行実行しても安全 │
│ Durability(永続性) 確定したら消えない │
└──────────────────────────────────────────────┘
分離性を実現する3つの考え方:
悲観的(2PL)→ 先にロックして競合を防ぐ
楽観的(OCC)→ まず実行、後で競合チェック
MVCC → 古いバージョンを残して読み取りを速く
1. トランザクションの基本 ── 銀行送金で考える
口座Aから口座Bへ1万円を送金する場面を想像してみてください。
口座A: 残高 5万円 → 引き出し -1万円 → 残高 4万円
口座B: 残高 3万円 → 預け入れ +1万円 → 残高 4万円
もし引き出しは成功したのに、預け入れの前にシステムがクラッシュしたら? 1万円が消えます。
これを防ぐために、「引き出しと預け入れを切り離せない一つの塊」として扱います。これが トランザクション です。RDB(リレーショナルデータベース)では
BEGIN(開始)/ COMMIT(確定)/ ROLLBACK(取り消し)という命令でこれを実現しています。
2. トランザクションが保証する4つの性質(ACID)
トランザクションが提供する保証は、ACID という4つの性質にまとめられます。先ほどの送金の例に沿って、一つずつ見ていきます。
Atomicity(原子性)── 「全部か、ゼロか」
トランザクション内の操作は、全部成功するか、全部なかったことになるかのどちらかです。
送金の例で言えば、引き出し後にDBがクラッシュしたとしても、復旧時に自動でロールバックされます。
裏側の仕組み: WAL(Write-Ahead Log)
DBは変更を実際に適用する前に、まず「何を変更するか」をディスク上のログに記録します。クラッシュ時はこのログを見て、やり直し(Redo)や取り消し(Undo)を行います。この仕組みのおかげで、原子性と永続性が保証されています。
Consistency(一貫性)── 「ルール違反を起こさない」
アプリケーションレベルの不変条件(「送金前後で口座全体の合計金額は変わらない」など)が維持されることを指します。
実はACIDの「C」は、分散システムで登場する「結果整合性」などの一貫性モデルとは無関係です。ACIDの一貫性はあくまで「アプリケーションが定義したルールを破らない」という意味で、DB側の保証というよりアプリ側の責任に近いものです。
Isolation(分離性)── 「並行実行しても安全」
同時に走る複数のトランザクションが、お互いに干渉しないことを保証します。
これは次のセクションで詳しく掘り下げます。
Durability(永続性)── 「コミットしたら消えない」
一度コミット(確定)されたデータは、その直後にDBがクラッシュしても消えません。
トランザクション: 送金処理
──────────────────────
口座Aから引き出し
口座Bへ入金
コミット ← ここで「確定」
│
↓
直後にクラッシュ!
│
↓
復旧後 → コミット済みのデータはちゃんと残っている
ストレージへの書き込みと、障害に備えたレプリケーション(データの複製)によって実現されています。
実務での恩恵
開発者はトランザクションのおかげで「もしここでクラッシュしたら?」「もし同時にアクセスされたら?」という失敗シナリオの処理から解放され、ビジネスロジックに集中できています。
この恩恵は普段意識しないからこそ、いざ失われたとき(例えば複数のデータストアにまたがる処理が必要になったとき)に初めて、そのありがたさに気づくものです。
3. 分離性を深掘りする ── 「どこまで守るか」の選択
2章で紹介した分離性(Isolation)は、実は「ON/OFF」のような単純なものではありません。どの種類の同時アクセス問題をどこまで防ぐかに段階があり、それを「分離レベル」と呼びます。
まずは「防がないと何が起きるのか」を見てから、分離レベルの選択肢を整理していきます。
3.1 並行実行で起きるレースコンディション
複数のトランザクションが同じデータに同時にアクセスすると、いくつかの種類の問題が起きえます。一つずつ見ていきます。
ダーティライト
トランザクションAがまだコミット(確定)していない書き込みを、トランザクションBが上書きしてしまう問題です。
2つの注文処理が同時に在庫数を更新すると、片方の更新が消えてしまいます。
ダーティリード
まだ完了していないトランザクションの書き込みが、別のトランザクションから見えてしまう問題です。
送金処理中に、引き出し後・預け入れ前のタイミングで残高を読み取られると、実際よりも少ない残高が見えてしまいます。
ファジーリード(非再現リード)
同じ値を2回読み取ったのに、間に別のトランザクションが更新を入れたため、1回目と2回目で異なる値が返ってくる問題です。
レポートの集計処理中に対象データが変更されると、集計結果がおかしくなります。
ファントムリード
条件に一致するレコード群を読んでいる最中に、別のトランザクションがその条件に一致するレコードを追加・削除してしまう問題です。
全従業員の給与合計を計算している最中に一部の従業員レコードが削除されると、合計金額が実態と合わなくなります。
3.2 分離レベル ── どこまで守るかの選択
これらのレースコンディションからどこまで保護するかを決めるのが 分離レベル です。「どの種類のレースコンディションを禁止するか」で段階的に定義されています。
↑ より強い保護(= より安全だがパフォーマンスコスト大)
│
┌───────────────────────┐
│ Serializable │ 全レースコンディションを禁止
│ │ トランザクションが順番に実行されたのと同じ結果を保証
├───────────────────────┤
│ Repeatable Read │ + ファジーリードを禁止
├───────────────────────┤
│ Read Committed │ + ダーティリードを禁止
│ (PostgreSQLの初期値) │
├───────────────────────┤
│ Read Uncommitted │ ダーティライトのみ禁止
└───────────────────────┘
│
↓ より弱い保護(= パフォーマンスは良いがリスクあり)
Strict Serializability(厳密な直列化可能性) という、さらに強い分離レベルも存在します。Serializableにリアルタイム保証を加えたもので、あるトランザクションが完了したら、その結果は即座に全ての後続トランザクションから見えることを保証します。Google SpannerなどのNewSQLデータベースはこのレベルを実現しています。
3.3 実務での判断基準
分離レベルは意識的に選択することが大切です。デフォルト設定に任せてしまうと、知らないうちにデータストアが弱い分離レベルを使っているかもしれません。例えばPostgreSQLのデフォルトはRead Committedで、ファジーリードやファントムリードは防いでくれません。
迷ったらSerializableを選ぶのが安全ですが、パフォーマンスコストが伴います。使用しているDBが実際にどの分離レベルを保証しているかは、Jepsenなどのリファレンスで確認するのがおすすめです。
DBベンダーが公称する分離レベルの名前と、学術的な定義が一致しないケースがあります。「Repeatable Read」と書かれていても、実装上はスナップショット分離(Snapshot Isolation)だったりするので、公式ドキュメントで実際の挙動を確認しておくと安心です。
4. 分離性を実現する仕組み ── 並行性制御プロトコル
分離レベルの概念がわかったところで、「DBは内部でどうやってこれを実現しているのか?」を見ていきます。この仕組みを知ると、「なぜ強い分離性にはパフォーマンスコストがかかるのか」が腑に落ちます。
このセクションの読み方
ここは仕組みの「考え方」を掴むのが目的です。細かい手順を暗記する必要はありません。押さえてほしいのは3つだけ:
- 悲観的(2PL): 先にロックを取って、競合を防ぐ
- 楽観的(OCC): まず実行して、後で競合をチェックする
- MVCC: 古いバージョンを残して、読み取りを速くする
この3つの考え方の違いがわかれば十分です。
4.1 悲観的アプローチ ── 2相ロック(2PL)
「競合するかもしれないから、先にロックを取って防ごう」という考え方です。
ここで言うロックとは、「このデータを今使っているので、他のトランザクションは待ってください」とDBに宣言する仕組みです。
ロックはデータごとに個別にかかります。例えば「口座Aから口座Bへ送金する」トランザクションでは、口座Aのロックと口座Bのロックは別々です。ロックを取得するとそのデータに他のトランザクションがアクセスできなくなり、解放するとアクセスできるようになります。
読み取りロックと書き込みロック ── 複数トランザクション間のルール
2PL(Two-Phase Locking)では、2種類のロックを使います。
- 読み取りロック: 「このデータを読んでいます」という宣言。データを変えるわけではないので、他のトランザクションも同時に読める。ただし、誰かが書き込みたい場合は待ってもらう
- 書き込みロック: 「このデータを書き換えています」という宣言。データが変わる最中なので、他のトランザクションは読むことも書くこともできず、終わるまで待ってもらう
| やりたいこと | 他のトランザクションが読み取りロック中 | 他のトランザクションが書き込みロック中 |
|---|---|---|
| 読みたい | できる | 待つ |
| 書きたい | 待つ | 待つ |
2つのフェーズ ── 一つのトランザクション内のルール
次は視点を変えて、一つのトランザクションの中でロックの取得と解放をどういう順序で行うか、というルールです。ここでの「ロック」は読み取りロック・書き込みロックの両方を指します。どちらであっても、以下のルールに従います。
口座Aから口座Bへ送金するトランザクションの動きを見てみます。
2PLのルールは、この 「取得 → 処理 → 解放」がワンサイクルであること です。一度でも解放を始めたら、そのトランザクションが新たにロックを取得することはできません。
OK: 取得(A) → 取得(B) → 処理 → 解放(A) → 解放(B)
NG: 取得(A) → 解放(A) → 取得(B) → ...
↑ 解放した後に別のデータのロックを取得している!
この隙間に他のトランザクションが口座Aを書き換えてしまう可能性がある
実際には、トランザクションが完了(コミット)するまでロックを一切解放しないStrict 2PLが一般的です。つまり必要なロックを全て取得し、コミットの時点でまとめて解放します。こうすれば「隙間」が生まれないので、より安全です。
デッドロック問題
2PLには厄介な問題があります。2つのトランザクションが異なる順番で同じデータの書き込みロックを取得しようとすると、お互いが相手のロック解放を待ち続けて、どちらも永遠に進めなくなります。これがデッドロックです。
書き込みロックで起きるのは、先ほどの表の通り「書き込みロック中は読むことも書くこともできない」ためです。読み取りロック同士は互いにブロックしないので、デッドロックにはなりません。
具体的な流れを見てみます。
一般的な対処法は、デッドロックを検出したら「犠牲」となるトランザクションを選んでアボート(中断)&リトライすることです。
4.2 楽観的アプローチ ── OCC(楽観的並行性制御)
「競合は稀だろうから、まず自由に実行して、コミット時に問題がなかったか検証しよう」という考え方です。2PLとは対照的なアプローチですね。
動作の流れ
OCCの競合チェック(検証)の方式は一つではありません。読み書きしたデータの集合を比較する方式やタイムスタンプを使う方式など、複数の実装が存在します。
2PLとOCC、どちらを使うか
- OCC は、読み取りが多く競合が少ないワークロードに向いています
- 2PL は、競合が激しいワークロードに向いています(無駄な作業を事前に防げるため)
どちらが良いということではなく、ワークロードの特性に応じた使い分けです。
実務で身近なOCC: 楽観的ロック
OCCの考え方を限定的に適用した「楽観的ロック」は、実務で非常によく使われています。
仕組みはシンプルです。オブジェクトにバージョン番号を付与し、更新時に「バージョンが変わっていなければ更新する」という条件付き更新(compare-and-swap)を行います。
1. バージョン 42 のデータを読み取る
2. ローカルで処理する
3. 「バージョンがまだ 42 なら」更新を適用 → 成功したらバージョンを 43 に
→ バージョンが変わっていたら失敗 → リトライ
多くのデータストアやORM(Object-Relational Mapping)でサポートされているので、触れたことがある方も多いのではないでしょうか。
4.3 読み取りを高速化する ── MVCC(マルチバージョン並行性制御)
ここまでの2PLとOCCには、共通の弱点があります。読み取り専用のトランザクションにとって最適ではない という点です。
- 2PLでは、読み取りでも読み取りロックを取得するので、書き込みロックとの競合で待たされることがある
- OCCでは、読み取った値がコミット前に他のトランザクションに上書きされるとリトライになる
一般的に、読み取りは書き込みよりもはるかに多いので、読み取りの効率化は非常に重要です。
MVCCの仕組み
MVCC(Multi-Version Concurrency Control)は、データの古いバージョンを保持する ことでこの問題を解決します。
時刻 t=100 で書き込み → バージョン 100 として保存
時刻 t=200 で書き込み → バージョン 200 として保存(100も残る)
読み取りトランザクション(開始時刻 t=150)→ バージョン 100 を読む(開始時点のスナップショット)
読み取りトランザクション(開始時刻 t=250)→ バージョン 200 を読む
読み取りトランザクションは、自身が開始した時点のスナップショットを読みます。書き込みとの衝突でブロックされたりアボートされたりすることがありません。
MVCCは読み取りを高速化する仕組みであり、書き込みトランザクション同士の競合解決には、従来の2PLやOCCが併用されます。つまり「MVCC + 2PL」「MVCC + OCC」のように組み合わせて使うのが一般的です。MVCCは今日最も広く採用されている並行性制御の仕組みで、PostgreSQL、MySQL(InnoDB)、Oracleなど、主要なDBが採用しています。
まとめ
この記事で扱ったことを振り返ります。
- トランザクションの基本 ── 「一連の操作を切り離せない一つの塊」として扱う仕組み
- ACID ── 原子性、一貫性、分離性、永続性の4つの保証
- レースコンディション ── ダーティライト/リード、ファジーリード、ファントムリード。並行実行で起きる問題
- 分離レベル ── どのレースコンディションを禁止するかの段階的な選択。強いほど安全だがコストも高い
- 並行性制御プロトコル ── 悲観的(2PL)、楽観的(OCC)、MVCC。分離レベルを実現する内部の仕組み
普段なんとなく使っているトランザクションが、裏側でどれだけのことを守ってくれているか。この記事でその一端が伝わっていれば嬉しいです。