前一篇已完成 PostgreSQL 單機基線。今天從單機提交交易的落盤順序開始,沿著同一份預寫式日誌(Write-Ahead Log,WAL)追到複本節點(Replica),建立 PostgreSQL 高可用最底層的資料複寫。PostgreSQL 的設定與文件也常將這類節點稱為待命節點(Standby)。
今天的重點是 PostgreSQL 原生的實體串流複寫。先理解 WAL 保存的內容與落盤順序,再手動建立一台主節點(Primary)與兩台複本節點,實際觀察 WAL 如何傳送、寫入作業系統、落盤(Flush)與套用(Replay),並使用 LSN 判讀每台複本節點的資料進度。
本文閱讀方式
- 完整理解技術與底層原理:依序閱讀全文。
- 掌握完成部署所需的基本概念:優先閱讀標有「⭐」的章節。
- 快速了解當天的部署內容:閱讀文末的 Lab/實作章節。
- 跟著系列建立完整系統:依照 GitHub 詳細部署文件 的天數順序操作。
PostgreSQL 透過 WAL 讓交易提交無須等待所有修改過的資料頁寫回資料檔。後端程序會先修改共享緩衝區中的資料頁,同時產生能重做這次變更的 WAL 紀錄(WAL Record)。本次未指定同步待命節點,並使用預設的 synchronous_commit = on。提交所需的 WAL 在主節點寫入穩定儲存後,才向用戶端回覆成功。

