はじめに
この記事では、PostgreSQLのWAL(Write-Ahead Log)の内部実装を深掘りします。
前回の記事(InnoDB REDO Log の内部構造を徹底解説)ではMySQLのREDO Logを解説しました。今回はPostgreSQL 17のソースコード(src/backend/access/transam/xlog*.c)を読みながら、同じテーマをPostgreSQLの視点で解説します。InnoDBとの違いも随所で比較していきます。
この記事で扱う内容:
- WALの役割とFull Page Write(InnoDBのDouble Write Bufferとの違い)
- WALレコードの構造(Resource Manager方式)
- WALの物理構造(ページ・セグメント・ファイル)
- 書き込みパイプライン(WAL Buffer → ディスク)
- Checkpoint(InnoDBとの設計思想の違い)
そもそもWALって何?
InnoDBのREDO Logと同じ役割です。PostgreSQLもメモリ(Shared Buffers)上でデータを変更し、ディスクへの反映は後回しにします。クラッシュ時にデータを失わないために、変更内容を先にログとしてディスクに書く。これがWAL(Write-Ahead Log)です。
InnoDBでは「REDO Log」と呼びますが、PostgreSQLでは「WAL」と呼びます。概念は同じです。
PostgreSQL用語 InnoDB用語
─────────────────────────────────
WAL REDO Log
LSN LSN
WAL Buffer Log Buffer
Checkpointer log_checkpointer
Full Page Write Double Write Buffer(目的は同じ、手段が違う)
Partial Write問題とFull Page Write
InnoDBの記事で「Physiological Loggingはページの正しい状態が前提」と説明しました。PostgreSQLも同じ問題を抱えています。PostgreSQLのページサイズは8KB、ファイルシステムの原子書き込みは通常4KB。クラッシュ時にページの半分だけ書かれる(Partial Write / Torn Page)可能性があります。
InnoDBはDouble Write Bufferでこれを解決しました。ページを書く前に別領域に二重書きしておき、クラッシュ時に正しいページを復元します。
PostgreSQLは別のアプローチを取ります:Full Page Write(FPW)です。
Full Page Writeの仕組み
Checkpoint後に初めてページを変更するとき、WALレコードにページ全体のコピー(8KB)を含めます:
Checkpoint
↓
Page Xを初めて変更 → WALに「Page X全体(8KB)+ 変更内容」を記録
Page Xを2回目に変更 → WALに「変更内容だけ」を記録(FPWなし)
Page Xを3回目に変更 → WALに「変更内容だけ」を記録(FPWなし)
↓
次のCheckpoint
↓
Page Xを初めて変更 → またFPW付きで記録
リカバリ時にPartial Writeが検出されたら、FPW付きのWALレコードからページ全体を復元し、そこから後続のWALをリプレイします。
InnoDBとの比較
| InnoDB | PostgreSQL | |
|---|---|---|
| 手段 | Double Write Buffer | Full Page Write |
| 追加I/O | ページ書き込み時に二重書き | Checkpoint直後のWALが肥大化 |
| WALサイズ | 影響なし | FPWでWALが大きくなる |
| 設定 | innodb_doublewrite |
full_page_writes(デフォルトON) |
PostgreSQLのFPWはWALサイズを増大させるデメリットがありますが、実装がシンプルで、WALだけで完結する(別の領域が不要)というメリットがあります。wal_compressionを有効にすればFPW部分を圧縮してサイズを軽減できます。
WALレコードの構造
InnoDBでは65種類のREDOタイプがありました。PostgreSQLはResource Manager(RM)という仕組みでWALレコードを管理します。
Resource Managerとは
PostgreSQLの各サブシステム(Heap、B-tree、Transaction、Sequenceなど)がそれぞれResource Managerとして登録されています。WALレコードの生成とリプレイは各RMが担当します。
/* src/include/access/rmgrlist.h より抜粋 */
PG_RMGR(RM_XLOG_ID, "XLOG", ...) // WAL制御用
PG_RMGR(RM_XACT_ID, "Transaction", ...) // トランザクション
PG_RMGR(RM_HEAP_ID, "Heap", ...) // テーブル操作
PG_RMGR(RM_BTREE_ID, "Btree", ...) // B-treeインデックス
PG_RMGR(RM_HASH_ID, "Hash", ...) // Hashインデックス
PG_RMGR(RM_GIN_ID, "Gin", ...) // GINインデックス
PG_RMGR(RM_GIST_ID, "Gist", ...) // GiSTインデックス
// ... 他にも多数InnoDBが1つの巨大なswitch文で全REDOタイプを処理するのに対し、PostgreSQLは各RMに処理を委譲するプラグイン的な設計です。カスタムRMの追加も可能です。
WALレコードのヘッダ
各WALレコードには共通ヘッダXLogRecordが付きます:
typedef struct XLogRecord {
uint32 xl_tot_len; // レコード全体の長さ
TransactionId xl_xid; // トランザクションID
XLogRecPtr xl_prev; // 前のWALレコードのLSN
uint8 xl_info; // RMごとのフラグ(操作種別)
RmgrId xl_rmid; // Resource Manager ID
pg_crc32c xl_crc; // CRC
} XLogRecord;InnoDBとの違い: – xl_prev:前のレコードへのポインタ。InnoDBにはない。WALを逆方向にたどれる – xl_xid:トランザクションIDが入る。InnoDBのREDOにはトランザクション情報がない(UNDOログ側で管理) – xl_rmid + xl_info:RMの種類と操作種別の組み合わせで、何が起きたかを表現
WALレコードのボディ
ヘッダの後に、Block Reference(どのページを変更したか)とデータ本体が続きます:
- XLogRecord 共通ヘッダ(24バイト)
- Block Ref 0 対象ページ情報(RelFileNode + BlockNumber)
- FPW data ← Full Page Writeの場合、ページ全体がここに入る
- Block Ref 1 2つ目のページ(ある場合)
- Main Data RM固有のデータ
1つのWALレコードが複数ページを参照できるのがInnoDBとの大きな違いです。InnoDBでは1 REDOレコード = 1ページでしたが、PostgreSQLではB-treeのノード分割のように複数ページにまたがる操作を1つのWALレコードで表現できます。
pg_waldumpで実際に見てみる
pg_waldumpコマンドでWALの中身を確認できます:
$ pg_waldump /path/to/pg_wal/000000010000000000000001
rmgr: Heap len (rec/tot): 54/ 150, tx: 737, lsn: 0/01234568, prev 0/01234520, desc: INSERT off: 2, blkref #0: rel 1663/13067/16384 blk 0 FPW
rmgr: Btree len (rec/tot): 64/ 64, tx: 737, lsn: 0/012345F8, prev 0/01234568, desc: INSERT_LEAF off: 2, blkref #0: rel 1663/13067/16387 blk 1
rmgr: Transaction len (rec/tot): 46/ 46, tx: 737, lsn: 0/01234638, prev 0/012345F8, desc: COMMIT 2026-04-02 14:00:00 BSTこの例では: 1. Heapテーブルにレコード挿入(FPW付き = Checkpoint後の初回変更) 2. B-treeインデックスにエントリ挿入 3. トランザクションコミット
WALの物理構造
InnoDBは「論理層→物理層(Block 512B)→ファイル層」の3層でした。PostgreSQLも似た構造ですが、単位が異なります。
ページ(8KB)
PostgreSQLのWALはディスク上で8KBページ単位に分割されます(InnoDBの512B Blockに相当)。各ページにはヘッダXLogPageHeaderDataが付きます:
typedef struct XLogPageHeaderData {
uint16 xlp_magic; // マジックナンバー
uint16 xlp_info; // フラグ(ロングヘッダかどうか等)
TimeLineID xlp_tli; // タイムラインID(PITRやレプリケーション用)
XLogRecPtr xlp_pageaddr; // このページの先頭LSN
uint32 xlp_rem_len; // 前ページから続くレコードの残りバイト数
} XLogPageHeaderData;InnoDBのBlock Headerとの違い: – xlp_tli(タイムラインID):PostgreSQLのPITR(Point-in-Time Recovery)やレプリケーションで使う概念。InnoDBにはない – xlp_rem_len:前ページからまたがるレコードの残りバイト数。InnoDBのFirst Record Offsetと似た役割
WALレコードがページ境界をまたぐ場合、InnoDBと同様に分割されます:
Page N Page N+1
| Header | Record A | | Header | Record B(後半) |
|---|---|---|---|---|
| | Record B(前半) | | (rem_len) | Record C |
セグメントファイル(デフォルト16MB)
WALページはセグメントファイルにまとめられます。デフォルトで1ファイル16MB(--wal-segsizeで変更可能)。
ファイル名は24桁の16進数です:
pg_wal/
├── 000000010000000000000001 ← タイムライン1、セグメント1
├── 000000010000000000000002 ← タイムライン1、セグメント2
└── ...
ファイル名: TTTTTTTTSSSSSSSSSSSSSSSS
^^^^^^^^ タイムラインID(8桁)
^^^^^^^^^^^^^^^^ セグメント番号(16桁)
InnoDBのib_logfile0/1が固定数のファイルを循環利用するのに対し、PostgreSQLは新しいセグメントファイルを次々作成し、不要になったら削除(またはリサイクル)します。
LSNとファイル位置の対応
PostgreSQLのLSNは64ビットの値で、ファイル内の物理位置に直接対応します:
/* LSNからセグメントファイル番号を求める */
segment = lsn / wal_segment_size;
/* LSNからファイル内オフセットを求める */
offset = lsn % wal_segment_size;InnoDBのLSNはBlock Header/Tailerのオーバーヘッドを含むため、snとの変換が必要でした。PostgreSQLのLSNはページヘッダ分を含んだ値なので、変換はよりシンプルです。
書き込みパイプライン
InnoDBはMySQL 8.0で専用スレッド(log_writer、log_flusher)によるLock-free設計を導入しました。PostgreSQLのアプローチは異なります。
WAL Buffer
WAL Bufferは共有メモリ上の循環バッファです。サイズはwal_buffersで設定します(デフォルトはshared_buffersの1/32、最大64KB〜最小32KB)。
書き込みの流れ
PostgreSQLでは、WALの書き込みは各バックエンドプロセス自身が行います(InnoDBのような専用スレッドではない):
1. WAL Bufferへの挿入
├─ WALInsertLockを取得(軽量ロック、複数スロット)
├─ 予約位置を確保
├─ WALレコードをバッファにコピー
└─ ロック解放
2. ディスクへの書き込み(必要な場合)
├─ WALWriteLockを取得(1つだけ)
├─ write() でPage Cacheに書く
└─ ロック解放
3. fsync(コミット時)
├─ issue_xlog_fsync() を呼ぶ
└─ 他のバックエンドのWALもまとめてフラッシュされる(group commit効果)
InnoDBとの設計思想の違い
| InnoDB (8.0) | PostgreSQL | |
|---|---|---|
| 書き込み主体 | 専用スレッド(log_writer) | 各バックエンド自身 |
| 並列挿入 | fetch_add + link_buf(Lock-free) | WALInsertLock(複数スロットのLWLock) |
| fsync | 専用スレッド(log_flusher) | コミットするバックエンド自身 |
| Group Commit | log_flush_notifierで起床 | fsync中に他のバックエンドが待機→まとめてフラッシュ |
PostgreSQLのWALInsertLockはnum_xloginsert_locks個(デフォルト8)のスロットに分かれており、異なるスロットを取得したバックエンドは並列にWAL Bufferに書き込めます。InnoDBのLock-freeほど洗練されていませんが、実用上は十分な並列性を確保しています。
synchronous_commit
InnoDBのinnodb_flush_log_at_trx_commitに相当するのがsynchronous_commitです:
| 値 | 挙動 | InnoDBの対応 |
|---|---|---|
on(デフォルト) |
fsyncまで待つ | = 1 |
off |
待たない(最大600msのデータロス) | = 0 |
remote_write |
レプリカのwrite()まで待つ | ― |
remote_apply |
レプリカの適用まで待つ | ― |
PostgreSQLはレプリケーションとの統合が深く、remote_writeやremote_applyのようなレプリカ側の永続化レベルも選べます。
Checkpoint
InnoDBと同じく、PostgreSQLもCheckpointで「ここより前のWALは不要」という位置を記録します。ただし仕組みが異なります。
Checkpointの流れ
PostgreSQLのCheckpointerプロセスが定期的に実行します:
1. Checkpointの開始をWALに記録(XLOG_CHECKPOINT_ONLINE)
2. 全ダーティページをディスクにフラッシュ
3. Checkpoint完了をpg_control(制御ファイル)に記録
4. 古いWALセグメントを削除/リサイクル
InnoDBとの違い
InnoDBのCheckpointは「現在のダーティページの最小LSNを記録する」だけで、ダーティページのフラッシュは別の仕組み(Page Cleaner)が非同期に行います。つまりInnoDBのCheckpointは軽い操作です。
一方、PostgreSQLのCheckpointは全ダーティページをフラッシュする重い操作です。そのためcheckpoint_completion_target(デフォルト0.9)で、次のCheckpointまでの時間の90%をかけてゆっくりフラッシュする仕組みがあります。
| InnoDB | PostgreSQL | |
|---|---|---|
| Checkpoint時の動作 | LSNを記録するだけ | 全ダーティページをフラッシュ |
| ダーティページのフラッシュ | Page Cleanerが常時非同期で実行 | Checkpointer + BGWriter |
| Checkpoint間隔 | 自動(REDOの消費量に応じて) | checkpoint_timeout(デフォルト5分) |
| 記録先 | ib_logfile0のCheckpoint Block |
pg_controlファイル |
pg_controlの確認
pg_controldataコマンドでCheckpoint情報を確認できます:
$ pg_controldata /path/to/data
Latest checkpoint location: 0/1234568
Latest checkpoint's REDO location: 0/1234520
Latest checkpoint's TimeLineID: 1まとめ
| トピック | InnoDB | PostgreSQL |
|---|---|---|
| 名称 | REDO Log | WAL |
| Partial Write対策 | Double Write Buffer | Full Page Write |
| レコード管理 | 65種類のType | Resource Manager方式 |
| 1レコードの対象 | 1ページのみ | 複数ページ可 |
| 物理単位 | Block(512B) | ページ(8KB) |
| ファイル管理 | 固定数ファイルを循環利用 | セグメントファイルを作成/削除 |
| 書き込み方式 | 専用スレッド + Lock-free | 各バックエンド + LWLock |
| Checkpoint | 軽い(LSN記録のみ) | 重い(全ダーティページフラッシュ) |
設計思想の違いが面白いですね。InnoDBはMySQL 8.0で専用スレッドとLock-freeデータ構造による高度な並列化を追求しています。PostgreSQLはよりシンプルな設計で、レプリケーションやPITRとの統合を重視しています。
次回はOracle REDO Logを同じ切り口で解説します。