トランザクションの分離レベルについてまとめてみた！

こんにちは！株式会社GROWTH VERSEに所属しております川田剣士です。最近トランザクションの分離レベルに関してしっかり理解したいなと思い、個人的に学習し技術記事で体系的にまとめようと思いました。本記事を読むことでトランザクションの分離レベルについて理解していただけることを目的としています。記事に対する質問・コメント大歓迎です！
英語バージョンは[こちら](https://qiita.com/kenji-kawata/items/9a8c7cb5aa49583dee94)から見れます。

# 目次
- [トランザクションとは](#トランザクションとは)
- [ACID特性とは](#acid特性とは)
- [トランザクション分離レベルについて](#トランザクション分離レベルについて)
- [repeatable-read分離レベルだがphantom-readが起こらないような仕組み](#repeatable-read分離レベルだがphantom-readが起こらないような仕組み)
- [終わりに](#終わりに)

## トランザクションとは

トランザクションとは、データベースに対する一連の処理をひとまとめにしたもので、「すべての処理が成功するか、あるいはすべての処理が失敗する」という単位を保証する仕組みです。例えば、銀行口座間での送金処理を考えると、「Aさんの口座から1万円を引き落とす」処理と、「Bさんの口座に1万円を入金する」処理の両方が成功するか、あるいは両方とも行われないことが保証される必要があります。

トランザクションを利用しない場合、処理の途中でエラーが発生したりシステム障害が起きたりすると、一部の処理だけが完了してしまい、データに矛盾が生じる恐れがあります。これが「データ不整合」と呼ばれる問題です。トランザクションはこうした問題を防ぐために不可欠な仕組みです。

## ACID特性とは

ACID特性とは、データベースのトランザクション処理において、データの整合性を保つために必要な4つの特性を指します。原子性（Atomicity）、一貫性（Consistency）、独立性（Isolation）、永続性（Durability）の4つの要素を組み合わせたものです。

### Atomicity（原子性）

トランザクション内のすべての処理が成功するか、あるいはすべての処理が失敗することを保証します。途中でエラーが発生した場合、トランザクション開始前の状態に戻ります。

### Consistency（一貫性）

トランザクションの実行前後でデータベースの整合性が常に保たれていることを保証します。例えば、銀行口座の残高が負にならないようなルールが一貫性の一例です。

### Isolation（独立性・隔離性）

複数のトランザクションが同時に実行される場合でも、それぞれが互いの影響を受けずに独立して処理されることを保証します。例えば、同じデータを別々のトランザクションが同時に更新しても、矛盾が起こらないように制御します。

### Durability（永続性）

トランザクションが一度コミット（完了）された後は、障害が発生してもその結果が失われないことを保証します。これにより、データベースの耐久性と信頼性が確保されます。

## トランザクション分離レベルについて

トランザクション分離レベルとは、複数トランザクションが同時に動作した際に発生するデータの整合性問題に対処するための設定です。上のACID特性でConsistencyとisolationについて述べていますが、実際の運用では完全に一貫性を保ち、かつ完全にトランザクション間で分離されるように分離レベルで制御するということは少ないです(この完全な分離レベルはSERIALIZABLEと呼びます)。もし、全てのトランザクションが完全に一貫性を保ち、完全にトランザクション間で分離されていることを目指すのであれば、トランザクション間の同時実行をせず、トランザクションを1つずつ処理していくことで実現できます。ただ、上記の方針をとると並列実行ができないため、パフォーマンスが非常に悪くなります。パフォーマンスと一貫性とでトレードオフの関係があるため、仕様として一貫性をどこまで求められているか理解し、適切な分離レベルを指定することが重要です。

トランザクション分離レベルの種類は以下の4つです。下へ行くほどトランザクション間の分離レベルが高いです。

- READ UNCOMMITTED（読み取り非コミット）
  - 他のトランザクションが コミットしていないデータ（未確定データ）も読み取れる
  - 一番制限が緩い分離レベル
  - 用途は一貫性よりもパフォーマンスを優先する読み取り専用の処理
- READ COMMITTED（読み取りコミット済み）
  - 他のトランザクションが コミットしたデータのみを読み取れる
  - 一般的なデフォルト設定（例：PostgreSQL）
  - 一般的な業務処理に適しており、バランスが良い
- REPEATABLE READ（再読可能読み取り）
  - 同じトランザクション内で、同じクエリ結果は常に同じになる（行レベルの一貫性を保持）
  - ロックまたはスナップショット方式で整合性を守る
  - 複数回同じデータにアクセスし、整合性が重要なバッチ処理など
- SERIALIZABLE（直列化可能）
  - 複数のトランザクションが、順番に実行されたかのように振る舞う
  - 一番厳格な分離レベル
  - ロックや検出処理が増え、スループットが低下しやすい
  - 銀行や在庫管理など、高い整合性が求められる処理

各分離レベルで許容される現象（問題）の違いを表形式でまとめます。

| 分離レベル            | Dirty Read | Non-repeatable Read | Phantom Read |
| ---------------- | ---------- | ------------------- | ------------ |
| READ UNCOMMITTED | 発生する       | 発生する                | 発生する         |
| READ COMMITTED   | 発生しない      | 発生する                | 発生する         |
| REPEATABLE READ  | 発生しない      | 発生しない               | 発生することがある    |
| SERIALIZABLE     | 発生しない      | 発生しない               | 発生しない        |

それぞれの現象について以下にまとめます。
### Dirty Read（ダーティリード）
コミットされていない（未確定の）変更を他のトランザクションが読み取ってしまう現象です。

具体例
1. トランザクションAでAさんの口座から1万円を引き出し、残高を更新（未コミット）
1. トランザクションBでAさんの口座残高を読み取り、更新された（未コミット）残高を表示
1. その後、トランザクションAがエラーでロールバックすると、トランザクションBは誤った残高を読み取っていたことになる

### Non-repeatable Read（ノンリピータブルリード）
同一トランザクション内で同じデータを複数回読み取った際に、そのデータが他のトランザクションによって変更されてしまう現象です。

具体例
1. トランザクションAがAさんの口座残高を1回目に読み取り（例：10万円と表示）
1. トランザクションBがAさんの口座に2万円を入金しコミット（残高12万円）
1. トランザクションAが再びAさんの口座残高を読み取ると、最初と異なる12万円が表示される

### Phantom Read（ファントムリード）
同一トランザクション内で条件指定して読み取った複数データに対して、2回目以降の読み取り時に新しい行が追加され、データ件数が変わる現象です。

具体例
1. トランザクションAが「残高が100万円以上ある口座」を検索（5件取得）
1. トランザクションBが新たに口座を開設し、200万円を入金してコミット
1. トランザクションAが再度同じ検索をすると、新しく追加された口座が現れ、取得結果が6件となる

個人的にはPostgreSQLのREAD COMMITTEDに慣れてしまっているためか、Non-repeatable ReadとPhantom Readは特に問題ではないと感じてしまいます。しかし、トランザクションのACID特性の一貫性は整合性を高く保つ上では重要です。Non-repeatable Readに関しては、同じデータを参照したにも関わらず結果が異なるという点、Phantom Readに関しては、新規データの追加により結果が異なってしまうという点で一貫性が低いという部分が問題である認識していただければと思います。

## REPEATABLE READ分離レベルだがPhantom Readが起こらないような仕組み

ちなみに、MySQL InnoDBなどはデフォルトでREPEATABLE READ分離レベルを採用していますが、Phantom Readが起こらないような仕組みになっています。以下の二つの仕組みによりPhantom Readの防止を実現しています。

### MVCC（多版同時実行制御）によるスナップショット読み取り
InnoDBは、各トランザクションに「開始時点のスナップショット」を提供します。
そのスナップショットに含まれている行だけを対象に読み取りを行うため、新しく挿入された行（ファントム）を無視します。

```sql
START TRANSACTION;
SELECT * FROM orders WHERE amount > 100;
-- 他トランザクションが新しい行をINSERTしてCOMMIT
SELECT * FROM orders WHERE amount > 100;
-- 新しい行は見えない！（MVCCで防止される）
COMMIT;
```

MVCCに関してはRepetable Readの実現にも使用されていますが、Phantom ReadまでMVCCでカバーできるかはDBMSの実装に依存します。そのため、防ぎきれない場合には次の範囲ロックが必要になります。

### 意図しないファントムを防ぐ行ロック（範囲ロック）
挿入によるファントムを防ぐには範囲ロックが必要なケースがあります。
通常の SELECT ではなく、以下のように SELECT ... FOR UPDATE や SELECT ... LOCK IN SHARE MODE を使うと、InnoDBはインデックスレンジロック（Gap Lock） を使います。
これにより、指定範囲に新しい行を挿入できなくなり、ファントムリードも確実に防止できます。

```sql
START TRANSACTION;
SELECT * FROM orders WHERE amount > 100 FOR UPDATE;
-- この間、他のトランザクションはamount > 100の範囲にINSERTできない
COMMIT;
```

## 終わりに
最後まで読んでいただき、ありがとうございます！