日誌記錄#

發生故障時(斷電、OS 錯誤、資料庫伺服器當機),RAM 中的所有內容都會遺失,只有已寫入磁碟的資料能存活。故障後要啟動伺服器,就必須還原資料一致性;若磁碟本身損毀,同樣的問題得靠備份還原來解決。

理論上你可以讓磁碟上的資料隨時保持一致。但實務上這意味著伺服器必須不斷把隨機頁面寫到磁碟(儘管循序寫入便宜得多),而且這些寫入的順序必須保證任何時刻一致性都不受損——這很難做到,尤其在處理複雜索引結構時。

與多數資料庫系統一樣,PostgreSQL 採取不同做法:

  • 伺服器運行時,部分當前資料只存在於 RAM,寫入永久儲存被延後
  • 因此運行期間磁碟上的資料總是不一致的,因為頁面從不會被一次全部刷出
  • 但 RAM 中發生的每一項變更(例如在緩衝快取中執行的頁面更新)都會被記錄:PostgreSQL 建立一筆日誌條目,包含日後重做該操作所需的全部關鍵資訊

與頁面修改相關的日誌條目,必須在被修改的頁面本身之前寫入磁碟——日誌的名稱即由此而來:預寫式日誌(write-ahead log,WAL)。

這項要求保證了:故障發生時 PostgreSQL 能從磁碟讀取 WAL 條目並重播它們,重做那些「已完成但結果仍在 RAM、當機前未落盤」的操作。

維護預寫式日誌通常比把隨機頁面寫到磁碟更有效率:

  • WAL 條目構成連續的資料流,連 HDD 都能勝任
  • WAL 條目通常比頁面小

哪些操作被記錄#

必須記錄所有在故障時可能破壞資料一致性的操作:

  • 緩衝快取中執行的頁面修改——因為寫入被延後
  • 交易提交與回滾——因為狀態變更發生在 CLOG 緩衝區中,不會立刻落盤
  • 檔案操作(表格新增或移除時的檔案與目錄建立、刪除)——因為這類操作必須與資料變更同步

不被記錄的操作:

  • UNLOGGED 表格的操作
  • 對暫存表格的操作——它們的生命週期無論如何都受限於產生它們的 session

PostgreSQL 10 之前,雜湊索引也不被記錄。它們當時的唯一用途是把雜湊函式對應到不同資料型別。

除了當機復原,WAL 也用於從備份進行時間點還原(PITR)與複寫(replication)。

WAL 的結構#

邏輯結構#

就邏輯結構而言,WAL 是一串長度可變的日誌條目。每筆條目由標準標頭加上特定操作的資料構成,標頭提供的資訊包括:

  • 與該條目相關的交易 ID
  • 負責解讀該條目的資源管理器(resource manager)
  • 用來偵測資料損毀的校驗和
  • 條目長度
  • 指向前一筆 WAL 條目的參照

WAL 通常朝方向讀取,但 pg_rewind 之類的工具可能會反向掃描——這正是「指向前一筆條目」的參照存在的理由。

WAL 資料本身可能有不同格式與意義。例如它可以是一段頁面片段,用來取代頁面中指定偏移量處的某部分。對應的資源管理器必須知道如何解讀與重播特定條目;表格、各類索引、交易狀態與其他實體各有獨立的管理器。

WAL 快取#

WAL 檔案在伺服器共享記憶體中佔用專屬緩衝區,其大小由 wal_buffers 參數定義。預設值 -1 表示自動選擇為緩衝快取總大小的 1/32

WAL 快取與緩衝快取相當類似,但它通常以環形緩衝模式運作:新條目加到頭部,較舊的條目從尾部開始存到磁碟。

若 WAL 快取太小,磁碟同步的執行頻率會高於必要

低負載下,插入位置(緩衝區頭部)幾乎總是與已存到磁碟的條目位置(尾部)相同:

=> SELECT pg_current_wal_lsn(), pg_current_wal_insert_lsn();
 pg_current_wal_lsn | pg_current_wal_insert_lsn
