WAL: vì sao Postgres bắt buộc ghi log trước data file, và lý do pg_wal/ đầy đĩa làm cluster ngừng nhận write
WAL (Write-Ahead Log) là cơ chế durability lõi của Postgres: mọi thay đổi đối với heap, index, free-space map, visibility map đều phải được ghi xuống WAL và fsync trước khi data file tương ứng được phép flush ra đĩa. Nguyên tắc này, mô tả trong Postgres docs chương "Reliability and the Write-Ahead Log", là cái cho phép một transaction đã COMMIT thoả ACID-D dù OS crash hoặc mất điện ngay sau đó. Dev gặp WAL trong việc thật không phải vì cú pháp khó: gặp khi pg_wal/ đầy đĩa do một replication slot bị quên dọn, Postgres dừng nhận write với PANIC: could not write to file ... No space left on device, hoặc khi crash recovery sau OOM kéo mười mấy phút làm health check fail và load balancer cắt traffic.
Cơ chế hoạt động
Postgres không ghi thẳng vào data file mỗi khi có INSERT/UPDATE. Trang 8KB (heap page, index page) sống trong shared_buffers; mỗi thay đổi tạo ra một WAL record mô tả delta đó (record type, relfilenode, block number, payload), append vào wal_buffers — một vùng shared memory nhỏ trước khi xuống đĩa. Tại thời điểm COMMIT, backend gọi XLogFlush() để write + fsync WAL tới hết byte chứa commit record; chỉ sau khi fsync trả về, Postgres mới ghi commit bit vào pg_xact và reply OK về client. Data page bẩn ở lại trong shared_buffers; checkpointer sẽ flush chúng ra data file sau, không gắn với từng commit.
WAL được tổ chức thành segment file kích thước cố định trong $PGDATA/pg_wal/, mặc định 16MB mỗi segment (cấu hình lúc initdb --wal-segsize). Vị trí trong WAL là LSN (Log Sequence Number) — số 64-bit, in dạng XXXX/XXXXXXXX, thực chất là byte offset từ đầu WAL của cluster. LSN tăng đơn điệu và là "đồng hồ" duy nhất Postgres tin cậy cho thứ tự ghi.
-- Quan sát LSN tiến lên sau mỗi ghi
SELECT pg_current_wal_lsn(); -- vd: 0/1A2B3C40
INSERT INTO t SELECT g FROM generate_series(1, 1000) g;
SELECT pg_current_wal_lsn(); -- 0/1A2BE018
SELECT pg_wal_lsn_diff('0/1A2BE018', '0/1A2B3C40') AS bytes_written;
-- Tên segment chứa một LSN cụ thể
SELECT pg_walfile_name(pg_current_wal_lsn());
-- vd: 000000010000000000000001
Sau crash, Postgres khởi động ở recovery mode: đọc pg_control để lấy LSN của redo point (LSN đầu của checkpoint gần nhất đã hoàn tất), rồi replay tuần tự mọi WAL record từ đó tới hết WAL hợp lệ — apply lại vào shared buffers, tái dựng state in-memory. Mọi commit có WAL record đã fsync trước crash sẽ được khôi phục; mọi thay đổi chưa kịp ghi WAL biến mất. Đây là toàn bộ contract durability.
Có một cơ chế phụ quan trọng tên là full-page write (full_page_writes = on mặc định): lần đầu một page bị sửa sau mỗi checkpoint, Postgres ghi cả 8KB page vào WAL chứ không phải chỉ delta. Lý do là OS có thể ghi page xuống đĩa kiểu torn write (một phần 4KB cũ + một phần 4KB mới) khi mất điện; replay chỉ delta lên một torn page sẽ ra rác. Full-page write cho phép replay overwrite cả page bằng bản sao trong WAL, an toàn với mọi loại corruption nửa-ghi. Hệ quả: WAL volume phình mạnh ngay sau mỗi checkpoint rồi giảm dần khi các page nóng đã được FPW một lần.
Vấn đề gặp trong production
Failure mode 1: pg_wal/ phình vì replication slot bỏ quên. Replication slot (logical hoặc physical) buộc Postgres giữ lại mọi WAL segment từ restart_lsn của slot trở đi cho tới khi consumer xác nhận đã apply. Một consumer chết, một slot tạo cho debug rồi không dọn, một Debezium connector bị pause vô thời hạn — đủ để pg_wal/ lớn lên không giới hạn, vượt qua max_wal_size (vốn chỉ là soft target). Khi partition $PGDATA hết chỗ, Postgres PANIC và shutdown; cluster không khởi động lại được cho tới khi giải phóng đĩa.
-- Sai: tạo slot cho một consumer ad-hoc rồi quên drop
SELECT pg_create_logical_replication_slot('debug_cdc', 'pgoutput');
-- consumer chạy vài phút rồi tắt; slot tiếp tục pin WAL mãi mãi
-- Đúng: kiểm slot trước khi tạo, set hàng rào cứng cho slot không active
SELECT slot_name, active, restart_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;
-- Postgres 13+ có max_slot_wal_keep_size: slot quá lag sẽ bị invalidate thay vì fill disk
ALTER SYSTEM SET max_slot_wal_keep_size = '20GB';
SELECT pg_reload_conf();
max_slot_wal_keep_size là lưới cứu thực tế: thà mất một consumer (phải resync) còn hơn mất cả cluster vì hết đĩa.
Failure mode 2: archive_command fail thầm lặng. Khi bật archiving (archive_mode = on), Postgres chỉ recycle một segment WAL sau khi archive_command trả exit 0. Nếu lệnh archive (aws s3 cp ..., wal-g wal-push ...) fail liên tục do credential hết hạn hoặc bucket policy đổi, các segment cứ ở lại pg_wal/. Service vẫn chạy, log Postgres có dòng archive command failed with exit code N lặp lại, nhưng nếu không ai để ý, cuối cùng đĩa đầy. Bài học từ vụ GitLab database outage 2017 (postmortem công khai trên blog GitLab) đúng phần này: nhiều cơ chế bảo vệ (backup, replication, WAL archiving) đều fail thầm cùng lúc, không alert nào kêu.
Failure mode 3: hiểu lầm "Postgres ghi thẳng vào table file". Một dev đo sai bằng cách strace thấy data file rarely được ghi, kết luận "Postgres lazy, không persist dữ liệu". Thực tế durability nằm ở WAL fsync chứ không ở data file; data file flush là việc của checkpointer, thưa hơn nhiều. Khi tự "tối ưu" bằng cách disable fsync, tắt full_page_writes, hoặc đặt synchronous_commit = off mà không hiểu hệ quả → một lần mất điện duy nhất là dữ liệu hỏng không recover được. fsync = off được Postgres docs cảnh báo rõ là "may result in unrecoverable data corruption".
Failure mode 4: WAL spike sau checkpoint làm replica lag nhảy. Ngay sau checkpoint, mỗi page nóng bị sửa lần đầu phát sinh FPW 8KB trong WAL. Một bảng đang chịu UPDATE rải rác có thể làm WAL volume gấp nhiều lần trong vài chục giây sau checkpoint, đẩy network ra replica spike, replication lag nhảy lên. Nếu app dùng read replica cho query nhạy lag (vd: "đọc lại order vừa tạo"), người dùng sẽ thấy "tạo đơn xong refresh không thấy đơn" mỗi vài phút theo nhịp checkpoint. Giảm bằng cách kéo checkpoint_timeout xa hơn (vd vài chục phút thay vì mặc định), tăng max_wal_size để checkpoint không bị ép theo size, và bật wal_compression để FPW nén lại.
Failure mode 5: crash recovery lâu vì WAL tích tụ giữa hai checkpoint. Càng nhiều WAL kể từ redo point gần nhất, recovery càng phải replay nhiều. Nếu cluster set checkpoint_timeout xa để né FPW amplification nhưng workload ghi nặng, sau crash có thể phải replay nhiều GB WAL → vài phút tới chục phút không phục vụ. Đây là tradeoff trực tiếp giữa overhead vận hành (FPW) và RTO (recovery time).
Cách debug và monitor
Triệu chứng cần soi:
-
pg_wal/lớn dần và vượt nhiều lầnmax_wal_size→ có slot pin WAL hoặc archive đang fail. - Log Postgres có
archive command failed,checkpoints are occurring too frequently, hoặc cảnh báo vềmax_wal_size. - Replica lag (
pg_last_wal_replay_lsnso với primarypg_current_wal_lsn) nhảy theo chu kỳ trùngcheckpoint_timeout. - Sau crash, startup log spam
redo starts at ...,redo done at ..., khoảng cách thời gian lớn = RTO dài.
Các view và hàm cần thuộc (đều có trong Postgres docs, mục "System Functions" và "System Views"):
-- Vị trí ghi hiện tại và đang flush
SELECT pg_current_wal_lsn() AS insert_lsn,
pg_current_wal_flush_lsn() AS flush_lsn,
pg_current_wal_insert_lsn() AS write_lsn;
-- WAL generation rate qua hai snapshot
-- Lấy lsn1 tại t1, lsn2 tại t2, tính bytes/giây:
SELECT pg_wal_lsn_diff(:lsn2, :lsn1) / EXTRACT(EPOCH FROM (:t2 - :t1)) AS bytes_per_sec;
-- pg_stat_wal (Postgres 14 trở đi) — counters cumulative
SELECT wal_records, wal_fpi, wal_bytes,
wal_buffers_full, wal_write, wal_sync,
wal_write_time, wal_sync_time
FROM pg_stat_wal;
-- Archiver status
SELECT archived_count, failed_count, last_failed_wal, last_failed_time
FROM pg_stat_archiver;
-- Replication slot pin WAL bao nhiêu
SELECT slot_name, active, restart_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots
ORDER BY retained DESC;
pg_waldump là tool đi kèm Postgres để giải mã từng WAL record — dùng khi cần biết chính xác record nào đang chiếm chỗ:
# Decode mọi record trong một segment, đếm theo loại
pg_waldump $PGDATA/pg_wal/000000010000000000000005 \
| awk '{print $4}' | sort | uniq -c | sort -rn | head
# Cột "FPI" trong output cho biết record có full-page-image — nguồn FPW amplification
Metric cần export lên monitoring (Prometheus + postgres_exporter phơi sẵn các cột trên):
-
WAL generation rate — bytes/sec từ delta
pg_current_wal_lsn. Baseline workload, alert khi tăng đột biến gấp nhiều lần. -
pg_walsize on disk —du -sb $PGDATA/pg_walhoặc tổngsizetừpg_ls_waldir(). Alert khi vượtmax_wal_sizenhiều lần (vd 3×). -
Replication slot lag —
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)cho mỗi slot. Alert theo absolute threshold của bucket. -
pg_stat_archiver.failed_countdelta — bất kỳ tăng nào là sự cố cần kiểm. -
Checkpoint frequency — số checkpoint mỗi giờ từ
pg_stat_bgwriter(checkpoints_timedvscheckpoints_req). Tỷ lệcheckpoints_reqcao =max_wal_sizequá nhỏ, đang bị ép checkpoint theo size.
Rule phòng ngừa cứng:
- Luôn set
max_slot_wal_keep_sizeở mức "có thể mất consumer còn hơn mất cluster". - Mọi cluster bật archiving phải có alert trên
failed_count; nếu fail liên tục N phút, oncall page. - Không bao giờ tắt
full_page_writeshay đặtfsync = offtrên production — Postgres docs ghi rõ rủi ro corruption không thể phục hồi. - Đặt
checkpoint_timeoutđủ xa vàmax_wal_sizeđủ rộng để checkpoint nhịp theo thời gian, không bị ép theo size.
Tradeoff
WAL đổi thêm I/O ghi tuần tự để có durability + crash recovery + replication + PITR. Cụ thể: mỗi commit phải tốn ít nhất một fsync (latency vài ms trên SSD enterprise, có thể chục ms trên đĩa kém), mỗi page nóng bị sửa lần đầu sau checkpoint tốn thêm 8KB FPW trong WAL, và disk pg_wal/ phải đủ rộng cho max_wal_size + lag của replica/archiver tệ nhất. Cái được lại là: một cluster sống sót qua mọi kiểu mất điện đột ngột, có thể replay tới một timestamp bất kỳ với PITR, và replication streaming gần như miễn phí vì nó chỉ là consumer của cùng cái stream đã phải ghi sẵn. synchronous_commit = off cho commit không đợi fsync — throughput tăng nhưng một crash có thể mất các transaction gần nhất (giới hạn bởi wal_writer_delay, mặc định 200ms theo Postgres docs); chấp nhận được với log/analytics, không chấp nhận được với thanh toán. Quy tắc thực tế: giữ synchronous_commit = on cho data có giá trị; full_page_writes = on luôn luôn; max_wal_size đặt rộng để checkpoint chạy theo checkpoint_timeout chứ không theo size; alert cứng trên size pg_wal/, archiver failed_count, và slot retention — đây là bộ ba duy nhất đứng giữa cluster và sự cố "đĩa đầy".
Câu hỏi phỏng vấn
Vì sao Postgres cần WAL? Mô tả flow từ một
INSERTcho tới khi client nhậnCOMMIT OK, và điều gì xảy ra nếu OS crash ngay sau khiCOMMITđược reply.
WAL là durability primitive: nguyên tắc "log trước, data sau" cho phép Postgres trả lời COMMIT OK mà không cần data file đã ghi xuống đĩa. Khi INSERT chạy: tuple được chèn vào heap page trong shared_buffers (page chuyển thành dirty), một WAL record mô tả thay đổi được append vào wal_buffers. Tại COMMIT, backend gọi XLogFlush() để write + fsync wal_buffers từ đầu tới byte chứa commit record xuống pg_wal/; chỉ sau khi fsync trả về thành công, Postgres đặt commit bit vào pg_xact và reply OK về client. Data page bẩn ở lại trong shared buffer; checkpointer flush ra data file sau theo nhịp checkpoint_timeout. Nếu OS crash ngay sau khi client nhận OK, dữ liệu được đảm bảo: lúc khởi động lại Postgres đọc pg_control, tìm redo LSN của checkpoint gần nhất, replay tuần tự mọi WAL record từ đó (bao gồm record của transaction vừa commit) vào shared buffers, dựng lại state — commit không mất. Mọi transaction chưa tới được bước fsync xem như chưa xảy ra. Hậu quả production cần nêu: (1) pg_wal/ phình vô hạn nếu một replication slot bị bỏ quên hoặc archive_command fail thầm — disk full → cluster PANIC dừng nhận write (đặt max_slot_wal_keep_size, alert pg_stat_archiver.failed_count); (2) full-page write sau checkpoint làm WAL volume nhảy mạnh, kéo replication lag — đặt checkpoint_timeout xa và max_wal_size rộng để FPW thưa; (3) checkpoint quá thưa khiến crash recovery replay nhiều GB WAL → RTO dài; cân bằng giữa overhead FPW và recovery time. Điểm ăn điểm là gắn nguyên tắc "log first" với từng đối tượng được phơi ra: pg_current_wal_lsn/pg_stat_wal để đo, pg_replication_slots/pg_stat_archiver để biết ai đang pin WAL, pg_waldump để soi record — không chỉ đọc lại định nghĩa WAL.
Hands-on
Mục tiêu: tận mắt thấy WAL tiến theo từng INSERT, đo bytes WAL sinh ra, gọi pg_waldump đọc record, ép FPW spike sau checkpoint, dựng một replication slot "thây ma" để thấy pg_wal/ phình, rồi cứu hộ bằng max_slot_wal_keep_size.
Dựng Postgres trong container với log đủ chi tiết:
docker run -d --name pg-wal \
-e POSTGRES_PASSWORD=pw -p 5436:5432 \
postgres:16 \
-c wal_level=logical \
-c max_replication_slots=4 \
-c checkpoint_timeout=30s \
-c max_wal_size=64MB \
-c log_checkpoints=on \
-c log_min_messages=info
sleep 5
docker exec -i pg-wal psql -U postgres <<'SQL'
CREATE TABLE t (
id bigserial PRIMARY KEY,
data text NOT NULL
);
SQL
Bước 1 — Quan sát LSN tiến lên sau INSERT, đo bytes WAL:
docker exec -it pg-wal psql -U postgres
SELECT pg_current_wal_lsn() AS before \gset
INSERT INTO t(data) SELECT repeat('x', 100) FROM generate_series(1, 10000);
SELECT pg_current_wal_lsn() AS after \gset
SELECT :'before' AS before, :'after' AS after,
pg_size_pretty(pg_wal_lsn_diff(:'after', :'before')) AS wal_generated;
Lặp với UPDATE để thấy WAL nhiều hơn INSERT (vì MVCC sinh tuple version mới + heap_update record):
SELECT pg_current_wal_lsn() AS before \gset
UPDATE t SET data = data || 'y' WHERE id <= 10000;
SELECT pg_current_wal_lsn() AS after \gset
SELECT pg_size_pretty(pg_wal_lsn_diff(:'after', :'before')) AS wal_on_update;
Bước 2 — Decode WAL record bằng pg_waldump:
# Liệt kê segment hiện có
docker exec -it pg-wal ls -lh /var/lib/postgresql/data/pg_wal/ | head
# Decode một segment (thay tên file theo output trên)
docker exec -it pg-wal pg_waldump /var/lib/postgresql/data/pg_wal/000000010000000000000001 2>/dev/null \
| head -20
# Đếm loại record để thấy heap/btree chiếm bao nhiêu, có bao nhiêu FPI
docker exec -it pg-wal sh -c \
"pg_waldump /var/lib/postgresql/data/pg_wal/000000010000000000000001 2>/dev/null \
| awk '{print \$4}' | sort | uniq -c | sort -rn | head"
Bước 3 — Force checkpoint, quan sát FPW spike. Cập nhật rải rác trên nhiều page rồi đo WAL trước/sau checkpoint:
-- Warm-up cho table lớn hơn để có nhiều page
INSERT INTO t(data) SELECT repeat('z', 200) FROM generate_series(1, 200000);
CHECKPOINT;
-- Lấy snapshot pg_stat_wal trước
SELECT wal_records, wal_fpi, wal_bytes FROM pg_stat_wal \gset prev_
-- UPDATE rải rác → lần đầu chạm mỗi page sau checkpoint sẽ ghi FPI 8KB
UPDATE t SET data = data || '!' WHERE id % 100 = 0;
-- So sánh
SELECT wal_records - :prev_wal_records AS delta_records,
wal_fpi - :prev_wal_fpi AS delta_fpi,
pg_size_pretty(wal_bytes - :prev_wal_bytes) AS delta_bytes
FROM pg_stat_wal;
-- delta_fpi sẽ lớn ngay sau CHECKPOINT, giảm dần ở các UPDATE kế tiếp trên cùng page
Bước 4 — Dựng replication slot "thây ma" pin WAL, quan sát pg_wal/ phình:
SELECT pg_create_logical_replication_slot('orphan', 'pgoutput');
-- không có consumer nào kết nối tới slot này
SELECT slot_name, active, restart_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;
Sinh tải để WAL bị giữ lại:
INSERT INTO t(data) SELECT repeat('q', 500) FROM generate_series(1, 500000);
CHECKPOINT; -- bình thường sau checkpoint, segment cũ được recycle
Kiểm pg_wal/ size:
docker exec -it pg-wal sh -c "du -sh /var/lib/postgresql/data/pg_wal && ls /var/lib/postgresql/data/pg_wal | wc -l"
# pg_wal lớn hơn max_wal_size vì slot pin các segment chưa được consume
-- Khẳng định bằng pg_replication_slots
SELECT slot_name, active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;
Bước 5 — Cứu hộ bằng max_slot_wal_keep_size:
ALTER SYSTEM SET max_slot_wal_keep_size = '32MB';
SELECT pg_reload_conf();
-- Tiếp tục sinh WAL
INSERT INTO t(data) SELECT repeat('w', 500) FROM generate_series(1, 500000);
CHECKPOINT;
-- Slot sẽ bị invalidate khi vượt ngưỡng — wal_status chuyển 'lost'
SELECT slot_name, active, wal_status,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;
# pg_wal/ trở lại quanh max_wal_size sau khi checkpoint kế tiếp recycle
docker exec -it pg-wal du -sh /var/lib/postgresql/data/pg_wal
Dọn:
SELECT pg_drop_replication_slot('orphan');
docker rm -f pg-wal
Sau bài này một câu SELECT pg_current_wal_lsn() không còn là chuỗi ký tự lạ — nó là đồng hồ duy nhất Postgres tin cậy, và mọi thứ về durability, replication, PITR đều quay quanh chuỗi byte mà LSN trỏ vào.
Top comments (0)