說明為何「先寫日誌、再寫資料頁」能同時提供原子性與持久性,並與 steal/no-force 緩衝策略連結。
陳述 WAL 的兩條規則與 LSN、pageLSN、flushedLSN 之間的不變式。
比較實體、邏輯、生理(physiological)三種日誌記錄,知道 PostgreSQL 選了哪一種、為什麼。
解釋 full-page write 如何解決 torn page,以及 checkpoint 如何限制復原時間並讓日誌可回收。
讀懂 PostgreSQL 的 LSN 格式、 目錄與 輸出,並正確設定 等參數。
為什麼需要日誌
第 4 章提到緩衝池的 steal(未 commit 的髒頁可先寫回)與 no-force(commit 時不必寫回資料頁)策略能把隨機寫入延後、合併, 但也帶來兩個問題:
steal \(\Rightarrow\) 當機時磁碟上可能有未 commit 交易的修改,需要 undo。
no-force \(\Rightarrow\) 當機時已 commit 交易的修改可能還沒落盤,需要 redo。
解法是把「發生了什麼修改」循序追加到一個獨立的日誌檔並 fsync——循序寫比隨機寫快一到兩個數量級, 而且一次 fsync 就能讓一批交易持久。資料頁本身可以晚點、合併後再寫。這就是 。
WAL 協定
每筆日誌記錄有一個單調遞增的 (本質上是它在日誌串流中的位元組位移)。 每個資料頁的標頭記錄 pageLSN:最後一次修改這頁的日誌記錄 LSN。系統維護 flushedLSN:已持久化到磁碟的日誌最大 LSN。
寫頁前:把資料頁 \(P\) 寫回磁碟之前,必須先確保 \(\text{pageLSN}(P) \le \text{flushedLSN}\)——描述這頁修改的所有日誌已落盤。(保證可 undo/可 redo)
Commit 前:回報交易成功之前,該交易的所有日誌記錄(包含 commit 記錄)必須已落盤。(保證持久性)
日誌記錄的內容
lP5.2cmP5.8cm 類型 & 記錄什麼 & 取捨
實體(physical) & 頁面某位移前後的位元組 & 重做簡單、冪等;但記錄大,且無法描述跨頁操作
邏輯(logical) & 高層操作(「對 插入這筆 tuple」) & 記錄小;但重做時結果必須完全一致(不確定性、並行交錯難以重現),復原慢
生理(physiological) & 以頁為單位、頁內以邏輯方式描述(「在 page 7 的 slot 3 插入 tuple」) & 兩者折衷,主流選擇(ARIES、PostgreSQL、InnoDB)
典型記錄欄位:LSN、交易 ID、prevLSN(同一交易前一筆記錄,串成該交易的回溯鏈)、記錄類型、受影響的頁與位移、 修改前映像(before image,供 undo)、修改後映像(after image,供 redo)。 PostgreSQL 因為 MVCC 不需要 undo 日誌,只記 redo 資訊。
每筆記錄以 標頭開頭( 總長、、、、、), 後接零到多個 block reference(受影響的頁,可含 full-page image)與資源管理器專屬資料。 資源管理器(rmgr)依子系統劃分:()、()、 、()、()、、…… 每個 rmgr 提供 函數,復原時依 分派。
Full-Page Write 與 torn page
第 2 章提到:8 KB 頁寫到只保證 4 KB 原子性的磁碟上,斷電可能造成半新半舊的頁。 生理日誌無法對一個損壞的頁做 redo(頁內結構已不可信)。PostgreSQL 的解法是 full-page write(FPW): 每個 checkpoint 之後,第一次修改某頁時把整頁映像寫進 WAL;復原時直接用該映像覆蓋,再套用後續的增量記錄。 代價是 checkpoint 剛結束時 WAL 量暴增(可用 壓縮 FPI)。 InnoDB 則用 doublewrite buffer:先把頁寫到一塊連續區域、再寫到真正位置,兩者至少有一份完整。
Checkpoint
日誌不能無限成長,復原也不能從創世紀開始重播。Checkpoint 把緩衝池的髒頁寫回磁碟,並記下一個 redo point: 復原只需從此點開始。簡單的做法是暫停所有交易、寫光髒頁——不可接受;實務上用 fuzzy checkpoint: 記下開始時的 LSN,在正常服務的同時慢慢把當時的髒頁寫出,完成後才把 redo point 寫入控制檔(第 11 章 ARIES 會精確處理其正確性)。
觸發:(預設 5 min)或 WAL 量達到 (預設 1 GB);也可手動 。
(預設 0.9):checkpointer 把寫出攤平在 90% 的間隔內,避免 I/O 尖峰。
redo point 與最新 checkpoint 位置寫入 ( 可查)。
日誌出現 表示 太小——每次 checkpoint 都重置 FPW,太頻繁反而放大 WAL 量。 生產環境常設 數 GB、 15–30 min,以復原時間換取平穩的 I/O。
Commit 的成本:fsync 與 group commit
一次 在 NVMe 上約 20–100 µs、在雲端網路磁碟上可能數毫秒——若每筆交易都 fsync,吞吐上限就是 1/fsync 延遲。 Group commit:多個同時 commit 的交易共用一次 fsync。PostgreSQL 的做法是:commit 的 backend 若發現有人正在 flush,就等待那次 flush 完成 (); 可刻意多等一點以湊更多交易。另有背景的 walwriter 定期(,200 ms)寫出 WAL 緩衝區。
lP9.8cm & 回報成功的時機
& WAL 進入記憶體緩衝即回報;當機最多遺失約 \(3 \times\) 的交易,但不會損壞資料(與 不同)。
& 本機 WAL fsync 完成。
& 加上:同步備援已收到並寫入 OS(未 fsync)。
(預設) & 加上:同步備援已 fsync。
& 加上:同步備援已重播,讀備援也看得到。
會讓 checkpoint 也不等資料落盤,當機後資料庫可能損壞且無法啟動——只能用於可隨時重建的測試環境。
在 Linux 預設 ;某些檔案系統/虛擬磁碟的 fsync 行為不可靠(「fsyncgate」事件:PostgreSQL 12 之前曾在 fsync 回報錯誤後重試,但 Linux 已清除髒頁狀態,導致靜默遺失;現在遇到 fsync 失敗會直接 PANIC)。
消費級 SSD 可能在回報寫入完成後資料仍在揮發性快取;有電容保護(PLP)的企業級硬碟才能安全啟用寫入快取。
PostgreSQL 的 WAL 實體佈局
WAL 位於 ,切成 16 MB 的段檔(segment)( 可改),每段內再分 8 KB 頁。
檔名 由三個 8 位十六進位數組成:timeline(PITR/failover 後遞增)、log id、segment no。
LSN 以 的十六進位表示,如 ; 取得目前位置、 對應段檔、 計算位元組差(監控複寫延遲的基礎)。
:(只夠當機復原)、(預設;支援串流複寫與 PITR)、(加入邏輯解碼所需資訊)。
封存(archiving): 加 (或 )把寫滿的段檔複製到外部儲存, 搭配基礎備份即可做 Point-in-Time Recovery。
段檔會被回收重用(rename 為未來的檔名)而非刪除,介於 與 之間。
$ pg_waldump -p $PGDATA/pg_wal -s 0/16B3748 -n 4
rmgr: Heap len (rec/tot): 54/ 218, tx: 748, lsn: 0/016B3748, prev 0/016B3710,
desc: HOT_UPDATE old_xmax: 748, old_off: 2, old_infobits: [], flags: 0x20, new_xmax: 0, new_off: 3,
blkref #0: rel 1663/16384/16385 blk 0 FPW
rmgr: Transaction len (rec/tot): 34/ 34, tx: 748, lsn: 0/016B3828, prev 0/016B3748,
desc: COMMIT 2026-10-08 10:12:31.448521 CST
第一筆是 heap 的 HOT update(帶 full-page image,因為是 checkpoint 後首次修改該頁),第二筆是 commit 記錄; 串起同一交易的記錄。可加 看各 rmgr 的 WAL 量分佈,是診斷「為什麼 WAL 這麼多」的第一步。
其他系統的日誌設計
InnoDB:redo log 是固定大小的環狀檔(),寫滿前必須完成 checkpoint; undo 資訊存在 undo tablespace(供 MVCC 與回滾,第 9 章); 對應 。
SQLite WAL 模式:修改的整頁追加到 檔, 檔是頁號到 WAL 位置的索引;checkpoint 把頁複製回主檔。 讀者讀 WAL 中的最新版本,寫者不阻塞讀者——一種以頁為粒度的 MVCC。
RocksDB:每個 memtable 對應一個 WAL,memtable flush 成 SSTable 後 WAL 即可刪除(第 12 章)。
WAL 的其他用途
WAL 是資料庫中「所有變更的全序」,因此順理成章地成為:實體複寫的傳輸內容(第 14 章 streaming replication)、 PITR 的素材、邏輯解碼(logical decoding)與 CDC(Debezium 把 WAL 轉成 Kafka 事件)的來源。 理解 WAL 等於同時理解了復原、複寫與變更擷取。
class LogManager {
public:
// 追加記錄到記憶體緩衝區,回傳其 LSN;不保證落盤
lsn_t Append(const LogRecord &rec);
// 阻塞直到 flushed_lsn_ >= lsn(commit 與寫髒頁前呼叫)
void Flush(lsn_t lsn);
lsn_t FlushedLsn() const { return flushed_lsn_.load(); }
private:
void FlusherThread(); // 背景執行緒:定期或被喚醒時 write + fsync,實作 group commit
std::vector<char> buffer_; std::mutex mu_; std::condition_variable cv_;
std::atomic<lsn_t> next_lsn_{0}, flushed_lsn_{0};
};
v4 的記錄類型:、、、、、(含 before/after image)、、。 先呼叫 以遵守規則 1; 寫 COMMIT 後 以遵守規則 2。 加入「寫到一半 kill -9」的測試:重啟後所有已回報成功的交易都必須存在,未 commit 的都必須消失。
若只採用 no-steal/force 策略,還需要日誌嗎?需要 undo 還是 redo?這種策略的效能問題在哪?
說明「pageLSN \(\le\) flushedLSN 才能寫頁」為何同時保護 undo 與 redo 的正確性。若違反會出現什麼具體錯誤情境?
一個交易修改了同一頁 1000 次:實體日誌、生理日誌、full-page write 各會產生多少資料?為何 PostgreSQL 只在 checkpoint 後首次修改時寫 FPI?
解釋為什麼 不會損壞資料庫,而 會。
系統每秒 20,000 筆小交易、fsync 延遲 0.5 ms。沒有 group commit 時最大吞吐是多少?有 group commit 時瓶頸會移到哪裡?
(動手)執行一批 前後用 與 計算產生的 WAL 量; 然後 後再做一次,比較 中 FPI 的比例。
PostgreSQL 文件 Ch. 28 Reliability and the Write-Ahead Log(含 WAL 設定、非同步 commit、WAL 內部): https://www.postgresql.org/docs/current/wal.html
PostgreSQL 文件 pg_waldump:https://www.postgresql.org/docs/current/pgwaldump.html; §20.5 Write Ahead Log 參數。
Hironobu Suzuki, The Internals of PostgreSQL, Ch. 9 Write Ahead Logging:https://www.interdb.jp/pg/pgsql09.html
PostgreSQL 原始碼 (交易、WAL、復原的設計筆記)、、、 。
CMU 15-445 Fall 2025 Lecture #19 Logging Schemes。
SQLite, Write-Ahead Logging:https://www.sqlite.org/wal.html
MySQL 文件 InnoDB Redo Log 與 Doublewrite Buffer。
T. Munro, fsync() errors 討論與 PostgreSQL 12 的 PANIC 行為:https://wiki.postgresql.org/wiki/Fsync_Errors