−−−−−−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−−−−−−−−−−−−−−
 0/3DF56000         | 0/3DF57968
(1 row)

LSN#

要指涉特定條目,PostgreSQL 使用特殊資料型別 pg_lsn(log sequence number,日誌序號)。它表示從 WAL 起點到該條目的 64 位元位元組偏移量,顯示為兩個以斜線分隔的 32 位元十六進位數字。

實驗:更新一列後,插入 LSN 前進了:

=> BEGIN;
=> SELECT pg_current_wal_insert_lsn();   -- 0/3DF708D8
=> UPDATE wal SET id = id + 1;
=> SELECT pg_current_wal_insert_lsn();   -- 0/3DF70920

頁面修改在 RAM 的緩衝快取中執行,這項變更也記錄在同樣位於 RAM 的 WAL 頁面中。

為確保被修改的資料頁面嚴格在對應 WAL 條目之後才刷到磁碟,頁首存放了與該頁面相關的最新 WAL 條目的 LSN

=> SELECT lsn FROM page_header(get_raw_page('wal',0));
    lsn
−−−−−−−−−−−−
 0/3DF70920
(1 row)

整個資料庫叢集只有一個 WAL,新條目不斷被附加其後。因此頁面中存放的 LSN 可能比稍早 pg_current_wal_insert_lsn 回傳的值還小;但若系統中什麼都沒發生,這兩個數字會相同。

CLOG 頁面的處理#

提交操作同樣被記錄,插入 LSN 再次改變。提交會更新 CLOG 頁面中的交易狀態,這些頁面存放在自己的快取中(共享記憶體中通常佔 128 個頁面)。

為確保 CLOG 頁面不會在對應 WAL 條目之前落盤,CLOG 頁面也必須追蹤最新 WAL 條目的 LSN——但這項資訊存放在 RAM 中,而非頁面本身

到了某個時點 WAL 條目會落盤,屆時 CLOG 與資料頁面才可能被淘汰出快取。若它們必須更早被淘汰,系統會發現這一點,並強制先把 WAL 條目寫到磁碟

計算 WAL 條目的大小#

若知道兩個 LSN 位置,只要相減(並轉型為 pg_lsn)就能算出兩者之間 WAL 條目的位元組數:

=> SELECT '0/3DF70948'::pg_lsn - '0/3DF708D8'::pg_lsn;
 ?column?
−−−−−−−−−−
      112
(1 row)

本例中 UPDATECOMMIT 相關的 WAL 條目大約佔一百個位元組。

同樣的方法可用來估算特定工作負載每單位時間產生的 WAL 量——這項資訊是設定檢查點時所必需的。

實體結構#

磁碟上,WAL 存放在 PGDATA/pg_wal 目錄中,分成獨立的檔案(區段,segment)。其大小由唯讀參數 wal_segment_size 顯示(預設 16 MB)。

對高負載系統而言,加大區段大小是合理的,可能減少開銷。但這項設定只能在叢集初始化時修改initdb --wal-segsize)。

WAL 條目寫入目前檔案直到空間用盡,接著 PostgreSQL 開啟新檔案。可以查出某條目位於哪個檔案、以及距檔案起點的偏移量:

=> SELECT file_name, upper(to_hex(file_offset)) file_offset
FROM pg_walfile_name_offset('0/3DF708D8');
            file_name              | file_offset
−−−−−−−−−−−−−−−−−−−−−−−−−−+−−−−−−−−−−−−−
 00000001000000000000003D | F708D8
(1 row)

檔名由兩部分構成:

  • 最高的 8 位十六進位數字定義從備份還原時使用的時間線(timeline)
  • 其餘部分代表 LSN 的高 64 位元(低位元則顯示在 file_offset 欄位)

用 pg_waldump 檢視條目#

