はじめに
この記事では、MySQL InnoDBのUNDO Logの内部実装を深掘りします。
REDO Logの記事では「変更後の内容を記録して、クラッシュ時に復元する」仕組みを解説しました。UNDO Logはその逆で、「変更前の内容を記録して、ロールバックやMVCCに使う」仕組みです。MySQL 8.0のソースコードを追いながら、以下を解説します:
- UNDO Logの2つの役割(ロールバックとMVCC)
- Undo Recordの中身(Insert型とUpdate型)
- バージョンチェーン(Rollback Pointer)
- MVCC(ReadViewによる可視性判定)
- 論理構造と物理構造(Undo Tablespace)
- Purge(不要なバージョンの削除)
元ネタは Hanzhi 氏の An In-Depth Analysis of UNDO Logs in InnoDB です。
そもそもUNDO Logって何?
UNDO Logには2つの役割があります。
役割1:トランザクションのロールバック
ユーザーがROLLBACKを実行したとき、またはクラッシュ復旧時に未コミットのトランザクションを取り消すとき、UNDO Logを使って変更を元に戻します。
1. BEGIN;
2. UPDATE employees SET salary = 5000 WHERE id = 1;
→ UNDO Log: 「id=1のsalaryは元々3000だった」を記録
3. ROLLBACK;
→ UNDO Logを見て salary を 3000 に戻す
役割2:MVCC(Multi-Version Concurrency Control)
読み取りトランザクションと書き込みトランザクションが互いにブロックしないために、InnoDBは各レコードの複数バージョンを保持します。UNDO Logがこの過去バージョンの保管場所です。
トランザクションA(読み取り): SELECT salary FROM employees WHERE id = 1;
→ 自分の開始時点のバージョン(3000)を見る
トランザクションB(書き込み): UPDATE ... SET salary = 5000 WHERE id = 1;
→ 最新バージョン(5000)を書く
→ 旧バージョン(3000)はUNDO Logに残る
AはBの変更を見ない。BはAの読み取りを待たない。これがMVCCです。
REDO LogとUNDO Logの関係
重要なポイント:UNDO Log自体もデータです。UNDO Logへの書き込みもページの変更なので、そのREDO Logが記録されます。
UNDO Log ← REDO Logで保護される
↓
つまり、UNDO Logはクラッシュしても失われない
REDO Logが「物理的・ページベース」なのに対し、UNDO Logは「論理的・トランザクションベース」です。
Undo Recordの中身
レコードを変更するたびに、1つのUndo Recordが生成されます。Insert型とUpdate型の2種類があります。
Insert型(TRX_UNDO_INSERT_REC)
新しいレコードの挿入時に生成されます。ロールバック時にDELETEするだけなので、主キー情報だけ記録すれば十分です。MVCCには使われません(挿入前にはバージョンが存在しないため)。
| Undo Number | Table ID | Primary Key Fields |
|---|---|---|
| (連番) | (テーブル) | (PK値) |
Update型(TRX_UNDO_UPD_EXIST_REC / DEL_MARK_REC / UPD_DEL_REC)
既存レコードの更新・削除時に生成されます。MVCCのために変更前の値を記録します。
| Undo Number | Table ID | Primary Key Fields |
|---|---|---|
| Transaction ID | Rollback Ptr | Update Fields |
| (この版のTxID) | (前の版への | (変更されたカラムの |
| ポインタ) | 番号・長さ・旧値) |
3つのサブタイプ: – UPD_EXIST_REC:通常のUPDATE – DEL_MARK_REC:DELETE(実際にはDelete Markを立てるだけ) – UPD_DEL_REC:Delete Mark済みレコードへの再INSERT
InnoDBのDELETEは即座にレコードを消しません。Delete Markフラグを立てるだけです。物理的な削除は後述のPurgeが行います。
バージョンチェーン
Update型Undo RecordのRollback Pointer(Roll Ptr)が、MVCCの核心です。
仕組み
各レコードには隠しカラムDB_TRX_ID(最終更新トランザクションID)とDB_ROLL_PTR(Undo Recordへのポインタ)があります:
Clustered Indexのレコード:
| PK | DB_TRX_ID | DB_ROLL_PTR | user columns ... |
|---|---|---|---|
| 1 | TxID=200 | ptr→UndoB | salary=5000 |
↓
Undo Record B (TxID=200):
│ salary の旧値 = 4000, Roll Ptr → Undo A │
↓
Undo Record A (TxID=150):
│ salary の旧値 = 3000, Roll Ptr → NULL │
これがバージョンチェーンです。最新版からRoll Ptrをたどることで、任意の過去バージョンにアクセスできます。
MVCC:ReadViewによる可視性判定
バージョンチェーンがあっても、「どのバージョンを見るべきか」を決める仕組みが必要です。それがReadViewです。
ReadViewの構造
トランザクションがSELECTを実行するとき(REPEATABLE READの場合はトランザクション開始時)、ReadViewが作成されます:
struct ReadView {
trx_id_t m_low_limit_id; // この値以上のTxIDは「未来」→ 見えない
trx_id_t m_up_limit_id; // この値未満のTxIDは「確定済み」→ 見える
trx_ids_t m_ids; // 作成時点でアクティブなTxIDのリスト → 見えない
trx_id_t m_creator_trx_id; // 自分自身のTxID → 見える
};可視性判定のロジック
バージョンチェーンを先頭からたどり、最初に「見える」バージョンを返します:
for each version in version_chain:
trx_id = version.DB_TRX_ID
if trx_id == m_creator_trx_id:
→ 自分の変更 → 見える ✅
if trx_id < m_up_limit_id:
→ ReadView作成前にコミット済み → 見える ✅
if trx_id >= m_low_limit_id:
→ ReadView作成後に開始 → 見えない ❌
if trx_id in m_ids:
→ ReadView作成時にまだアクティブ → 見えない ❌
else:
→ ReadView作成前にコミット済み → 見える ✅
分離レベルとReadView
| 分離レベル | ReadView作成タイミング |
|---|---|
| READ COMMITTED | 毎回のSELECTで新しいReadViewを作成 |
| REPEATABLE READ | トランザクション開始時に1回だけ作成 |
REPEATABLE READでは同じReadViewを使い続けるため、トランザクション中に他のトランザクションがコミットしても見えません。READ COMMITTEDでは毎回新しいReadViewを作るため、他のコミットが見えます。
UNDO Logの組織構造
論理構造
Undo Recordは以下の階層で整理されます:
Undo Tablespace
└─ Rollback Segment(128個)
└─ Undo Log(トランザクションごと)
└─ Undo Record(操作ごと)
- Undo Tablespace:UNDO専用のファイル(
undo_001、undo_002) - Rollback Segment(Rseg):Undo Logのコンテナ。1つのRsegは最大1024個のUndoスロットを持つ
- Undo Log:1トランザクションにつき最大2つ(Insert用とUpdate用を分離)
- Undo Record:個々の変更記録
Insert用とUpdate用を分けるのは、Insert Undo Logはコミット後すぐ再利用できる(MVCCに不要)のに対し、Update Undo Logは他のトランザクションが参照中かもしれないためです。
物理構造
Undo Logは通常のInnoDBページ(16KB)に格納されます。Undo専用のページタイプFIL_PAGE_UNDO_LOGです。
Undo Page:
- FIL Header
- Undo Page Header
- ├─ Type (Insert / Update)
- ├─ Latest Log Record Offset
- └─ Free Space Offset
- Undo Record 1
- Undo Record 2
- ...
- Free Space
1ページに収まらない場合、複数ページがリンクリストで繋がります。
MySQL 8.0のUndo Tablespace
MySQL 8.0では、UNDOは専用のテーブルスペースに格納されます:
-- Undo Tablespaceの確認
SELECT TABLESPACE_NAME, FILE_NAME, AUTOEXTEND_SIZE
FROM INFORMATION_SCHEMA.FILES
WHERE FILE_TYPE = 'UNDO LOG';
-- 追加のUndo Tablespaceを作成
CREATE UNDO TABLESPACE undo_003 ADD DATAFILE 'undo_003.ibu';MySQL 5.7以前はシステムテーブルスペース(ibdata1)内にUNDOが格納されており、肥大化の原因になっていました。8.0で分離されたことで、不要になったUndo TablespaceをALTER UNDO TABLESPACE ... SET INACTIVEで縮小できるようになりました。
Purge:不要バージョンの削除
バージョンチェーンは際限なく伸び続けるわけではありません。どのトランザクションからも参照されなくなったバージョンは、Purgeスレッドが削除します。
Purgeの対象
- Delete Markされたレコードの物理削除
- 不要になったUndo Recordの削除
Purgeの判定
「どのReadViewからも参照されない」= 「最も古いアクティブなReadViewよりも前にコミットされたバージョンの、さらに前のバージョン」が削除対象です。
バージョンチェーン:
最新(TxID=300) → v2(TxID=200) → v1(TxID=100) → v0(TxID=50)
最も古いアクティブReadView: m_up_limit_id = 150
→ TxID=100のバージョンは参照される可能性あり(ReadViewが見るかも)
→ TxID=50のバージョンは不要 → Purge対象
History List
Purge対象のUndo LogはHistory Listで管理されます。トランザクションがコミットすると、そのUpdate Undo LogがHistory Listに追加されます。Purgeスレッドはこのリストを古い順にたどって削除します。
-- History Listの長さを確認
SHOW ENGINE INNODB STATUS;
-- → History list length: 1234History list lengthが大きい場合、Purgeが追いついていないことを意味します。長時間のトランザクションやReadViewが原因で、UNDO領域が肥大化します。
Purgeが遅れるとどうなるか
- Undo Tablespaceが肥大化する
- バージョンチェーンが長くなり、SELECT性能が劣化する
- Clustered IndexのレコードにDelete Markが残り続け、スキャン性能が劣化する
-- Purgeスレッド数の調整
SET GLOBAL innodb_purge_threads = 4; -- デフォルト4
-- Purgeのバッチサイズ
SET GLOBAL innodb_purge_batch_size = 300; -- デフォルト300まとめ
| トピック | ポイント |
|---|---|
| 役割 | ロールバック + MVCC(2つの役割を1つの仕組みで実現) |
| Undo Record | Insert型(PKのみ)とUpdate型(旧値 + Roll Ptr) |
| バージョンチェーン | Roll Ptrで過去バージョンを辿る連結リスト |
| MVCC | ReadViewで可視性を判定。分離レベルで作成タイミングが変わる |
| 物理構造 | Undo Tablespace → Rollback Segment → Undo Log → Undo Record |
| Purge | 不要バージョンをバックグラウンドで削除。History Listで管理 |
次回はPostgreSQL MVCCを同じ切り口で解説し、InnoDBとの設計思想の違いを比較していきます。