この記事の要点
• Write-Ahead Log(WAL): データ本体を更新する前にログを先に書き込むことでクラッシュ耐性を実現
• REDO ログで未コミットの変更を復元、UNDO ログで未完了トランザクションを巻き戻し
• PostgreSQL、MySQL、SQLite、Kafka など主要データベース・分散システムで採用
Write-Ahead Log(WAL、先行書き込みログ)は、データベースや分散システムにおいて、クラッシュや障害からデータを確実に復旧するための技術です。データ本体を更新する前に変更内容をログに記録することで、障害発生時にログから状態を再構築できます。本記事では、WAL の原理、実装パターン、最適化手法を体系的に解説します。
概要
Write-Ahead Log とは
WAL は、データベースのトランザクション処理において、データファイルへの書き込み前に、変更内容をログファイルに記録する手法です。これにより、以下を保証します。
- Durability(永続性): コミット完了後はクラッシュしてもデータが失われない
- Atomicity(原子性): トランザクションの途中でクラッシュしても、全体が巻き戻される
sequenceDiagram
participant Client
participant DB
participant WAL
participant DataFile
Client->>DB: BEGIN TRANSACTION
Client->>DB: UPDATE users SET balance=1000 WHERE id=1
DB->>WAL: 1. ログに書き込み<br/>(UPDATE users id=1 balance=1000)
WAL-->>DB: 2. ログ書き込み完了(fsync)
DB->>DataFile: 3. データファイル更新(後回し可)
DataFile-->>DB: 4. 更新完了
Client->>DB: COMMIT
DB->>WAL: 5. COMMIT ログ書き込み
WAL-->>DB: 6. fsync 完了
DB-->>Client: 7. COMMIT 成功
注意: ログへの書き込みは必ず fsync(ディスク同期)が必要です。メモリバッファに留まっている状態でクラッシュすると、ログが失われます。
WAL の基本原則
1. ログ先行書き込み(Log-First Rule)
データファイルを更新する前に、必ずログに記録する。
2. 強制ログ書き込み(Force-Log-at-Commit)
トランザクションのコミット時、ログを物理ディスクに同期(fsync)する。
3. ログからの復元(Recovery from Log)
クラッシュ後、ログを読んで未完了のトランザクションを処理する。
原則・定義
REDO と UNDO
WAL には2種類のログ戦略があります。
REDO ログ(再実行ログ)
ポイント: REDO ログは「何をしたか」を記録し、クラッシュ後に変更を再実行します。コミット済みだがディスクに書き込まれていない変更を復元します。
REDO ログのエントリ例:
[REDO] Transaction 123: UPDATE users SET balance=1000 WHERE id=1 (old=500, new=1000)
UNDO ログ(取り消しログ)
UNDO ログは「何を変更したか(変更前の値)」を記録し、未完了トランザクションを巻き戻します。
UNDO ログのエントリ例:
[UNDO] Transaction 456: UPDATE users SET balance=500 WHERE id=2 (old=300, new=500)
| ログ種類 | 記録内容 | 用途 |
|---|---|---|
| REDO | 変更後の値(new value) | コミット済み変更の再実行 |
| UNDO | 変更前の値(old value) | 未完了変更の巻き戻し |
| REDO+UNDO | 両方(old, new) | 完全な復旧(ARIES 等) |
LSN(Log Sequence Number)
ログエントリに付与される単調増加する番号で、ログの順序を保証します。
LSN を含むログエントリの並び例:
LSN 1000: BEGIN TXN 123
LSN 1001: UPDATE users SET balance=1000 WHERE id=1
LSN 1002: COMMIT TXN 123
チェックポイント(Checkpoint)
ポイント: チェックポイントは、メモリ内の変更を定期的にディスクに書き出し、復旧時にスキャンするログ量を削減します。
チェックポイント以降のログだけを処理すればよいため、復旧時間が短縮されます。
flowchart LR
Start["起動"]
OldLogs["古いログ<br/>(スキャン不要)"]
Checkpoint["チェックポイント"]
NewLogs["新しいログ<br/>(復旧時にスキャン)"]
Crash["クラッシュ"]
Start --> OldLogs --> Checkpoint --> NewLogs --> Crash
構成要素
WAL の実装コンポーネント
| コンポーネント | 説明 |
|---|---|
| ログバッファ | メモリ内のログエントリ一時保存領域 |
| ログファイル | ディスク上のログの永続化先 |
| ログライタ | ログバッファをディスクに書き出すスレッド |
| チェックポインター | 定期的にメモリ内データをディスクに同期 |
| リカバリマネージャ | クラッシュ後のログからの復元を実行 |
ARIES(Algorithms for Recovery and Isolation Exploiting Semantics)
IBM が開発した高度な WAL ベースのリカバリアルゴリズムで、以下のフェーズで復旧します。
- Analysis(分析): ログをスキャンし、どのトランザクションがコミット済み/未完了かを特定
- REDO(再実行): コミット済みの変更を再実行
- UNDO(巻き戻し): 未完了トランザクションを巻き戻し
実装例
1. シンプルな WAL 実装(TypeScript)
import fs from "fs";
import path from "path";
interface LogEntry {
lsn: number;
txnId: number;
type: "BEGIN" | "UPDATE" | "COMMIT" | "ABORT";
table?: string;
key?: string;
oldValue?: any;
newValue?: any;
}
class WriteAheadLog {
private logFilePath: string;
private currentLSN = 0;
private logBuffer: LogEntry[] = [];
private activeTxns = new Set<number>();
constructor(dataDir: string) {
this.logFilePath = path.join(dataDir, "wal.log");
}
// ログエントリを追加
append(entry: Omit<LogEntry, "lsn">): number {
const lsn = ++this.currentLSN;
const logEntry: LogEntry = { lsn, ...entry };
this.logBuffer.push(logEntry);
if (entry.type === "BEGIN") {
this.activeTxns.add(entry.txnId);
} else if (entry.type === "COMMIT" || entry.type === "ABORT") {
this.activeTxns.delete(entry.txnId);
}
return lsn;
}
// ログをディスクに書き込み(fsync)
flush(): void {
if (this.logBuffer.length === 0) return;
const logData = this.logBuffer.map((e) => JSON.stringify(e)).join("\n") + "\n";
fs.appendFileSync(this.logFilePath, logData, { flag: "a" });
// CRITICAL: fsync でディスクに同期
const fd = fs.openSync(this.logFilePath, "r+");
fs.fsyncSync(fd);
fs.closeSync(fd);
this.logBuffer = [];
}
// リカバリ(クラッシュ後の復元)
recover(): Map<string, any> {
const data = new Map<string, any>();
const committedTxns = new Set<number>();
const abortedTxns = new Set<number>();
if (!fs.existsSync(this.logFilePath)) return data;
const logContent = fs.readFileSync(this.logFilePath, "utf-8");
const entries: LogEntry[] = logContent
.split("\n")
.filter((line) => line.trim())
.map((line) => JSON.parse(line));
// Phase 1: Analysis(どのトランザクションがコミット済みか)
for (const entry of entries) {
if (entry.type === "COMMIT") {
committedTxns.add(entry.txnId);
} else if (entry.type === "ABORT") {
abortedTxns.add(entry.txnId);
}
this.currentLSN = Math.max(this.currentLSN, entry.lsn);
}
// Phase 2: REDO(コミット済みトランザクションの再実行)
for (const entry of entries) {
if (entry.type === "UPDATE" && committedTxns.has(entry.txnId)) {
const key = `${entry.table}:${entry.key}`;
data.set(key, entry.newValue);
}
}
console.log(`Recovery completed. Committed: ${committedTxns.size}, Aborted: ${abortedTxns.size}`);
return data;
}
}
// 使用例
const wal = new WriteAheadLog("/tmp/mydb");
// トランザクション開始
wal.append({ txnId: 1, type: "BEGIN" });
// 更新
wal.append({
txnId: 1,
type: "UPDATE",
table: "users",
key: "123",
oldValue: { balance: 500 },
newValue: { balance: 1000 },
});
// コミット前に必ずflush
wal.append({ txnId: 1, type: "COMMIT" });
wal.flush(); // fsync でディスク同期
// クラッシュ後の復旧
const recoveredData = wal.recover();
console.log(recoveredData.get("users:123")); // { balance: 1000 }
2. PostgreSQL の WAL 設定
-- WAL レベルの設定(レプリケーション用)
ALTER SYSTEM SET wal_level = 'replica';
-- WAL バッファサイズ(デフォルト 16MB)
ALTER SYSTEM SET wal_buffers = '16MB';
-- チェックポイントの間隔(最大 1GB のログ)
ALTER SYSTEM SET max_wal_size = '1GB';
-- fsync の強制(本番では必須)
ALTER SYSTEM SET fsync = on;
-- WAL 書き込みのタイミング
ALTER SYSTEM SET synchronous_commit = on;
-- 設定を反映
SELECT pg_reload_conf();
-- WAL の統計を確認
SELECT * FROM pg_stat_wal;
3. SQLite の WAL モード
-- WAL モードを有効化
PRAGMA journal_mode=WAL;
-- チェックポイントの設定(1000 ページごと)
PRAGMA wal_autocheckpoint=1000;
-- WAL ファイルのサイズ確認
PRAGMA wal_checkpoint(FULL);
// Node.js から SQLite WAL を使用
import Database from "better-sqlite3";
const db = new Database("mydb.sqlite");
// WAL モードを有効化
db.pragma("journal_mode = WAL");
// トランザクション
const insertUser = db.prepare("INSERT INTO users (name, balance) VALUES (?, ?)");
db.transaction(() => {
insertUser.run("Alice", 1000);
insertUser.run("Bob", 2000);
})();
// チェックポイント実行
db.pragma("wal_checkpoint(TRUNCATE)");
実践メモ: SQLite の WAL モードは、読み取りと書き込みが並行可能になるため、Web アプリケーションでのパフォーマンスが向上します。
メリット・デメリット
メリット
- 耐障害性: クラッシュしてもデータが失われない
- 高速書き込み: データファイルの更新は後回しでよい(Sequential Write)
- 並行性: データファイルのロックを短時間で済ませられる
- レプリケーション: WAL をストリーミングして他ノードに複製可能
デメリット
- ディスクI/O増: ログとデータファイルの両方に書き込む
- 復旧時間: ログが大きいと復旧に時間がかかる(チェックポイントで緩和)
- ディスク容量: ログファイルが肥大化する可能性
- fsync のオーバーヘッド: 同期書き込みは遅い
ユースケース
1. PostgreSQL(ストリーミングレプリケーション)
PostgreSQL は WAL をリアルタイムでスタンバイサーバーに送信し、レプリケーションを実現します。
# プライマリサーバーの設定
# postgresql.conf
wal_level = replica
max_wal_senders = 5
wal_keep_size = 1GB
# スタンバイサーバーの設定
# standby.signal ファイルを作成
primary_conninfo = 'host=primary port=5432 user=replicator'
2. MySQL(Binary Log / Redo Log)
MySQL は REDO ログ(InnoDB)と Binary Log(レプリケーション用)の2種類のログを持ちます。
-- InnoDB REDO ログのサイズ設定
SET GLOBAL innodb_log_file_size = 512M;
-- Binary Log の有効化
SET GLOBAL log_bin = ON;
-- Binary Log を確認
SHOW BINARY LOGS;
3. Apache Kafka(Commit Log)
Kafka は各パーティションを WAL として実装しており、メッセージを順序保証つきで永続化します。
# Kafka のログ設定(server.properties)
log.dirs=/var/kafka-logs
log.segment.bytes=1073741824 # 1GB ごとにログファイルを分割
log.retention.hours=168 # 7日間保持
4. Redis(AOF: Append-Only File)
Redis は AOF モードで、全ての書き込みコマンドをログに記録します。
# redis.conf
appendonly yes
appendfsync everysec # 毎秒 fsync(デフォルト)
落とし穴
1. fsync の省略
開発環境で fsync=off にすると高速ですが、クラッシュ時にデータが失われます。本番では必ず fsync を有効化しましょう。
2. ログファイルの肥大化
チェックポイントやログローテーションを設定しないと、ログが無限に増えます。
-- PostgreSQL のログローテーション
ALTER SYSTEM SET max_wal_size = '2GB';
ALTER SYSTEM SET min_wal_size = '500MB';
3. 復旧時間の見積もり不足
ログが 10GB ある場合、復旧に数分〜数十分かかる可能性があります。定期的なチェックポイントが重要です。
4. ログとデータの不整合
ログとデータファイルが別ディスクにある場合、片方だけ障害が発生すると不整合が生じます。RAID や冗長化が必要です。
5. トランザクションIDの枯渇
PostgreSQL は32bit のトランザクションID(XID)を使うため、約40億トランザクションで枯渇します。定期的な VACUUM が必須です。
比較表
ログ方式の比較
| DB / System | ログ方式 | fsync タイミング | 用途 |
|---|---|---|---|
| PostgreSQL | REDO (WAL) | commit 時 | トランザクション + レプリケーション |
| MySQL InnoDB | REDO (ib_logfile) | commit 時 | トランザクション |
| MySQL (Binary Log) | REDO | 可変 | レプリケーション |
| SQLite | REDO (WAL) | commit 時 | 組み込みDB |
| Redis | AOF | everysec / always | 永続化 |
| Kafka | Commit Log | OS 依存 | メッセージキュー |
チェックポイント戦略
| DB | デフォルト間隔 | 設定パラメータ |
|---|---|---|
| PostgreSQL | 5分 または 1GB | checkpoint_timeout, max_wal_size |
| MySQL InnoDB | 可変(アダプティブ) | innodb_log_file_size |
| SQLite | 1000 ページ | PRAGMA wal_autocheckpoint |
ベストプラクティス
- fsync を必ず有効化: 本番環境では
fsync=on - ログとデータを分離: 別ディスク/SSDに配置してI/O競合を回避
- 定期的なチェックポイント: 復旧時間を短縮
- ログローテーション: 古いログを定期削除
- モニタリング: ログサイズ、チェックポイント頻度、fsync レイテンシを監視
- レプリケーション: WAL を別ノードにストリーミングして冗長化
- バックアップ: ログだけでなくデータファイルも定期バックアップ
- SSD 推奨: WAL はランダムI/Oが少ないが、fsync が頻繁なので SSD が有利
まとめ
Write-Ahead Log は、データベースの耐障害性を支える基盤技術です。
- 原理: データ更新前にログを先に書き込む
- 保証: Durability(永続性)と Atomicity(原子性)
- REDO/UNDO: コミット済み変更の復元 / 未完了変更の巻き戻し
- チェックポイント: 復旧時間の短縮
- 採用例: PostgreSQL、MySQL、SQLite、Redis、Kafka
WAL なしで耐障害性を実現することは困難です。仕組みを理解し、適切に運用しましょう。
応用トピック
Group Commit(グループコミット)
複数のトランザクションのログを1回の fsync でまとめて書き込むことで、スループットを向上させます。
-- PostgreSQL のグループコミット設定
ALTER SYSTEM SET commit_delay = 10; -- 10マイクロ秒待つ
ALTER SYSTEM SET commit_siblings = 5; -- 5個以上の同時トランザクションで有効
Log-Structured Merge Tree(LSM Tree)
Cassandra や RocksDB が採用する、WAL を基盤としたデータ構造です。全ての書き込みをログに追記し、バックグラウンドでソート済みファイルにマージします。
Physical vs Logical Logging
- Physical Log: ディスクページの変更を記録(PostgreSQL)
- Logical Log: SQL レベルの操作を記録(MySQL Binary Log)
Logical Log はレプリケーションに適し、Physical Log は復旧が高速です。
WAL Compression
PostgreSQL 14 以降、WAL を圧縮して転送量を削減できます。
ALTER SYSTEM SET wal_compression = on;
参考リソース
- PostgreSQL Documentation - WAL Internals
- SQLite Write-Ahead Logging
- ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking
- The Design and Implementation of a Log-Structured File System
- MySQL InnoDB: Redo Log
- Designing Data-Intensive Applications - Chapter 3
関連記事
- データベーストランザクション - ACID 特性と WAL の関係
- データベースレプリケーション - WAL を使ったストリーミングレプリケーション