pg_waldump 工具能依 LSN 範圍或特定交易 ID 過濾 WAL 條目。它必須以 postgres 作業系統使用者的身分執行,因為它需要存取磁碟上的 WAL 檔案。

postgres$ /usr/local/pgsql/bin/pg_waldump \
-p /usr/local/pgsql/data/pg_wal -s 0/3DF708D8 -e 0/3DF70948
rmgr: Heap        len (rec/tot):     69/    69, tx:        886, lsn:
0/3DF708D8, prev 0/3DF708B0, desc: HOT_UPDATE off 1 xmax 886 flags
0x40 ; new off 2 xmax 0, blkref #0: rel 1663/16391/16562 blk 0
rmgr: Transaction len (rec/tot):     34/    34, tx:        886, lsn:
0/3DF70920, prev 0/3DF708D8, desc: COMMIT 2023−03−06 14:01:48.875861
MSK
  • 第一筆是由 Heap 資源管理器處理的 HOT_UPDATE 操作;blkref 欄位顯示被更新 heap 頁面的檔名與頁面 ID
  • 第二筆是由 Transaction 資源管理器監管的 COMMIT 操作

檢查點#

要在故障後還原資料一致性(也就是執行復原),PostgreSQL 必須向前重播 WAL,把代表遺失變更的條目套用到對應頁面。判斷什麼被遺失的方式,是比較磁碟上頁面的 LSN 與 WAL 條目的 LSN。

但該從哪一點開始復原?

  • 起點太晚 → 在該點之前寫入磁碟的頁面將收不到全部變更,導致不可逆的資料損毀
  • 從最開頭起 → 不切實際:既無法存放如此龐大的資料量,也無法接受如此漫長的復原時間

我們需要一個逐步向前移動的檢查點(checkpoint),使得從該點開始復原是安全的,並讓所有更早的 WAL 條目可以被移除。

最直截了當的做法是週期性地暫停所有系統操作、強制把所有髒頁寫到磁碟。這當然不可接受——系統會停頓一段不確定但相當長的時間。

因此檢查點被攤開在一段時間內,實質上構成一個區間。檢查點的執行由一個稱為 checkpointer 的特殊背景行程負責。

三個階段#

1. 檢查點開始

checkpointer 行程把所有能瞬間寫出的東西刷到磁碟:CLOG 交易狀態、子交易中繼資料,以及少數其他結構。

2. 檢查點執行

檢查點的大部分時間花在把髒頁刷到磁碟:

  • 首先,在檢查點開始時為髒的所有緩衝區標頭中設一個特殊標記。這步非常快,因為不涉及 I/O
  • 接著 checkpointer 走訪所有緩衝區,把被標記的寫到磁碟。它們的頁面不會被淘汰出快取——只是被寫下來,因此使用計數與 pin 計數可以忽略
  • 頁面依 ID 順序處理,盡可能避免隨機寫入。為了更好的負載平衡,PostgreSQL 會在不同表空間之間輪替(它們可能位於不同實體裝置上)
  • backend 也可以寫出被標記的緩衝區——如果它們先碰到的話。無論如何,緩衝區標記在此階段就被移除,因此就檢查點而言每個緩衝區只會被寫一次

檢查點進行期間,頁面當然仍可能在緩衝快取中被修改。但由於新的髒緩衝區不會被標記,checkpointer 會忽略它們。

3. 檢查點完成

當檢查點開始時為髒的所有緩衝區都已寫入磁碟,檢查點即視為完成。

從這一刻起(而非更早!),檢查點的「開始」位置才會被用作新的復原起點。所有在該點之前寫入的 WAL 條目都不再需要。

圖 10-1:故障發生時,復原所需的 WAL 檔案自最後一個完成檢查點的開始位置起算

圖 10-2:新檢查點完成後,復原起點前移,先前的 WAL 檔案不再需要

最後 checkpointer 建立一筆對應檢查點完成的 WAL 條目,其中指明檢查點的開始 LSN。由於檢查點開始時不記錄任何東西,這個 LSN 可能屬於任何類型的 WAL 條目。