圖(一)PostgreSQL 先保存能重做變更的 WAL,再向用戶端確認交易提交。檢查點完成資料頁落盤後,較舊且已無其他用途的 WAL 便可回收。
這個順序就是預寫原則(Write-Ahead)。若主機在資料頁全部寫回前中斷,PostgreSQL 可從最近的檢查點(Checkpoint)開始套用 WAL,將資料帶回一致狀態。WAL 首先是當機復原機制,串流複寫、WAL 封存(WAL Archive)與時間點復原(Point-in-Time Recovery,PITR)也都建立在它之上。
PostgreSQL 長期資料保存在資料檔的資料頁中。pg_wal 則保存依 LSN 排列的二進位紀錄,描述近期變更並提供 PostgreSQL 重做資料頁內容。
一筆 SQL 或一個交易也不一定只產生一筆 WAL。一次 UPDATE 可能同時修改堆積資料頁(Heap Page)、索引、可見性資訊與交易狀態,因此產生多筆由不同資源管理器(Resource Manager)處理的 WAL 紀錄。最後通常還有一筆 COMMIT 紀錄。反過來說,一筆 WAL 紀錄描述的是一次底層操作,不等同於一筆資料列或一條 SQL。
WAL 保存 PostgreSQL 重做資料頁變更所需的二進位資訊,主要描述發生變更的資料區塊、重做方式,以及交易最後是否提交。
假設商品庫存從 10 更新為 9,而且 stock 沒有被索引、資料頁也還有空間,這次更新可能成為同頁資料列更新(Heap-Only Tuple Update,HOT Update)。相關 WAL 大致包含:
| WAL 內容 | 這次更新可能保存的資訊 |
|---|---|
| 通用標頭 | 紀錄長度、交易 ID(Transaction ID)、前一筆 WAL 的 LSN、資源管理器編號、旗標與檢查碼 |
| 堆積資料列更新(Heap Update)資訊 | 舊資料列版本(Tuple)在資料頁中的位置、新資料列版本的位置、狀態旗標及更新後的二進位資料 |
| 區塊參照(Block Reference) | 哪個表格空間(Tablespace)、資料庫(Database)、關聯(Relation)、分支(Fork)與區塊(Block)需要修改 |
| 完整頁面映像 | 若為檢查點後第一次修改該頁,可能附帶整個資料頁的映像 |
| 交易紀錄(Transaction Record) | 另一筆 COMMIT 紀錄,表示這個交易 ID 已成功提交 |
使用 pg_waldump 解碼時,會看到接近以下結構。這是為了閱讀而整理的格式示意。LSN、交易 ID、關聯與區塊編號會依實際環境改變:
rmgr: Heap
tx: 742
lsn: 0/1A2B3C40
prev: 0/1A2B3BF8
desc: HOT_UPDATE
old tuple offset: 5
new tuple offset: 8
block reference: relation 1663/16384/24576, block 12
tuple data: 更新後資料列的二進位內容
rmgr: Transaction
tx: 742
lsn: 0/1A2B3CB0
prev: 0/1A2B3C40
desc: COMMIT
第一筆表示由堆積資源管理器在指定資料頁建立新版資料列,並連結舊、新資料列版本的位置。目前 LSN 是這筆紀錄在 WAL 串流中的位置,由 pg_waldump 讀取後顯示。更新後的庫存 9 位於資料列的二進位內容中,pg_waldump 通常只顯示紀錄種類與結構資訊,管理者需再依資料頁內容判讀實際欄位值。
第二筆 COMMIT 紀錄表示交易 742 已完成。沒有這筆提交狀態,重新啟動後即使底層資料列曾被重做,也不會成為正常交易可見的已提交版本。
如果 stock 是索引欄位,或 HOT Update 的其他條件不成立,WAL 中通常會看到一般 Heap UPDATE,並另外出現 B 樹(B-tree)等索引資源管理器產生的紀錄。若該資料頁是檢查點後第一次修改,區塊參照還可能攜帶完整頁面映像(Full-page Image),避免主機中斷時只寫入半個資料頁而無法復原。因此 WAL 通常比完整資料庫小,但大量寫入、頻繁檢查點與大量完整頁面映像可能產生可觀的 WAL。
若庫存為 9 的新版資料頁尚未寫回資料檔就發生中斷,PostgreSQL 重新啟動時會從 pg_control 找到最近的檢查點與重做起點,再依序套用後續 WAL。資料頁處於舊版本時便套用堆積與索引紀錄。資料頁已經寫入新版內容時,則依頁面 LSN 避免重複套用。
檢查點完成後,先前的髒資料頁(Dirty Page)已寫入資料檔。當更舊的 WAL 已經不再供當機復原、複本節點、複寫槽、備份或 WAL 封存使用,PostgreSQL 便能移除或重新命名這些 WAL 段檔(WAL Segment)。因此 pg_wal 是循環使用的工作區,只保留目前有用途的 WAL。
| 儲存內容 | 保存目的 | 一般生命週期 |
|---|---|---|
| 資料檔 | 保存資料庫目前的完整狀態 | 持續存在並隨資料量成長 |
| pg_wal 中的 WAL | 從目前復原起點重做近期變更 | 檢查點完成後循環回收或移除 |
| WAL 封存 | 支援較長時間的備份與 PITR | 依備份與保留政策另外管理 |
max_wal_size 是促使 PostgreSQL 執行檢查點的目標值。複本節點長期失聯、複寫槽停在舊 LSN、WAL 封存失敗或短時間產生大量 WAL,都可能讓 pg_wal 超過這個值並持續成長。
WAL 在邏輯上是一條連續的位元組串流,實際存放於資料目錄下的 pg_wal,並切成多個 WAL 段檔。每個段檔預設為 16 MiB,也可在建立資料庫叢集時調整。
日誌序號(Log Sequence Number,LSN)表示 WAL 串流中的位元組位置,與時間戳及交易 ID 各自描述不同資訊。兩個 LSN 相減可以計算相差多少 WAL。追上所需時間還要配合實際寫入與套用速度判讀。
| 名詞 | 表示的內容 | 今天如何使用 |
|---|---|---|
| WAL 紀錄(WAL Record) | 一次可被重做的資料變更紀錄 | 由主節點產生並傳給複本節點 |
| WAL 段檔 | pg_wal 中保存 WAL 的檔案單位 | 觀察 WAL 如何被保存與保留 |
| LSN | WAL 串流中的位置 | 比較傳送、落盤與套用進度 |
| 時間軸(Timeline) | 一條 WAL 歷史的分支 | 複本節點被提升後會建立新的歷史分支 |
複本節點被提升為新主節點時會建立新的時間軸。不同時間軸代表已經分岔的資料歷史,舊主節點重新加入前必須先與目前主節點對齊。
複本節點要先取得與主節點一致的實體基礎備份(Base Backup),建立完整資料頁起點,再從備份所對應的 LSN 繼續接收 WAL。
pg_basebackup 透過 PostgreSQL 的複寫協定(Replication Protocol)複製整個資料庫叢集,建立與來源相容的實體副本。需要選擇個別資料庫或資料表時,則使用 pg_dump 等邏輯備份工具。
今天使用的主要選項如下:
| 選項 | 作用 |
|---|---|
| -D | 指定複本節點的資料目錄 |
| -X stream | 備份資料檔時,同時串流備份期間所需的 WAL |
| -R | 建立 standby.signal,並把連線與複寫槽設定寫入 postgresql.auto.conf |
| -S | 指定這台複本節點使用的實體複寫槽 |
| -P | 顯示估算的備份進度 |
因此 pg_basebackup -R 完成後,除了複製檔案,也會留下 PostgreSQL 以待命節點身分啟動所需的設定。密碼應保存在權限受限的 .pgpass。
主節點還要以 wal_level = replica 產生足夠的複寫資訊,並讓 max_wal_senders 足以容納待命節點與備份連線。使用複寫槽時,max_replication_slots 也要保留足夠數量。這些參數已寫入三台節點,今天負責驗證它們如何被實際使用。
主節點上的 WAL 傳送程序(WAL Sender)讀取 WAL,透過複寫連線送給複本節點的 WAL 接收程序(WAL Receiver)。複本節點先接收、寫入並將 WAL 落盤,再由啟動程序(Startup Process)套用到資料頁。開啟熱待命(Hot Standby)後,使用者才能在復原期間執行唯讀查詢。

