Bài trước ta thấy MVCC khiến mỗi UPDATE/DELETE để lại dòng chết. Bài này đo hệ quả tích lũy của điều đó: bloat — bảng (và index) phình to vì chứa đầy chỗ trống từ dòng chết. Bloat là một trong những vấn đề vận hành âm thầm nhất: một bảng ghi nhiều có thể chiếm gấp nhiều lần dung lượng thật cần, làm chậm mọi truy vấn quét, và tốn đĩa — mà không có lỗi nào báo. Bài này đo thật bloat hình thành ra sao, cách phát hiện, và ba mức dọn với ba đánh đổi.
Bloat: file to nhưng phần lớn là chỗ trống
Điểm mấu chốt cần hiểu: khi bạn DELETE (hay UPDATE) nhiều dòng, PostgreSQL không trả không gian về hệ điều hành. Dòng bị xóa trở thành dòng chết, và ngay cả sau VACUUM (biến dòng chết thành chỗ trống tái dùng), file bảng vẫn giữ nguyên kích thước. Kết quả: một file to đầy chỗ trống bên trong.
-- bảng 2 triệu dòng, xóa 95%
DELETE FROM bl WHERE id % 20 <> 0; -- xóa 1,9 triệu dòng
-- pg_relation_size vẫn 269 MB dù chỉ còn 100.000 dòng sống!

Hình 1: DELETE/UPDATE để lại dòng chết; file bảng không nhỏ lại dù xóa phần lớn dòng. Phát hiện bằng pgstattuple; dọn bằng VACUUM (không thu nhỏ), VACUUM FULL (thu nhỏ nhưng khóa), hoặc pg_repack (online).
Đo thật: xóa 95% nhưng file không nhỏ
Trên bảng 2 triệu dòng, xóa 95% và đo:

Hình 2: Sau khi xóa 95% dòng, file vẫn 269 MB (chỉ còn 100.000 dòng sống), free_percent 77,6% — 77% file là chỗ trống. VACUUM giữ nguyên 269 MB; VACUUM FULL thu về 13 MB nhưng khóa. Bảng bloat đọc 34.483 trang (31 ms) so với bảng gọn 1.725 trang (8 ms).
- Sau DELETE 95%: file vẫn 269 MB (không đổi), chỉ còn 100.000 dòng sống,
free_percent77,6% — tức 77% file là chỗ trống. Đây là bloat.
Phát hiện bloat
Cách chính xác nhất là extension pgstattuple:
CREATE EXTENSION pgstattuple;
SELECT free_percent, dead_tuple_percent FROM pgstattuple('bl');
-- free_percent = 77.6 → 77% file là chỗ trống
pgstattuple quét bảng để cho con số chính xác (hơi tốn với bảng lớn). Cách ước lượng nhanh không cần quét là nhìn pg_stat_user_tables:
SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname='bl';
Tỉ lệ n_dead_tup / (n_live_tup + n_dead_tup) cao là dấu hiệu cần chú ý. Nhiều công cụ giám sát dùng công thức ước lượng dựa trên thống kê này để cảnh báo bloat.
Bloat làm chậm đọc
Bloat không chỉ tốn đĩa — nó làm chậm mọi truy vấn quét, vì PostgreSQL phải đọc cả các trang trống. Đo thật cùng 100.000 dòng sống:
- Bảng bloat (269 MB):
count(v)đọc 34.483 trang, 31 ms. - Bảng gọn sau VACUUM FULL (13 MB):
count(v)đọc 1.725 trang, 8 ms.
Bảng bloat đọc ~20 lần nhiều trang hơn (quét cả chỗ trống) và chậm ~3,7 lần — cho cùng lượng dữ liệu thật. Với bảng lớn hơn bộ nhớ, khoảng cách này còn giãn ra vì phải đọc trang trống từ đĩa.
Ba cách dọn, ba đánh đổi
VACUUM: dọn dòng chết thành chỗ trống tái dùng. Không khóa đọc/ghi, nhưng không thu nhỏ file — chỉ ngăn bảng phình thêm. Đây là công cụ phòng ngừa hằng ngày (autovacuum làm tự động).VACUUM FULL: viết lại toàn bộ bảng gọn gàng, trả chỗ về hệ điều hành (269 MB → 13 MB, đo thật 201 ms). Nhưng nó khóa ACCESS EXCLUSIVE — chặn mọi truy vấn tới bảng suốt thời gian chạy. Không dùng được trên bảng nóng ở production.pg_repack: extension thu nhỏ bảng online, gần như không khóa — giải pháp production cho bloat nặng. Cần cài đặt riêng.REINDEX(CONCURRENTLY): index cũng bị bloat;REINDEX CONCURRENTLYdựng lại index gọn mà không khóa (đã bàn ở bài index bloat trước).
Đánh đổi cần cân nhắc
Phòng bệnh hơn chữa: autovacuum đều đặn. Cách tốt nhất chống bloat không phải dọn sau, mà là để autovacuum chạy đủ thường xuyên để dòng chết được tái dùng trước khi tích lũy. Bảng ghi rất nhiều có thể cần hạ autovacuum_vacuum_scale_factor để vacuum kích hoạt sớm hơn — chủ đề bài sau.
VACUUM FULL chỉ khi có cửa sổ bảo trì. Vì nó khóa toàn bảng, chỉ dùng khi có thể chịu downtime (ban đêm, bảng ít dùng). Với bảng 24/7, pg_repack là lựa chọn đúng dù phải cài thêm.
Một chút bloat là bình thường và tốt. Đừng ám ảnh đưa free_percent về 0. Một lượng chỗ trống vừa phải cho phép UPDATE tương lai dùng HOT (ở lại trong trang) và tránh phình lại ngay. fillfactor < 100 thậm chí cố ý chừa chỗ trống. Chỉ can thiệp khi bloat cao bất thường (ví dụ free_percent > 40-50% trên bảng lớn).
Ba ý mang về
- Bloat là bảng phình vì dòng chết mà file không nhỏ lại: đo thật, xóa 95% dòng nhưng file vẫn 269 MB với
free_percent77,6% — VACUUM biến dòng chết thành chỗ trống tái dùng nhưng không trả về hệ điều hành. - Bloat làm chậm đọc vì quét cả trang trống: đo thật,
count(v)trên bảng bloat đọc 34.483 trang (31 ms) so với 1.725 trang (8 ms) trên bảng gọn — cùng 100.000 dòng sống, chậm ~3,7 lần. - Ba cách dọn với ba đánh đổi: VACUUM (không khóa, không thu nhỏ), VACUUM FULL (thu về 13 MB nhưng khóa ACCESS EXCLUSIVE), pg_repack (online, cần extension) — phát hiện bằng
pgstattuple, phòng bằng autovacuum đều đặn.
Phần sau ta đi vào chính công cụ chống bloat: Phần sau mổ xẻ VACUUM — nó dọn dead tuple ra sao, khác gì VACUUM FULL, vai trò của autovacuum, và cách đọc VERBOSE để biết nó làm gì.