PGDATA/global/pg_control 檔案也會更新為指向最新完成的檢查點(在此過程結束前,pg_control 仍保留前一個檢查點)。

圖 10-3:檢查點的時序——由開始到完成,以及 pg_control 中記錄的最新檢查點與其 REDO 位置

實驗:手動觸發檢查點並在 WAL 中找到它

先弄髒一批快取頁面並記下目前 LSN:

=> UPDATE big SET s = 'FOO';
=> SELECT count(*) FROM pg_buffercache WHERE isdirty;   -- 4119
=> SELECT pg_current_wal_insert_lsn();                  -- 0/3E7EF7E0

手動完成檢查點,所有髒頁被刷到磁碟:

=> CHECKPOINT;
=> SELECT count(*) FROM pg_buffercache WHERE isdirty;   -- 0

檢視 WAL:

rmgr: XLOG        len (rec/tot):    114/    114, tx:         0, lsn:
0/3E7EF818, prev 0/3E7EF7E0, desc: CHECKPOINT_ONLINE redo
0/3E7EF7E0; tli 1; ... online

最新的 WAL 條目與檢查點完成(CHECKPOINT_ONLINE)有關。該檢查點的開始 LSN 標示在 redo 一詞之後,這個位置對應檢查點開始當時最後插入的 WAL 條目。

同樣的資訊也能在 pg_control 檔案中找到:

postgres$ pg_controldata -D /usr/local/pgsql/data | egrep 'Latest.*location'
Latest checkpoint location:                          0/3E7EF818
Latest checkpoint's REDO location:                   0/3E7EF7E0

復原#

伺服器啟動時第一個被拉起的行程是 postmaster,它接著衍生 startup 行程,由後者負責故障時的資料復原。

startup 行程讀取 pg_control 檔案並檢查叢集狀態,以判斷是否需要復原:

  • 正常停止的伺服器狀態為 shut down
  • 未運行的伺服器若狀態為 in production,即代表發生過故障

此時 startup 行程會自動從同一個 pg_control 檔案中所記錄的「最新完成檢查點的開始 LSN」發起復原。

PGDATA 目錄中含有與備份相關的 backup_label 檔案,起始 LSN 位置就取自該檔案。

復原如何進行#

startup 行程從指定位置開始逐筆讀取 WAL 條目,並在頁面的 LSN 小於 WAL 條目的 LSN 時把條目套用到資料頁面。

若頁面含有較大的 LSN,WAL 不該被套用——事實上是必須不能套用,因為這些條目被設計成嚴格循序重播。

然而有些 WAL 條目構成完整頁面映像(full page image,FPI)。這類條目可以套用到頁面的任何狀態,因為頁面內容反正會被全部覆蓋——這種修改稱為冪等(idempotent)。

另一個冪等操作的例子是登記交易狀態變更:每個交易狀態在 CLOG 中由特定位元定義,這些位元的設定與先前值無關——因此 CLOG 頁面不需要保存最新變更的 LSN

WAL 條目套用到緩衝快取中的頁面,與正常運作期間的一般頁面更新完全相同。檔案也以類似方式從 WAL 還原:例如某 WAL 條目顯示某檔案必須存在,但它因故不見了,該檔案就會被重新建立。

復原結束後:

  1. 所有 unlogged 關聯被對應的初始化分支覆寫
  2. 執行一次檢查點,把復原後的狀態固化到磁碟
  3. startup 行程的工作至此完成
側註:為何 PostgreSQL 不需要傳統的 roll-back 階段

古典形式的復原流程包含兩個階段:

  1. roll-forward 階段:重播 WAL 條目,重做遺失的操作
  2. roll-back 階段:伺服器中止那些在故障時尚未提交的交易

在 PostgreSQL 中,第二階段不需要。復原之後,未完成的交易在 CLOG 中既沒有 commit 位元也沒有 abort 位元(技術上這代表一筆「活躍」交易),但既然可以確定該交易已不再運行,它就會被視為已中止

