Khi một INSERT báo thành công, dữ liệu đã nằm an toàn trên đĩa chưa? Trực giác nói rồi — COMMIT xong nghĩa là ghi xong. Nhưng khi đo, tôi thấy điều bất ngờ: dữ liệu vừa ghi chưa nằm trong file dữ liệu trên đĩa, mà vẫn lơ lửng trong RAM. Chúng chỉ thật sự xuống file dữ liệu muộn hơn, tất cả cùng một lúc, ở một sự kiện gọi là checkpoint — và cú đẩy hàng loạt đó là một cơn bùng I/O có thể làm chậm mọi thứ đúng lúc nó xảy ra. Bài này đo cả hai: dữ liệu nằm ở đâu sau COMMIT, và checkpoint làm gì.

Checkpoint và I/O đột biến

Ghi không xuống file ngay

Khi bạn ghi vào PostgreSQL, nó không sửa file dữ liệu trên đĩa ngay. Nó làm hai việc: một, sửa trang tương ứng trong shared_buffers (trang đó thành "bẩn" — dirty, tức đã đổi so với bản trên đĩa); hai, ghi thay đổi vào WAL và fsync WAL xuống đĩa. Như bài WAL và độ bền đã đo, chính lần fsync WAL đó — chứ không phải việc ghi file dữ liệu — là cái làm giao dịch bền: sau khi COMMIT trả về, nếu mất điện, PostgreSQL dựng lại được mọi thay đổi từ WAL.

Nghĩa là sau COMMIT, trang dữ liệu vẫn bẩn trong RAM, chưa ra file dữ liệu. Việc đẩy các trang bẩn đó xuống file dữ liệu để lại về sau — và đó là việc của checkpoint.

Cách trì hoãn này là một tối ưu quan trọng: nếu cùng một trang bị nhiều giao dịch sửa liên tiếp, PostgreSQL chỉ cần ghi WAL cho từng thay đổi (ghi tuần tự, nhanh) và đẩy trang xuống đĩa một lần ở checkpoint, thay vì ghi ngẫu nhiên xuống file dữ liệu sau mỗi lần sửa. Ghi tuần tự vào WAL rồi gộp ghi ngẫu nhiên ở checkpoint rẻ hơn nhiều so với ghi ngẫu nhiên tức thì mỗi lần.

Một lần tôi đo hớ: COMMIT xong, data vẫn chưa ra đĩa

Tôi ghi 2 triệu hàng vào một bảng, để giao dịch commit xong xuôi, rồi đo số trang bẩn trong shared_buffers bằng pg_buffercache:

ngay sau khi ghi + COMMIT:  16320 trang BẨN trong shared_buffers (~127MB)

Mười sáu nghìn trang — khoảng 127MB dữ liệu — vẫn nằm bẩn trong RAM, chưa hề chạm file dữ liệu trên đĩa. Đây đúng là chỗ tôi hiểu sai: tôi tưởng COMMIT thành công nghĩa là dữ liệu đã ở trong file trên đĩa. Không phải. COMMIT chỉ đảm bảo WAL đã bền; file dữ liệu được cập nhật sau. Đây cũng là lý do các trang bẩn đó vẫn phục vụ đọc bình thường: một truy vấn đọc sẽ thấy phiên bản mới ngay trong shared_buffers (như bài cache hai tầng đã đo, đọc luôn tìm trong shared_buffers trước), nên việc file đĩa chưa cập nhật không ảnh hưởng tính đúng — chỉ ảnh hưởng thời điểm I/O ghi. Nếu mất điện lúc này, dữ liệu không mất — nhưng không phải vì nó ở trong file dữ liệu, mà vì PostgreSQL sẽ phát lại WAL khi khởi động lại để tái tạo các trang này.

Rồi tôi chạy CHECKPOINT (thường PostgreSQL tự chạy định kỳ; ở đây tôi gọi tay để đo):

CHECKPOINT:  79 ms
trang bẩn sau đó:  16320 -> 0

Checkpoint đẩy toàn bộ 16320 trang bẩn xuống file dữ liệu trong 79ms, rồi số trang bẩn về 0. Bảy mươi chín mili giây đó là một cơn bùng I/O: PostgreSQL ghi 127MB xuống đĩa gần như cùng lúc. Trên một hệ thống đang phục vụ truy vấn, cơn bùng này cạnh tranh băng thông đĩa với mọi thứ khác — đó là lý do đôi khi thấy độ trễ tăng vọt theo chu kỳ, đúng nhịp checkpoint. Khác với chi phí fsync mỗi commit ở bài gom commit — thứ rải đều trên từng giao dịch — cơn bùng checkpoint dồn vào một khoảnh khắc, nên nó gây giật rõ hơn: mọi thứ chạy mượt rồi bỗng một nhịp chậm hẳn.

