はじめに
この記事では、MySQL InnoDBのREDO Logの内部実装を深掘りします。
前回のシリーズ(Buffer Pool徹底解説)では、データをメモリにキャッシュする仕組みを解説しました。しかし、メモリ上のデータはプロセスやマシンがクラッシュすると消えてしまいます。そこで登場するのがREDO Logです。MySQL 8.0のソースコードを追いながら、以下を解説します:
- なぜREDO Logが必要なのか(WALの原則)
- 何が記録されるのか(Physiological Logging)
- どう整理されるのか(論理層・物理層・ファイル層の3層構造)
- どう効率的に書き込むのか(Lock-free設計)
- どう安全に削除するのか(Checkpoint)
元ネタは Hanzhi 氏の An In-Depth Analysis of REDO Logs in InnoDB です。この記事を日本語で噛み砕いて書き直しました。
そもそもREDO Logって何?
Buffer Poolの記事で説明したとおり、InnoDBはデータをメモリ(Buffer Pool)にキャッシュして読み書き性能を上げています。でも、メモリへの変更がディスクに反映されるのは後回しです。
ここに問題があります:
- ユーザーが
UPDATEを実行 → メモリ上のページが変更される - まだディスクには書かれていない
- この瞬間にクラッシュしたら? → データが消える
そこでWrite Ahead Log(WAL)という原則が登場します:
ページを変更する前に、変更内容をREDO Logに記録し、REDO Logを先にディスクに書く
たとえるならメモ帳です: – Buffer Pool = ホワイトボード(作業中の内容。電源が落ちたら消える) – REDO Log = メモ帳(ホワイトボードに何を書いたかの記録。ディスクに保存される) – クラッシュ復旧 = メモ帳を見てホワイトボードを復元する
これがARIES(Algorithm for Recovery and Isolation Exploiting Semantics)と呼ばれるリカバリ手法の基本です。ほぼすべてのディスクベースDBがこの方式を採用しています。
どんなREDO Logが必要か?
REDO Logの設計には3つの要件があります:
要件1:データ量は小さく
REDO Logの書き込みはトランザクションのコミット前に完了する必要があります。つまり、REDO Logの書き込み時間がシステムのスループットに直結します。記録するデータはできるだけ小さくしたい。
要件2:リプレイは冪等(べきとう)に
クラッシュ復旧時、どのページがディスクに書き込み済みかわかりません。同じREDOを2回リプレイしても結果が同じになる(=冪等性がある)必要があります。
要件3:ページ単位で
リカバリを高速化するために、1つのREDOレコードは1つのページの変更だけを扱います。こうすれば並列リプレイが可能になります。
Physiological Logging:いいとこ取り
- Logical Logging(論理ログ):データ量が小さい ✅ だが冪等性が難しい ❌
- Physical Logging(物理ログ):冪等性あり ✅ だがデータ量が大きい ❌
InnoDBはPhysiological Loggingという方式を採用しています。「ページ単位(Physical)だが、ページ内は論理的(Logical)に記録する」というハイブリッドです:
(Page ID, Record Offset, (Field 1, Value 1) ... (Field i, Value i) ...)
Page ID:どのページか(Physical)Record Offset:ページ内のどのレコードか(Logical)Field/Value:どのフィールドをどう変えたか(Logical)
問題1:正しいページ状態が前提
Physiological Loggingはページ内を論理的に記録するため、リプレイ時に正しいページ状態が必要です。しかしInnoDBのページサイズは16KBで、ファイルシステムが原子性を保証するのは4KBまで。クラッシュ時にページの半分だけ書かれる可能性があります。
InnoDBはこの問題をDouble Write Bufferで解決します。ページを書く前に、まず別の領域に二重書きしておくことで、クラッシュ時に正しいページ状態を復元できます。
問題2:冪等性の保証
正しいページ状態が復元できても、「このページにどのREDOまで適用済みか」を知る必要があります。InnoDBは各REDOレコードにグローバルで一意な増分ラベル LSN(Log Sequence Number) を付与します。ページが変更されると、そのREDOのLSNがページのFIL_PAGE_LSNフィールドに記録されます。
リプレイ時は:
if (redo_record.lsn <= page.FIL_PAGE_LSN) {
skip; // このREDOは適用済み
} else {
apply(redo_record); // このREDOを適用
}
これで冪等性が保証されます。
何が記録されるのか?
MySQL 8.0時点で65種類のREDOレコードタイプがあります。大きく3カテゴリに分かれます。
カテゴリ1:Page用REDO
全体の大部分を占めます。Index Page、Undo Page、Rtree Pageへの操作です。
例:MLOG_REC_UPDATE_IN_PLACE(レコードの更新)
| Type | Space ID | Page Number | Record Offset | Update Field Count |
|---|---|---|---|---|
| (Field Number, Field Data Length, Field Data) × N |
Type+Space ID+Page Number:全REDOレコード共通のヘッダRecord Offset:ページ内のレコード位置- 以降:変更するフィールドの番号・長さ・データ
他にもMLOG_REC_INSERT(挿入)、MLOG_REC_DELETE(削除)などがあります。
カテゴリ2:Space用REDO
テーブルスペースファイル自体の操作です。
MLOG_FILE_CREATE:ファイル作成MLOG_FILE_DELETE:ファイル削除MLOG_FILE_RENAME:ファイル名変更
これらはファイル操作が完了した後に記録されます。リカバリ時にこのログが見つかれば、ファイル操作は成功済みということです。
カテゴリ3:ロジック型REDO
データ変更ではなく、補助情報を記録します。最も一般的なのはMLOG_MULTI_REC_ENDで、REDOグループの終端を示します。1つのアトミック操作に含まれる複数REDOレコードの「ここまでが1セット」というマーカーです。
REDO Logの3層構造
REDO Logは「論理層 → 物理層 → ファイル層」の3層で整理されています。上から順に見ていきましょう。
第1層:論理REDO層
これが本当のREDOの中身です。さまざまなTypeのREDOレコードが隙間なく連続して並んでいます。各レコードにはグローバルで一意な増分オフセット sn が付きます。
InnoDBはグローバル変数log.snで現在の最大値を管理し、データを書くたびにREDO内容の長さ分だけsnを進めます:
sn: 0 100 250 400
├─REDO A──┼─REDO B──┼─REDO C──┤
snは純粋なデータオフセットで、ヘッダやフッタのオーバーヘッドを含みません。
第2層:物理REDO層(Block)
ディスクはブロックデバイスなので、InnoDBもBlock(512バイト)単位で読み書きします。Blockの構造:
- Block Header (12 bytes)
- ├─ Block Number (4B, 最上位bitはFlush Flag)
- ├─ Data Length (2B, 値の範囲: 12〜508)
- ├─ First Record Offset (2B)
- └─ Checkpoint Number (4B)
- REDO Data (498 bytes)
- Block Tailer (4 bytes) ── Checksum
各フィールドの役割: – Flush Flag:このBlockがI/Oの最初のBlockかどうか – Data Length:このBlock内の有効データ長 – First Record Offset:Block内の最初のREDOグループの開始位置(任意のBlockから有効なREDO開始位置を見つけるため) – Checkpoint Number:書き込み時のnext_checkpoint_number。ファイルの循環利用を検出するために使う – Checksum:Blockが完全に書き込まれたか検証するため
REDOレコードは可変長なので、1つのBlock内に複数のREDOが入ることもあれば、1つのREDOが複数Blockにまたがることもあります:
Block N Block N+1
| Header | REDO X(後半) | | Header | REDO Y | Tailer |
|---|---|---|---|---|---|
| | REDO Y(前半) | | | (後半) | |
| | | | | REDO Z | |
BlockのHeader/Tailerが加わるため、物理空間でのオフセットは論理snとずれます。これが**LSN**です。snとLSNの変換:
```c
constexpr inline lsn_t log_translate_sn_to_lsn(lsn_t sn) {
return (sn / LOG_BLOCK_DATA_SIZE * OS_FILE_LOG_BLOCK_SIZE +
sn % LOG_BLOCK_DATA_SIZE + LOG_BLOCK_HDR_SIZE);
}
つまり、snに「それまでに通過したHeader/Tailerの合計バイト数」を足したものがLSNです。
### 第3層:ファイル層
最終的にREDOは`ib_logfile0`、`ib_logfile1`などのファイルに書かれます。ファイル数は`innodb_log_files_in_group`で指定でき、複数ファイルを**循環的に**使います。
各ファイルの先頭4 Blockは予約領域です:
- **Block 0(Header Block)**:ファイル情報(Logバージョン、開始LSN、MySQLバージョン)
- **Block 1〜2**:Checkpoint情報(`ib_logfile0`のみ。他のファイルでは空)
- **Block 3**:予約
3層をまとめると:
論理REDO(sn) ↓ Blockに詰める(Header/Tailer追加) 物理REDO(LSN) ↓ ファイル空間に配置(循環利用) ファイル(offset)
LSNからファイルオフセットへの変換:
```c
const auto real_offset =
log.current_file_real_offset + (lsn - log.current_file_lsn);
ファイル切り替え時に、そのファイルの先頭オフセット(current_file_real_offset)と対応するLSN(current_file_lsn)がメモリに記録されます。この2つの値から、任意のLSNをファイルオフセットに変換できます。
どう効率的に書き込むのか?
REDO Logはトランザクションのコミット前にディスクに書き終わる必要があります。つまりREDO書き込みはクリティカルパスです。ここが遅いとDB全体の書き込み性能が落ちます。
書き込みプロセスは5つのステージに分かれます。
ステージ1:REDOレコードの生成(MTR)
InnoDBでは、アトミック操作の単位をMTR(Mini-Transaction)と呼びます。Buffer Poolの記事でも登場しましたね。
1つのアトミック操作(例:B+Treeのノード分割)は複数のREDOレコードを生成します。これらは連続して記録される必要があります。
MTRはこれを保証する仕組みです:
mtr_start(&mtr); // MTR開始
// ... ページを変更する操作 ...
// この間に生成されるREDOは、MTR内部のメモリ(m_log)に一時保存
mtr_commit(&mtr); // m_logの内容をLog Bufferにコピー
ポイントは、mtr_commitするまでREDOはLog Bufferに書かれないことです。これにより、1つのアトミック操作のREDOが途中で分断されることを防ぎます。
ステージ2:Log Bufferへの書き込み(Lock-free)
高負荷環境では、大量のMTRが同時にLog Bufferに書き込もうとします。MySQL 8.0以前はmutexロックで直列化していましたが、これがボトルネックでした。
MySQL 8.0ではLock-freeな設計に刷新されました。核心はfetch_addによるアトミックな領域予約です:
/* グローバルオフセットをアトミックに進めて、自分専用の領域を確保 */
const sn_t start_sn = log.sn.fetch_add(len);各MTRは: 1. 自分のREDO長(len)でlog.snをfetch_add → 専用領域を確保 2. 確保した領域に並列でデータをコピー
mutexなしで複数MTRが同時にLog Bufferの異なる位置に書き込めます。
ステージ3:Page Cacheへの書き込み(log_writer)
Log BufferのデータをカーネルのPage Cacheに書く専用スレッドlog_writerがいます。
ただし問題があります。MTRが並列にLog Bufferに書き込むため、途中に穴(hole)ができます:
Log Buffer:
[MTR-A: 完了][ 穴 ][MTR-C: 完了][MTR-D: 書き込み中...]
↑ MTR-Bがまだコピー中
log_writerは連続した部分しか書けません。穴の手前までしかpwriteできない。
この「どこまで連続しているか」を効率的に追跡するために、link_buf(log.recent_written)というデータ構造が導入されました:
link_buf(循環配列):
slot[lsn_a] = len_a ← MTR-Aが「ここからlen_aバイト書いた」と記録
slot[lsn_b] = 0 ← MTR-Bはまだ未完了
slot[lsn_c] = len_c ← MTR-Cが完了
log_writerはlink_bufを先頭から走査し、値が0のスロット(=穴)を見つけたらそこで止まります。その手前までが連続領域(buf_ready_for_write_lsn)です。
主要なLSNポインタの関係:
write_lsn buf_ready_for_write_lsn current_lsn
↓ ↓ ↓
─────┼────────────────────────┼────────────────────────┼───
│← 次にpwriteする範囲 →│← MTRが並列書き込み中 →│
│ (連続確認済み) │ (穴がある可能性) │
ステージ4:ディスクへのフラッシュ(log_flusher)
log_writerがwrite_lsnを進めると、log_flusherスレッドに通知します。log_flusherはfsyncを呼んでPage CacheからディスクにREDOを永続化します。
ステージ5:ユーザースレッドの起床
トランザクションをコミットするユーザースレッドは、自分のREDOがディスクに書かれるまでブロックされています。innodb_flush_log_at_trx_commitの設定で挙動が変わります:
| 値 | 待機条件 | 安全性 |
|---|---|---|
| 1(デフォルト) | fsync完了まで待つ |
最も安全 |
| 2 | Page Cache書き込みまで待つ | 電源断でデータロスの可能性 |
| 0 | 待たない | 最も危険(クラッシュで最大1秒分のデータロス) |
大量のユーザースレッドを効率的に起こすため、InnoDBはlog_write_notifierとlog_flush_notifierという専用スレッドを用意しています。条件変数もLSNのBlock位置でシャーディングされており、無駄なwake upを避けます。
全体の流れ
ユーザースレッド log_writer log_flusher notifier
│ │ │ │
mtr_commit │ │ │
│ │ │ │
fetch_add(log.sn) │ │ │
│ │ │ │
Log Bufferに並列コピー │ │ │
│ │ │ │
link_buf更新 │ │ │
│ link_buf走査 │ │
│ pwrite(連続部分) │ │
│ write_lsn更新 │ │
│ │ fsync実行 │
│ │ flushed_lsn更新 │
│ │ │ wake up
├────────────────────┴──────────────────┴───────────←───┘
コミット完了
どう安全に削除するのか?(Checkpoint)
REDOファイルの容量は有限で、循環的に再利用されます。古いREDOを上書きしても安全なタイミングを決めるのがCheckpointです。
Checkpointの意味
Checkpointとは「ここより前のREDOはもう不要」という位置(LSN)のことです。リカバリ時は最新のCheckpoint以降のREDOだけをリプレイすればOKです。
Checkpoint位置の決め方
REDOが不要になる条件は、「そのREDOに対応するダーティページがすでにディスクにフラッシュ済み」であることです。InnoDBのlog_checkpointerスレッドが定期的にCheckpointを生成します。
Checkpoint位置は2つの値の小さい方です:
/* Buffer Pool内の未フラッシュダーティページの最小LSN */
lsn_t lwm_lsn = buf_pool_get_oldest_modification_lwm();
/* Buffer Poolにダーティページとして登録済みの最大LSN */
const lsn_t dpa_lsn = log_buffer_dirty_pages_added_up_to_lsn(log);
lsn_t checkpoint_lsn = std::min(lwm_lsn, dpa_lsn);なぜdpa_lsnが必要かというと、MTRがREDOを書いた後、対応するページをBuffer Poolのダーティリストに登録するまでにタイムラグがあるからです。まだ登録されていないページのREDOを消してしまうと、クラッシュ時にデータが失われます。
MySQL 8.0では、ダーティページの登録も並列化されています。ステージ3のlog_writerと同様に、recent_closedというlink_bufで穴を追跡し、専用スレッドlog_closerが連続登録済みの最大LSN(= dpa_lsn)を進めます。
並列化による順序の乱れがあるため、lwm_lsnはrecent_closedの容量分(最大の乱れ幅)を差し引いて安全側に倒します:
const lsn_t lsn = buf_pool_get_oldest_modification_approx();
const lsn_t lag = log.recent_closed.capacity();
lsn_t lwm_lsn = lsn - lag;Checkpoint Blockの構造
Checkpointはib_logfile0の先頭にある2つのCheckpoint Block(Block 1とBlock 2)に交互に書かれます。交互に使うのは、書き込み中のクラッシュでも必ず1つは有効なCheckpointが残るようにするためです。
| Checkpoint Number | 8B ── どちらが新しいか判定用 |
|---|---|
| Checkpoint LSN | 8B ── リカバリ開始位置 |
| Checkpoint Offset | 8B ── LSNに対応するファイルオフセット |
| Log Buffer Size | 8B ── 現在は未使用 |
リカバリ時は、2つのCheckpoint BlockのCheckpoint Numberを比較して新しい方を採用し、そのCheckpoint LSNからREDOリプレイを開始します。
まとめ
| トピック | ポイント |
|---|---|
| なぜ必要か | メモリ上の変更をクラッシュから守るWALの原則 |
| 記録方式 | Physiological Logging(ページ単位+ページ内論理) |
| 冪等性 | LSNでページとREDOの対応を管理 |
| 3層構造 | 論理層(sn)→ 物理層(LSN, Block)→ ファイル層(offset) |
| 書き込み | Lock-free設計(fetch_add + link_buf)で高並列化 |
| Checkpoint | lwm_lsnとdpa_lsnの小さい方。古いREDOを安全に再利用 |
次回はPostgreSQL WALを同じ切り口で解説し、InnoDBとの設計思想の違いを比較していきます。