Khi bạn COMMIT một giao dịch và PostgreSQL trả về "thành công", bạn tin rằng dữ liệu đã bền — nếu server mất điện ngay sau đó, dữ liệu vẫn còn khi khởi động lại. Điều đảm bảo lời hứa này (durability — chữ D trong ACID) là WAL (Write-Ahead Log). Đây là cơ chế nền tảng nhất của độ bền trong PostgreSQL, và cũng là nền của replication và phục hồi sau sự cố.

Bài này (phần 10 loạt PostgreSQL) giải thích vì sao PostgreSQL ghi log trước khi ghi dữ liệu, và đo thật lượng WAL mỗi loại thao tác sinh ra trên pg-lab.

Nguyên tắc write-ahead

Ý tưởng cốt lõi: trước khi thay đổi trang dữ liệu (heap) trên đĩa, PostgreSQL ghi bản ghi mô tả thay đổi đó vào WAL — một log tuần tự, chỉ ghi thêm (append-only) — và fsync nó xuống đĩa. Trang dữ liệu thực được ghi sau, theo nhịp checkpoint. Nếu server crash giữa chừng, lúc khởi động PostgreSQL đọc lại WAL và áp lại (replay) các thay đổi đã commit nhưng chưa kịp ghi vào trang dữ liệu.

WAL write-ahead log PostgreSQL: nguyên tắc write-ahead khi COMMIT ghi bản ghi thay đổi vào WAL log tuần tự cộng fsync xuống đĩa trước, trang dữ liệu heap được ghi sau theo nhịp checkpoint, vì sao nếu crash giữa chừng đọc lại WAL áp lại replay các thay đổi không mất dữ liệu đã COMMIT durability dù trang dữ liệu chưa kịp ghi; vì sao WAL nhanh dù phải ghi 2 lần WAL ghi tuần tự append-only đĩa rất nhanh với ghi tuần tự, trang dữ liệu ghi ngẫu nhiên chậm nhưng được gom lại ghi sau checkpoint nên COMMIT chỉ chờ ghi WAL tuần tự nhanh không chờ ghi trang ngẫu nhiên; đo lượng WAL sinh ra bằng LSN SELECT pg_current_wal_lsn vị trí hiện tại trong WAL, đo trước sau một thao tác SELECT pg_size_pretty pg_wal_lsn_diff lsn_sau lsn_truoc byte WAL sinh ra

Hình 1: Write-ahead — ghi WAL (tuần tự) + fsync trước, ghi trang dữ liệu (ngẫu nhiên) sau. Crash → replay WAL để không mất dữ liệu đã commit. COMMIT chỉ chờ ghi WAL tuần tự (nhanh), không chờ ghi trang ngẫu nhiên. Đo WAL bằng pg_wal_lsn_diff.

-- LSN = vi tri hien tai trong WAL; do truoc/sau 1 thao tac de biet WAL sinh ra
SELECT pg_current_wal_lsn();
-- ... chay thao tac ...
SELECT pg_size_pretty(pg_wal_lsn_diff(lsn_sau, lsn_truoc));  -- byte WAL

Vì sao write-ahead nhanh dù phải ghi hai lần (WAL + trang dữ liệu)? Vì WAL ghi tuần tự (append-only) — đĩa rất nhanh với ghi tuần tự — còn trang dữ liệu ghi ngẫu nhiên (chậm) được gom lại và ghi sau theo checkpoint. COMMIT chỉ cần chờ ghi WAL tuần tự, không chờ ghi trang ngẫu nhiên. Đổi ghi-ngẫu-nhiên-ngay thành ghi-tuần-tự-ngay + ghi-ngẫu-nhiên-sau là một thắng lợi lớn.

Đo thật: WAL sinh ra bởi mỗi thao tác

Mình đo lượng WAL (pg_wal_lsn_diff) mỗi thao tác 10.000 hàng tạo ra trên pg-lab. Kết quả thật:

Bảng kết quả đo thật WAL sinh ra bởi mỗi thao tác trên pg-lab postgresql 16.15 10.000 hàng pg_wal_lsn_diff WAL segment 16 MB: lượng WAL theo loại thao tác cùng 10.000 hàng, INSERT 10.000 hàng 861 kB mỗi hàng mới 1 bản ghi WAL, COPY 10.000 hàng bulk 357 kB bulk hiệu quả hơn INSERT 2,4 lần, UPDATE 10.000 hàng 1.645 kB nặng nhất ghi cả bản cũ cộng mới, DELETE 10.000 hàng 572 kB đánh dấu xóa, SELECT chỉ đọc 0 bytes đọc không sinh WAL; cấu hình ảnh hưởng độ bền vs tốc độ fsync on full_page_writes on synchronous_commit on wal_level logical. Badge output thật màu xanh