實驗:模擬當機並觀察復原日誌

以 immediate 模式強制停止伺服器來模擬故障:

postgres$ pg_ctl stop -m immediate
postgres$ pg_controldata -D /usr/local/pgsql/data | grep 'state'
Database cluster state:                      in production

啟動伺服器,startup 行程發現發生過故障並進入復原模式:

LOG: database system was interrupted; last known up at 2023−03−06 14:01:49 MSK
LOG: database system was not properly shut down; automatic recovery in progress
LOG: redo starts at 0/3E7EF7E0
LOG: invalid record length at 0/3E7EF890: wanted 24, got 0
LOG: redo done at 0/3E7EF818
LOG: database system is ready to accept connections

相對地,若伺服器被正常停止,postmaster 會中斷所有客戶端連線,然後執行最終檢查點把所有髒頁刷到磁碟。此時叢集狀態為 shut down,而 WAL 末尾可看到 CHECKPOINT_SHUTDOWN 條目。

背景寫入#

若 backend 需要把髒頁淘汰出緩衝區,它就得把該頁寫到磁碟。這種情況不受歡迎,因為會造成等待——在背景非同步執行寫入好得多。

這項工作有一部分由 checkpointer 承擔,但仍然不夠。

因此 PostgreSQL 提供另一個專責背景寫入的行程:bgwriter。它依賴與淘汰相同的緩衝區搜尋演算法,但有兩項主要差異:

  • bgwriter 使用自己的時鐘指標,它永遠不落後於淘汰用的指標,通常還會超前
  • 走訪緩衝區時,不會減少使用計數

髒頁在「緩衝區未被 pin 住且使用計數為零」時被刷到磁碟。因此 bgwriter 跑在淘汰之前,主動把極可能很快被淘汰的頁面寫到磁碟,提高了「被選中淘汰的緩衝區是乾淨的」機率。

WAL 設定#

設定檢查點#

檢查點的持續時間(更精確地說,是寫出髒緩衝區的時間)由 checkpoint_completion_target 定義(預設 0.9)。它的值指定相鄰兩個檢查點開始之間的時間有多少比例分配給寫入。

其他參數的設定可依以下步驟:

  1. 決定相鄰兩個檢查點之間應儲存的 WAL 檔案量。量越大開銷越小,但這個值終究受限於可用空間與可接受的復原時間。
  2. 估算正常負載下產生這個量所需的時間:記下起始的插入 LSN,並不時檢查它與目前插入位置的差值。
  3. 把得到的數字當作典型的檢查點間隔,用作 checkpoint_timeout 的值。預設 5 分鐘很可能太小,通常會調高,例如調到 30 分鐘
  4. 但負載有時(甚至很可能)會更高,導致該區間內產生的 WAL 檔案過大。此時檢查點必須更頻繁地執行——用 max_wal_size(預設 1 GB)限制復原所需的 WAL 檔案大小;超過此門檻時伺服器會發起額外的檢查點

復原所需的 WAL 檔案,同時包含「最新完成的檢查點」與「目前尚未完成的檢查點」的條目。因此估算總量時,應把算出的檢查點間 WAL 大小乘以 1 + checkpoint_completion_target

(PostgreSQL 11 之前保留兩個已完成檢查點的 WAL 檔案,乘數為 2 + checkpoint_completion_target。)

依此設定,多數檢查點依排程執行(每 checkpoint_timeout 一次);但負載升高時,就在 WAL 大小超過 max_wal_size 時被觸發。

進度追蹤機制#

實際進度會週期性地與預期值比對:

指標定義
實際進度已處理的快取頁面比例
預期進度(依時間)已流逝的時間比例,假設檢查點必須在 checkpoint_timeout × checkpoint_completion_target 區間內完成
預期進度(依大小)已填滿的 WAL 檔案比例,其預期數量依 max_wal_size × checkpoint_completion_target 估算