圖(二)主節點將已產生的 WAL 串流到複本節點。複本節點完成接收、落盤與套用後,查詢才看得到對應資料。
複寫連線應使用只有 LOGIN 與 REPLICATION 權限的專用帳號。pg_hba.conf 中的 replication 是專門匹配實體複寫連線的資料庫欄位。今天以 hostssl、指定帳號及每台節點的 /32 位址限制來源。TLS 與憑證基線可回看 Day 20。
複寫帳號能讀取可能包含資料變更內容的 WAL,屬於需要獨立保護的高敏感權限,應與一般應用程式帳號分開使用。
今天讓 pg02、pg03 都直接連向 pg01,稱為直接串流複寫。PostgreSQL 也支援讓一台待命節點再把 WAL 傳給下游待命節點的串聯複寫(Cascading Replication),可減少主節點的連線數或跨站頻寬,但會增加上游依賴與複寫延遲。本系列採用直接串流拓撲。
主節點的 pg_stat_replication 會分別記錄每台複本節點到達的進度。這些欄位共同描述同一筆 WAL 依序經過的傳送、寫入、落盤與套用階段。
| 欄位 | 可以回答的問題 |
|---|---|
| sent_lsn | 主節點已經把 WAL 送到哪裡? |
| write_lsn | 複本節點已寫入作業系統到哪裡? |
| flush_lsn | 複本節點已持久化到哪裡? |
| replay_lsn | 複本節點已套用到哪裡? |
LSN 差距適合計算尚差多少 WAL。write_lag、flush_lag 與 replay_lag 則反映近期提交在各階段花費的時間,也可估計同步複寫可能增加的等待時間。複本節點追上所需時間還要配合 WAL 產生及套用速度判讀。連線閒置一段時間後,這些欄位可能變成 NULL。
複本節點可由 pg_stat_wal_receiver 觀察目前連到哪一台主節點,以及接收程序的狀態。判斷複寫健康時,應同時查看連線狀態、各階段 LSN 與應用程式實際可見資料。state = streaming 表示 WAL 傳送程序已進入串流階段,當下的落後量與資料可見性則要配合 LSN 及查詢結果判讀。
串流複寫預設為非同步。主節點在本機完成提交條件後即可回覆,因此寫入延遲較低。若主節點的儲存永久損毀,尚未送達複本節點的交易可能遺失。
同步複寫會讓提交等待指定複本節點到達特定位置。主節點需要同時以 synchronous_commit 指定等待階段,並透過 synchronous_standby_names 指定哪些待命節點可提供同步確認。
synchronous_standby_names 可用 FIRST N 依清單優先順序選出 N 台同步待命節點,也可用 ANY N 等待候選清單中任意 N 台回覆。前者表達優先順位,後者表達複數節點中的最低確認數。兩者都是 PostgreSQL 的提交確認規則,新的主節點則由高可用控制元件決策。
主節點先以 synchronous_standby_names 選出同步複本節點後,remote_write、on 與 remote_apply 才會等待對應的遠端階段。synchronous_standby_names 為空時,提交只依本機條件完成。synchronous_commit 決定每筆交易等待到哪個位置,synchronous_standby_names 則決定等待哪些複本節點,兩者必須一起判讀。