Cái gì kích checkpoint, và vì sao nhỏ lại tệ

Checkpoint chạy vì một trong hai lý do, và pg_stat_bgwriter đếm riêng: timed (hết checkpoint_timeout, mặc định 5 phút) và requested (WAL tích lũy vượt max_wal_size). Con số requested cao là dấu hiệu WAL đầy quá nhanh, buộc checkpoint chạy trước hạn giờ.

Tôi đo điều này bằng cách đặt max_wal_size = 48MB (nhỏ) rồi ghi 5 triệu hàng:

checkpoints_req:  4 -> 32   (28 checkpoint 'requested' trong một lần ghi)

Hai mươi tám checkpoint bị kích trong một thao tác ghi, vì WAL liên tục chạm trần 48MB. Mỗi checkpoint là một cơn bùng I/O. Đây là nghịch lý về checkpoint: nó cần thiết (không có nó, WAL phình vô hạn và khôi phục sau sự cố mất rất lâu), nhưng chạy quá thường xuyên thì biến việc đẩy dữ liệu thành một chuỗi spike liên miên, làm giật hiệu năng. max_wal_size quá nhỏ chính là nguyên nhân phổ biến của những cơn giật đó.

Làm phẳng cơn bùng

PostgreSQL có một van để giảm spike: checkpoint_completion_target (mặc định 0.9). Thay vì đẩy tất cả trang bẩn nhanh nhất có thể, checkpoint trải việc ghi ra trong phần lớn khoảng cách tới checkpoint kế tiếp — cùng lượng dữ liệu, nhưng dàn mỏng theo thời gian nên đỉnh I/O thấp hơn. Kết hợp với max_wal_size đủ lớn (để checkpoint thưa hơn, chạy theo giờ chứ không phải vì WAL đầy), đó là cách chuẩn để một CSDL ghi nhiều không bị giật theo nhịp checkpoint. Đánh đổi là: max_wal_size lớn hơn nghĩa là WAL giữ nhiều hơn giữa hai checkpoint, tốn thêm đĩa cho thư mục WAL và khiến khôi phục sau sự cố lâu hơn (phải phát lại nhiều WAL hơn). Như mọi tham số điều chỉnh khác trong sê-ri, đây là một con số cần cân theo tải thật, không có giá trị đúng cho mọi hệ thống.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: hiểu đúng "đã ghi" nghĩa là gì. COMMIT thành công đảm bảo độ bền (qua WAL), không đảm bảo dữ liệu đã ở trong file dữ liệu. Điều này quan trọng khi suy luận về khôi phục, sao lưu file thô, hay hành vi lúc mất điện: cái bảo vệ bạn là WAL, và file dữ liệu bắt kịp về sau ở checkpoint. Đừng nhầm "commit nhanh" thành "đã ghi hết xuống đĩa".

Hệ quả thứ hai: nếu thấy độ trễ giật theo chu kỳ, nghi checkpoint. Con số mang theo: ghi vào PostgreSQL tạo trang bẩn trong shared_buffers + WAL (đo được 16320 trang bẩn ~127MB sau khi COMMIT 2 triệu hàng, chưa ra file data); checkpoint đẩy tất cả xuống đĩa một lúc (79ms, bẩn về 0) — một cú bùng I/O; max_wal_size nhỏ khiến checkpoint 'requested' dồn dập (28 lần khi ghi 5 triệu hàng), spike liên miên. Làm phẳng bằng max_wal_size lớn hơn + checkpoint_completion_target. Khi một CSDL ghi nhiều bị giật đều đặn, xem pg_stat_bgwriter: checkpoints_req cao là manh mối max_wal_size quá nhỏ.

Thử ba mươi giây

Trên một PostgreSQL, chạy SELECT checkpoints_timed, checkpoints_req, buffers_checkpoint FROM pg_stat_bgwriter; để xem hệ thống đã chạy bao nhiêu checkpoint theo giờ so với theo WAL đầy — checkpoints_req lớn hơn checkpoints_timed nhiều nghĩa là WAL đầy quá nhanh, cân nhắc tăng max_wal_size. Nếu có pg_buffercache, ghi một mẻ lớn (INSERT INTO t SELECT ... FROM generate_series(1,1000000)), rồi ngay lập tức chạy SELECT count(*) FROM pg_buffercache WHERE isdirty; — bạn sẽ thấy hàng nghìn trang bẩn dù đã COMMIT. Chạy CHECKPOINT; rồi đếm lại: con số về gần 0. Nửa phút đó cho bạn thấy tận mắt khoảng cách giữa "đã commit" và "đã nằm trong file trên đĩa".