• 說明為何「先寫日誌、再寫資料頁」能同時提供原子性與持久性,並與 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。

  1. 寫頁前:把資料頁 \(P\) 寫回磁碟之前,必須先確保 \(\text{pageLSN}(P) \le \text{flushedLSN}\)——描述這頁修改的所有日誌已落盤。(保證可 undo/可 redo)

  2. 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 的都必須消失。

  1. 若只採用 no-steal/force 策略,還需要日誌嗎?需要 undo 還是 redo?這種策略的效能問題在哪?

  2. 說明「pageLSN \(\le\) flushedLSN 才能寫頁」為何同時保護 undo 與 redo 的正確性。若違反會出現什麼具體錯誤情境?

  3. 一個交易修改了同一頁 1000 次:實體日誌、生理日誌、full-page write 各會產生多少資料?為何 PostgreSQL 只在 checkpoint 後首次修改時寫 FPI?

  4. 解釋為什麼 不會損壞資料庫,而 會。

  5. 系統每秒 20,000 筆小交易、fsync 延遲 0.5 ms。沒有 group commit 時最大吞吐是多少?有 group commit 時瓶頸會移到哪裡?

  6. (動手)執行一批 前後用 與 計算產生的 WAL 量; 然後 後再做一次,比較 中 FPI 的比例。