圖(三)主節點先選出同步複本節點,再由 synchronous_commit 決定 COMMIT 要等待本機落盤、遠端寫入、遠端落盤或遠端套用。
同步複寫需要在資料保護與可用性之間取捨。同步待命節點或網路失效時,交易可能延遲甚至持續等待。正式環境應依可接受的資料遺失範圍、寫入延遲與故障時寫入政策決定策略。今天的 Lab 保留預設非同步模式,用來觀察 PostgreSQL 原生資料路徑。
若主節點已回收複本節點尚未取得的 WAL,離線過久的複本節點便需要重新建立基礎備份。實體複寫槽(Physical Replication Slot)會記住消費者需要的 WAL 位置,避免主節點過早移除這些檔案。
| pg_replication_slots 欄位 | 觀察重點 |
|---|---|
| active | 目前是否有複本節點使用這個複寫槽 |
| restart_lsn | 主節點至少要從哪個位置開始保留 WAL |
| wal_status | 所需 WAL 的可用狀態,以及複寫槽是否面臨失效 |
| safe_wal_size | 在複寫槽可能失去所需 WAL 前,還能寫入的大約容量 |
max_slot_wal_keep_size 保持預設值 -1 時,safe_wal_size 會顯示 NULL,表示複寫槽尚未設定 WAL 保留容量上限。
主節點也能以 wal_keep_size 保留最低數量的 WAL,或讓複本節點從 WAL 封存取得較舊的段檔。複寫槽則依特定消費者的進度保留所需 WAL。它保護 WAL 的可取得性,磁碟容量需要另外管理。複本節點長期離線且 restart_lsn 停滯時,主節點的 pg_wal 可能持續成長。正式環境要同時設定容量界線、監控複寫槽狀態,並建立停用或移除失聯複寫槽的程序。
暫停 WAL 套用時,WAL 接收程序可以繼續接收並落盤,使複寫槽的 restart_lsn 前進。這時主節點保留量可能變化不大,但複本節點會累積尚待套用的 WAL,查詢結果也會停留在舊版本。
複本節點處於復原模式時會接收並套用主節點傳來的 WAL,並拒絕一般 SQL 寫入。hot_standby 決定它能否在持續套用 WAL 的同時接受唯讀查詢。
| hot_standby 狀態 | 複寫節點的行為 |
|---|---|
| 未啟用 | 繼續接收並套用 WAL,但不接受一般使用者連線與查詢 |
| 已啟用 | 繼續接收並套用 WAL,也允許連線及執行 SELECT 等唯讀查詢 |
無論是否啟用熱待命,處於復原模式的複本節點都會拒絕 INSERT、UPDATE、DELETE 及其他需要寫入的操作。節點必須先被提升為主節點並離開復原模式,才能接受一般 SQL 寫入。
長時間查詢可能需要正在使用的資料版本,而主節點傳來的 WAL 正準備清除或修改相同資料,因而產生熱待命查詢衝突(Hot Standby Conflict)。PostgreSQL 可以等待一段時間後取消複本節點的查詢。hot_standby_feedback 可減少部分衝突,代價是主節點可能保留更多舊資料版本並造成膨脹。
手動串流複寫完成後,資料可以持續到達複本節點,但它本身沒有回答哪台節點應成為主節點、故障時由誰做決定,以及應用程式要連到哪個固定入口。
後續架構會由 etcd 提供多數決控制平面,Patroni 依據其中的一致狀態管理 PostgreSQL 角色與生命週期,再由代理服務與虛擬 IP 提供固定連線入口。複寫會同步誤刪資料與錯誤交易。基礎備份、WAL 封存與時間點復原則負責保留可獨立復原的歷史版本。
今天以 pg01 為主節點(Primary),依序將 pg02、pg03 建立為實體待命節點(Physical Standby),把正文中的 WAL 傳送、LSN 進度、唯讀限制與套用行為變成可觀察結果。
完整命令、設定位置與復原保護步驟請依照:
GitHub 實作文件:Day 21|手動 Streaming Replication
以下畫面是在叢集改由 Patroni 管理後補拍,當時 pg03 為主節點,pg01、pg02 為複本節點。角色與複寫槽名稱和最初手動建立時不同,但 WAL 傳送、套用與熱待命的判讀方式相同。
在目前的主節點 pg03 查詢 pg_stat_replication,可以看到 pg01、pg02 的 state 均為 streaming,sync_state 均為 async。pg_replication_slots 也顯示 pg01、pg02 使用的兩個實體複寫槽均為 active。