Hình 2: Kết quả thật. INSERT 861 kB, COPY (bulk) 357 kB cho cùng dữ liệu, UPDATE 1.645 kB (nặng nhất), DELETE 572 kB, SELECT 0 bytes. Mọi thay đổi sinh WAL; đọc thì không.

  • INSERT 10.000 hàng: 861 kB — mỗi hàng mới sinh một bản ghi WAL.
  • COPY 10.000 hàng (cùng dữ liệu): 357 kB — COPY sinh ít WAL hơn INSERT ~2,4 lần. Bulk load gom hiệu quả hơn. Đây là một lý do nữa (ngoài throughput) để dùng COPY/bulk khi nạp dữ liệu lớn.
  • UPDATE 10.000 hàng: 1.645 kB — nặng nhất. Vì MVCC (bài 1), UPDATE tạo phiên bản mới và giữ bản cũ, nên WAL phải ghi cả thông tin cho cả hai, cộng có thể full-page write.
  • DELETE 10.000 hàng: 572 kB — đánh dấu xóa.
  • SELECT: 0 bytes — đọc không sinh WAL. Chỉ thay đổi mới cần ghi log; đọc là "miễn phí" về mặt durability.

Đánh đổi cần cân nhắc

synchronous_commit đổi độ bền lấy tốc độ COMMIT. Mặc định synchronous_commit=on nghĩa là COMMIT chờ WAL được fsync xuống đĩa mới trả về — đảm bảo dữ liệu đã bền. Đặt off thì COMMIT trả về ngay sau khi ghi WAL vào bộ đệm (chưa fsync), nhanh hơn nhiều, nhưng nếu crash trong khoảng ~vài trăm ms đó, vài giao dịch cuối có thể mất (dù database vẫn nhất quán, không hỏng). Đây là "bền mềm" — hợp cho dữ liệu chịu được mất một ít (log, analytics, dữ liệu dựng lại được), không cho giao dịch tài chính. Một núm chỉnh rất hiệu quả cho workload ghi nhiều mà chấp nhận rủi ro nhỏ.

fsync=off nhanh nhất nhưng nguy hiểm — gần như không bao giờ dùng trên dữ liệu thật. Tắt fsync khiến PostgreSQL không ép WAL/dữ liệu xuống đĩa vật lý, nhanh hơn đáng kể nhưng nếu crash, dữ liệu có thể hỏng (corrupt), không chỉ mất vài giao dịch. Chỉ dùng cho dữ liệu hoàn toàn dựng lại được (ví dụ đang nạp dữ liệu ban đầu mà sẽ làm lại từ đầu nếu hỏng), và phải bật lại sau. Khác với synchronous_commit=off (chỉ mất giao dịch cuối, không hỏng), fsync=off có thể phá hủy cả database.

full_page_writes bảo vệ chống "torn page" — tốn WAL nhưng cần thiết. Khi một trang bị sửa lần đầu sau checkpoint, PostgreSQL ghi toàn bộ trang vào WAL (không chỉ thay đổi nhỏ), để phòng trường hợp crash làm trang bị ghi dở (torn page — một phần cũ, một phần mới). Điều này làm WAL nhiều hơn (nhất là ngay sau checkpoint), và là một phần lý do UPDATE sinh nhiều WAL. Tắt full_page_writes giảm WAL nhưng chỉ an toàn nếu hệ thống lưu trữ đảm bảo ghi nguyên tử theo trang (một số SAN/filesystem). Mặc định on là an toàn; đừng tắt trừ khi chắc chắn về tầng lưu trữ.

Ba ý mang về

  1. WAL ghi log trước, ghi dữ liệu sau — đó là nền của độ bền và phục hồi. Mọi thay đổi vào WAL (tuần tự, fsync) trước khi vào trang dữ liệu; crash → replay WAL để không mất giao dịch đã commit. WAL tuần tự nhanh nên COMMIT không phải chờ ghi trang ngẫu nhiên. Đây cũng là nền của replication và point-in-time recovery.
  2. Mọi thay đổi sinh WAL; đọc thì không — và lượng WAL khác nhau theo thao tác. Đo thật (10.000 hàng): INSERT 861 kB, COPY 357 kB (bulk hiệu quả hơn 2,4×), UPDATE 1.645 kB (nặng nhất do MVCC ghi cả bản cũ+mới), DELETE 572 kB, SELECT 0 bytes. Dùng pg_wal_lsn_diff để đo.
  3. Cấu hình WAL là đánh đổi độ bền vs tốc độ. synchronous_commit=off nhanh hơn nhưng crash mất vài giao dịch cuối (không hỏng); fsync=off nhanh nhất nhưng crash có thể HỎNG dữ liệu (chỉ cho dữ liệu dựng lại được); full_page_writes chống torn page (tốn WAL nhưng an toàn). Mặc định (tất cả on) an toàn nhất — chỉ nới khi hiểu rõ rủi ro.

Nguồn

Phần sau ta dùng SKIP LOCKED để xây một hàng đợi công việc ngay trong SQL: nhiều worker lấy việc đồng thời mà không đụng nhau, không khóa chờ — đo thật nhiều worker cùng chạy.