WAL trong PostgreSQL — vì sao COMMIT chỉ cần ghi log
PostgreSQL báo COMMIT xong khi bản ghi WAL tuần tự đã fsync, chưa cần ghi page dữ liệu. Checkpoint, full_page_writes và synchronous_commit là chỗ nó tính tiền lại.
TL;DR: PostgreSQL không ghi page dữ liệu lúc COMMIT. Mọi thay đổi nối vào WAL (write-ahead log) dưới dạng bản ghi, và COMMIT chỉ chờ fsync WAL tới bản ghi commit: một lần ghi tuần tự thay vì nhiều page 8 kB rải rác. Page dữ liệu để checkpoint ghi sau; crash thì recovery làm lại WAL từ điểm redo của checkpoint gần nhất. full_page_writes chép nguyên page vào WAL ở lần sửa đầu sau checkpoint để chống page ghi dở. synchronous_commit = off bỏ bước chờ fsync, có thể mất tối đa khoảng 600 ms giao dịch cuối nhưng không hỏng dữ liệu.
Bạn chạy UPDATE tasks SET status = 'done' WHERE id <= 50000 trên PostgreSQL 16, đợi COMMIT trả về, rồi giết tiến trình bằng docker kill -s KILL. Không có checkpoint nào chạy giữa hai việc đó, nên chẳng gì bảo đảm page dữ liệu đã rời shared_buffers. Khởi động lại, log in ra (chạy thật ngày 2026-10-06):
LOG: database system was not properly shut down; automatic recovery in progress
LOG: redo starts at 0/218DCB8
LOG: redo done at 0/2F34F60 system usage: CPU: user: 0.03 s, system: 0.00 s, elapsed: 0.04 s
Đếm lại vẫn đủ 50000 dòng done. Dữ liệu sống lại từ đâu? Bài này lần theo bản ghi WAL từ UPDATE tới COMMIT, rồi xem checkpoint và ba tham số full_page_writes, synchronous_commit, fsync trả giá bằng gì.
Bạn đã gặp ý tưởng chung: ghi log trước, flush bản ghi COMMIT rồi mới báo client. Bài này mổ cách PostgreSQL hiện thực nó.
1. Analogy — sổ giao ca của thủ kho
Mỗi lần xuất nhập, thủ kho không chạy ra kệ ngay mà ghi một dòng vào sổ giao ca, ký rồi mới báo khách "xong". Cuối ca anh xếp kệ một lượt và gạch vạch "tới đây kệ đã khớp sổ". Anh ngất giữa ca thì người thay ca làm lại các dòng sau vạch.
| Kho hàng | PostgreSQL |
|---|---|
| Sổ giao ca, chỉ viết nối vào cuối | WAL trong pg_wal/ |
| Số dòng trong sổ | LSN, vị trí byte trong WAL |
| Kệ hàng rải khắp kho | Page dữ liệu 8 kB của bảng và index |
| Ký tên rồi mới báo khách | COMMIT chờ fsync WAL tới bản ghi commit |
| Vạch "kệ đã khớp sổ" | Checkpoint và điểm redo |
| Người thay ca làm lại từ vạch | Crash recovery (redo) |
2. Vì sao COMMIT không ghi thẳng page dữ liệu?
Cách ngây thơ là COMMIT thì ghi mọi page vừa sửa rồi fsync. Vướng hai chuyện. Thứ nhất là ghi ngẫu nhiên. Một UPDATE sửa ít nhất một page heap (file chứa các dòng của bảng), và nếu không phải HOT update (kiểu update không cần đụng index, bài MVCC kể kỹ) thì sửa thêm một page lá trong mỗi index. Bảng năm index là sáu page nằm ở sáu chỗ trên đĩa, cho một dòng. Thiết bị phạt theo số lần nhảy chỗ chứ không theo số byte.
Thứ hai, chí mạng hơn, là page ghi dở (torn page). PostgreSQL ghi mỗi lần 8192 byte, tức 16 sector 512 byte, còn ổ đĩa chỉ nguyên tử ở mức sector. Mất điện giữa chừng để lại page nửa cũ nửa mới, và chẳng còn gì để đối chiếu.
WAL gỡ vấn đề thứ nhất. Theo tài liệu PostgreSQL, server chỉ ghi thay đổi xuống data file sau khi bản ghi WAL mô tả nó đã flush xuống bộ nhớ bền, và WAL ghi tuần tự nên fsync nó rẻ. Với page ghi dở thì WAL một mình chưa đủ: mục 5 giải thích phần còn thiếu.
3. Một bản ghi WAL đi từ UPDATE tới COMMIT ra sao?
Mỗi bản ghi WAL được nối vào cuối log và nhận một LSN (Log Sequence Number): độ lệch byte trong WAL, tăng đơn điệu, dạng 0/2F34F60. WAL nằm trong pg_wal/ thành các segment file, mặc định 16 MB mỗi file.
flowchart TB U["UPDATE sửa page trong shared_buffers (RAM)"] --> W["Nối bản ghi vào WAL buffer, nhận LSN"] W --> L["Ghi LSN đó lên header page (pd_lsn)"] L --> C["COMMIT: nối bản ghi commit"] C --> F["fsync WAL tới LSN của bản ghi commit"] F --> OK["Trả COMMIT cho client"] OK -. "lúc nào đó sau" .-> P["Ghi page bẩn xuống data file"]
Mắt xích biến chữ write-ahead thành sự thật là pd_lsn: header mỗi page heap hay index giữ LSN của bản ghi WAL cuối cùng đã sửa page, và buffer manager chỉ được đẩy page bẩn xuống data file khi WAL đã flush ít nhất tới LSN đó.
Page dữ liệu có thể nằm trong RAM thêm vài phút, nên cú docker kill không làm mất gì: shared_buffers bay theo tiến trình, nhưng WAL của 50000 dòng đã nằm trong pg_wal/. Thí nghiệm đó chỉ giết tiến trình; mất điện cả máy thì fsync lúc commit mới là thứ giữ WAL (xem fsync và độ bền).
Đo một bản ghi WAL thật:
SELECT pg_current_wal_insert_lsn() AS truoc \gset
UPDATE tasks SET status = 'done' WHERE id = 43;
SELECT pg_current_wal_insert_lsn() AS sau \gset
SELECT pg_wal_lsn_diff(:'sau', :'truoc');
Đoán trước: con số này lớn hơn tổng hai bản ghi dưới đây bao nhiêu?
$ pg_waldump -p $PGDATA/pg_wal -s 0/1825400 -e 0/1825470
rmgr: Heap len (rec/tot): 72/72, tx: 741, desc: HOT_UPDATE old_off: 43, new_off: 144
rmgr: Transaction len (rec/tot): 34/34, tx: 741, desc: COMMIT 2026-10-06 04:36:50 UTC
Kết quả 176 byte. Cửa sổ chỉ bao hai bản ghi sau: HOT_UPDATE 72 byte cộng COMMIT 34 byte, căn lề 8 byte thành 112. 64 byte còn lại thuộc bản ghi Heap2 PRUNE (rec 59, căn lề thành 64) dọn page, đứng ngay trước và ngoài cửa sổ. COMMIT chờ hơn trăm byte nối vào cuối file thay vì một page 8 kB giữa bảng.
4. Checkpoint quyết định recovery bắt đầu từ đâu?
Page không bao giờ được ghi thì WAL phải giữ mọi thứ từ ngày tạo database. Checkpoint chặn chuyện đó: tiến trình checkpointer flush hết page bẩn xuống data file rồi ghi một checkpoint record vào WAL. Record này chỉ ra điểm redo; mọi thay đổi trước đó chắc chắn đã nằm trong data file, nên segment WAL cũ hơn được tái sử dụng hoặc xoá.
Khi crash, server đọc pg_control (phần dữ liệu thực của file nhỏ hơn một sector 512 byte nên ghi nguyên tử), tìm checkpoint gần nhất, rồi quét WAL tiến tới từ điểm redo, đúng dòng redo starts at 0/218DCB8 ở đầu bài. Page nào có pd_lsn đã bằng hoặc vượt LSN của bản ghi đang xét thì thay đổi đã có sẵn, bỏ qua.
Checkpoint tự chạy mỗi checkpoint_timeout (mặc định 5 phút) hoặc khi WAL tiến gần max_wal_size (mặc định 1 GB), cái nào tới trước. max_wal_size là giới hạn mềm, và server kích checkpoint sớm hơn mốc đó để chừa chỗ cho WAL sinh ra trong lúc checkpoint chạy. Hai núm kéo hai chiều: checkpoint thưa thì ít flush nhưng recovery phải redo nhiều WAL hơn; checkpoint dày thì recovery nhanh nhưng WAL phình, lý do nằm ở mục sau. Checkpoint do WAL đầy dày hơn 30 giây một lần thì log báo checkpoints are occurring too frequently.
Thử ngẫmmột job nạp dữ liệu đêm sinh 20 GB WAL trong 15 phút với max_wal_size mặc định. Checkpoint chạy theo nhịp nào, và recovery sau crash giữa job ra sao?
5. Hai UPDATE giống hệt nhau trên một page: WAL sinh ra bao nhiêu?
Ngay sau CHECKPOINT, bạn UPDATE hai dòng id = 42 rồi id = 43 cùng một page heap. Mỗi câu sinh bao nhiêu byte WAL? Viết ra hai con số trước khi đọc tiếp.
Bản ghi HOT_UPDATE thường chỉ mô tả phần thay đổi (72 byte), nên chỉ làm lại được trên một page còn nguyên vẹn. Áp 72 byte lên page ghi dở vẫn ra rác. Vì vậy khi full_page_writes = on (mặc định), lần đầu tiên một page bị sửa sau mỗi checkpoint, bản ghi WAL mang theo nguyên ảnh page (full-page image). Recovery gặp nó thì khôi phục cả page từ ảnh. Làm ở lần đầu là đủ, vì redo luôn bắt đầu từ checkpoint.
CHECKPOINT;
UPDATE tasks SET status = 'done' WHERE id = 42;
UPDATE tasks SET status = 'done' WHERE id = 43;
rmgr: Heap len (rec/tot): 65/7453, tx: 740, desc: HOT_UPDATE ... blkref #0: ... FPW
rmgr: Heap len (rec/tot): 72/72, tx: 741, desc: HOT_UPDATE ...
7453 byte so với 72, hơn 100 lần cho cùng một thao tác. FPW đánh dấu bản ghi có ảnh page. Ảnh nhỏ hơn 8192 vì PostgreSQL cắt bỏ "lỗ" toàn số 0 giữa page trước khi ghi.
Vòng này tự khuếch đại: checkpoint càng dày thì càng nhiều "lần sửa đầu", WAL càng phình, càng sớm chạm max_wal_size, lại kích checkpoint dày hơn. Tắt full_page_writes thì nhanh hơn nhưng có thể hỏng dữ liệu âm thầm; tài liệu chỉ gợi ý khi filesystem tự chống ghi dở, như ZFS.
6. synchronous_commit đổi độ bền lấy độ trễ ra sao?
Với giao dịch nhỏ, lần chờ fsync chiếm phần lớn thời gian COMMIT. Tham số synchronous_commit quyết định COMMIT chờ tới đâu:
| Giá trị | COMMIT chờ gì | Mất gì nếu crash |
|---|---|---|
off | không chờ flush WAL | vài giao dịch cuối, tối đa 3 × wal_writer_delay |
on (mặc định) | WAL flush xuống đĩa; có standby đồng bộ thì chờ cả standby flush | chỉ mất khi primary và mọi standby đồng bộ cùng hỏng |
Các mức remote_write và remote_apply chỉ có nghĩa khi có standby đồng bộ; bài Single-leader replication nói về đánh đổi sync/async đó.
Với off, tiến trình WAL writer cứ mỗi wal_writer_delay (mặc định 200 ms) flush WAL một lần, và tài liệu nói cửa sổ rủi ro tối đa gấp ba, khoảng 600 ms. Một lần đo trên laptop (PostgreSQL 16.14 trong Docker, pgbench một client, mỗi giao dịch một INSERT, 2026-10-06):
synchronous_commit = on latency average = 0.504 ms tps = 1982
synchronous_commit = off latency average = 0.019 ms tps = 52178
Độ trễ fsync phụ thuộc thiết bị, nên đừng chép con số tuyệt đối. Điều đáng nhớ là off mất chứ không hỏng: recovery replay tới bản ghi cuối đã flush theo đúng thứ tự commit, nên database về trạng thái nhất quán như thể vài giao dịch cuối bị abort. Tham số này đặt được theo từng giao dịch:
BEGIN; -- su kien analytics: mat vai tram ms cuoi khi crash van chap nhan duoc
SET LOCAL synchronous_commit = off;
INSERT INTO page_view (user_id, path, viewed_at) VALUES (42, '/tasks', now());
COMMIT;
Thử ngẫmhệ thống gửi email "đã thanh toán" ngay khi COMMIT trả về. Nếu giao dịch đó chạy synchronous_commit = off và crash rơi vào cửa sổ 600 ms, khách thấy gì?
7. Pitfall: đừng nhầm fsync = off với synchronous_commit = off
❌ Nhầm 1: đặt fsync = off trong postgresql.conf để tăng tốc production.
fsync = off tắt mọi đồng bộ ghi, kể cả data file và checkpoint. OS hoặc phần cứng sập thì thứ tự "WAL trước, page sau" mất, và theo tài liệu database có thể hỏng không khôi phục được. Chỉ tắt khi dựng lại được toàn bộ database từ nguồn ngoài.
✅ Cần commit nhanh thì dùng synchronous_commit = off cho đúng những giao dịch chịu mất được, ví dụ ALTER ROLE analytics_writer SET synchronous_commit = off;.
❌ Nhầm 2: WAL sinh nhiều sau mỗi checkpoint nên tắt full_page_writes trên ext4, tức bỏ đúng lớp chống page ghi dở.
✅ Tăng max_wal_size để checkpoint thưa lại, chấp nhận recovery lâu hơn; hoặc bật wal_compression để nén ảnh page, trả bằng CPU.
8. Liên hệ các bài khác
- MVCC internals:
UPDATEghi tuple mới thay vì sửa tại chỗ; bản ghiHOT_UPDATEtrongpg_waldumpmô tả đúng thao tác đó. - VACUUM và dead tuple: VACUUM cũng sửa page và sinh full-page image ở lần chạm đầu sau checkpoint, nên VACUUM lớn làm WAL vọt lên.
- B-tree internals: page split sửa nhiều page index một lúc, page nào cũng đi qua luật "WAL trước, page sau".
- Cùng ý tưởng ở track khác — WAL nối tuần tự vì đĩa phạt theo lần nhảy; xem Tuần tự vs ngẫu nhiên.
- Cùng ý tưởng ở track khác — Kafka cũng chỉ nối vào cuối log, consumer đọc theo offset; xem Log-based broker.
- PostgreSQL Docs — Write-Ahead Logging và Reliability: nguyên tắc log trước, page 8192 byte so với sector 512 byte.
- PostgreSQL Docs — Asynchronous Commit: cửa sổ rủi ro 3 ×
wal_writer_delay, khác biệt vớifsync = off. - PostgreSQL Docs — WAL Configuration và WAL Internals: checkpoint, điểm redo, LSN, segment 16 MB.
- PostgreSQL Docs — Write Ahead Log settings: định nghĩa chính thức của mọi tham số trong bài.
- src/backend/access/transam/README: luật page LSN và cách redo bỏ qua thay đổi đã có trên page.
9. Tóm tắt
- Đo WAL của một thao tác:
pg_current_wal_insert_lsn()trước và sau, trừ bằngpg_wal_lsn_diff, soi bằngpg_waldump. - Bản ghi đầu tiên chạm một page sau checkpoint mang nguyên ảnh page (7453 byte so với 72 byte), nên WAL vọt lên sau mỗi checkpoint là bình thường.
- Log báo checkpoint quá dày: tăng
max_wal_size, đừng tắtfull_page_writes. - Commit nhanh cho dữ liệu chịu mất được:
SET LOCAL synchronous_commit = off; cònfsync = offchỉ dành cho database dựng lại được từ đầu.
10. Tự kiểm tra
- Q1COMMIT đã trả về, page dữ liệu chưa được ghi xuống data file, rồi server crash. Vì sao dữ liệu vẫn còn sau khi khởi động lại?
- Q2Vì sao buffer manager phải chờ WAL flush tới pd_lsn của page rồi mới ghi page đó xuống đĩa? Bỏ luật này thì hỏng ở đâu?
- Q3Ngay sau CHECKPOINT, UPDATE đầu tiên lên một page sinh 7453 byte WAL, UPDATE thứ hai lên cùng page chỉ 72 byte. Vì sao? Tắt full_page_writes để bỏ khoản chênh đó có an toàn không?
- Q4So sánh hậu quả khi crash giữa synchronous_commit = off và fsync = off. Vì sao một cái chỉ mất dữ liệu, cái kia có thể làm hỏng database?
- Q5Hai server giống hệt nhau, một đặt checkpoint_timeout = 30 phút, một đặt 5 phút. So sánh thời gian recovery, lượng WAL và số full-page image. Vì sao?
Bài tiếp theo: MVCC internals — vì sao SELECT không bao giờ block UPDATE
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Mini-challenge — TaskFlow dashboard 2.1s → 50ms
Bài tiếpMVCC internals — vì sao SELECT không bao giờ block UPDATE
Thảo luận & góp ý
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này