圖(四)目前的主節點 pg03 同時維持兩條非同步串流連線,並為 pg01、pg02 保留各自使用的實體複寫槽。
畫面中的 safe_wal_size 為空白,對應 PostgreSQL 顯示的 NULL。目前使用預設的無上限保留設定。
先在目前的主節點 pg03 寫入可辨識的測試資料並記錄目前 LSN,再於複本節點 pg02 查詢相同資料。pg02 的 pg_is_in_recovery() 為 t,且能查到相同的 id、時間與內容,因此可將主節點寫入、WAL 傳送與複本節點套用串成同一份證據。

圖(五)pg02 處於復原模式,並在套用 WAL 後讀到目前主節點 pg03 寫入的相同資料。
在 pg02 對 appdb 執行 SELECT 應成功。對相同資料庫執行 INSERT 則應收到唯讀交易錯誤。這項結果證明熱待命的服務邊界。

圖(六)熱待命可提供唯讀查詢,並由 PostgreSQL 拒絕寫入操作。
先在複本節點 pg02 記錄 Receive LSN、Replay LSN 與資料筆數,再暫停 WAL 套用。接著由主節點 pg03 寫入一批測試資料。回到 pg02 時,Receive LSN 應繼續前進,但 Replay LSN 與查詢結果維持在舊位置。恢復套用後,Replay LSN 與資料筆數應追上。欄位名稱保留英文,方便與查詢結果直接對照。
本次測試專門觀察 WAL 接收與套用的差異。節點中斷、角色提升與服務恢復時間屬於故障切換測試。
補拍時叢集已由 Patroni 管理,因此先讓 Patroni 進入維護暫停,完成測試後再恢復 WAL Replay 與 Patroni 管理。否則 Patroni 會主動解除 Replica 的 WAL Replay 暫停,無法留下兩個 LSN 分離的中間狀態。

圖(七之一)pg02 進入 paused,Receive LSN 與 Replay LSN 都位於 0/67000000,三筆測試資料尚未出現。

圖(七之二)Receive LSN 前進至 0/67000F38,Replay LSN 保持在 0/67000000,證明 WAL 已接收但尚未套用。

圖(七之三)恢復套用後,Receive LSN 與 Replay LSN 都前進至 0/68000000,三筆測試資料也能查詢。
| 檢查項目 | 畫面中的觀察結果 | 結論類型 |
|---|---|---|
| 主節點的 pg_stat_replication | pg01、pg02 均為 streaming/async | 驗證目前的複寫連線狀態 |
| 主節點的 pg_replication_slots | pg01、pg02 使用的實體複寫槽均為 active | 驗證目前的 WAL 保留機制 |
| 複本節點查詢主節點新增資料 | pg02 讀到 pg03 寫入的相同 id 與內容 | 證明 WAL 已完成套用 |
| 複本節點執行 SELECT/INSERT | SELECT 成功、INSERT 被拒絕 | 證明熱待命為唯讀 |
| 複本節點暫停 WAL 套用 | Receive LSN 前進,Replay LSN 與資料內容暫時不變 | 證明接收與套用是不同階段 |
streaming 狀態判讀複寫進度。下一篇是 Day 22|Patroni 如何讓 PostgreSQL 叢集取得一致決策?三節點 etcd、Raft 與 mTLS。資料已能到達複本節點,接著要建立具備多數決的分散式設定儲存,讓後續的 Patroni 能在節點故障與網路分割時取得一致的角色判斷依據。