若髒頁寫入超前排程,checkpointer 會暫停一會兒;若任一參數有落後,它會盡快追上。由於時間與資料量都被納入考量,PostgreSQL 能用同一套方法管理排程檢查點與按需檢查點。

WAL 檔案的回收#

一般情況下,磁碟上 WAL 檔案的大小變化如下:

圖 10-4:正常運作下磁碟上 WAL 大小的變化——每次檢查點之間累積,完成後回收

檢查點完成後,復原不再需要的 WAL 檔案會被刪除;但會保留數個檔案(總量最多 min_wal_size,預設 80 MB)供重複使用,只是單純改名。

這種改名減少了不斷建立與刪除檔案的開銷。若不需要,可用 wal_recycle 參數關閉此功能。

  • max_wal_size 指定的是期望的目標值,而非硬性上限。負載尖峰時寫入可能落後於排程
  • 伺服器無權刪除尚待複寫或尚待持續封存處理的 WAL 檔案。若啟用了這些功能,必須持續監控,因為它很容易造成磁碟爆滿
  • 可透過 wal_keep_size 參數保留一定量的空間存放 WAL 檔案

設定背景寫入#

設定好 checkpointer 之後,也應設定 bgwriter。這兩個行程必須合力在 backend 需要重用髒緩衝區之前就把它們寫到磁碟。

bgwriter 運作時會週期性暫停,每次睡眠 bgwriter_delay(預設 200 ms)。

兩次暫停之間寫出的頁面數量,取決於自上次執行以來 backend 存取的平均緩衝區數量(PostgreSQL 使用移動平均來平滑尖峰,同時避免依賴過舊的資料)。算出的數字再乘以 bgwriter_lru_multiplier(預設 2);但無論如何,單次執行寫出的頁面數不能超過 bgwriter_lru_maxpages(預設 100)。

若偵測不到髒緩衝區(也就是系統中什麼都沒發生),bgwriter 會睡到某個 backend 存取緩衝區為止,屆時它會醒來繼續正常運作。

監控#

檢查點設定可以、也應該依監控資料調校。

  • 依大小觸發的檢查點執行得比 checkpoint_warning(預設 30 秒)所定義的更頻繁,PostgreSQL 會發出警告。這項設定應與預期的尖峰負載保持一致。
  • log_checkpoints 參數(預設 off)啟用後,會把檢查點相關資訊印到伺服器日誌:
LOG: checkpoint complete: wrote 4100 buffers (25.0%); 0 WAL file(s)
added, 1 removed, 0 recycled; write=0.076 s, sync=0.009 s,
total=0.099 s; sync files=3, longest=0.007 s, average=0.003 s;
distance=9213 kB, estimate=9213 kB

日誌顯示寫出的緩衝區數量、檢查點後 WAL 檔案變化的統計、檢查點持續時間,以及相鄰兩個檢查點開始之間的距離(位元組)。

pg_stat_bgwriter#

對設定決策最有幫助的資料,是 pg_stat_bgwriter 視圖提供的背景寫入與檢查點執行統計。

9.2 版之前兩項任務都由 bgwriter 執行;之後引入了獨立的 checkpointer 行程,但這個共用視圖的名稱維持不變。

檢查點次數:

  • checkpoints_timed——排程檢查點(達到 checkpoint_timeout 時觸發)
  • checkpoints_req——按需檢查點(包含達到 max_wal_size 時觸發者)

寫出頁面數的統計:

  • buffers_checkpoint——由 checkpointer 寫出
  • buffers_backend——由 backend 寫出
  • buffers_clean——由 bgwriter 寫出

在設定良好的系統中,buffers_backend 必須遠低於 buffers_checkpointbuffers_clean 之和

設定背景寫入時要留意 maxwritten_clean:它顯示 bgwriter 因超過 bgwriter_lru_maxpages 門檻而不得不停下來的次數。

以下呼叫會清除已蒐集的統計:

=> SELECT pg_stat_reset_shared('bgwriter');