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!

Ảnh chụp đoạn mã SQL nền tối minh hoạ bloat bảng phình vì dòng chết cách phát hiện và dọn, bloat file to nhưng phần lớn là chỗ trống MVCC để lại dòng chết sau mỗi UPDATE DELETE xóa 95 phần trăm dòng file không nhỏ lại chỉ toàn chỗ trống bên trong DELETE FROM bl WHERE id phần trăm 20 khác 0 xóa 95 phần trăm pg_relation_size vẫn 269 MB dù chỉ còn 100000 dòng sống, phát hiện pgstattuple cho tỉ lệ chỗ chết trống CREATE EXTENSION pgstattuple SELECT free_percent dead_tuple_percent FROM pgstattuple bl free_percent bằng 77.6 tức 77 phần trăm file là chỗ trống bloat ước lượng nhanh không cần quét n_dead_tup chia n_live_tup SELECT n_live_tup n_dead_tup FROM pg_stat_user_tables WHERE relname bằng bl, dọn ba mức ba đánh đổi VACUUM dọn dead thành free tái dùng file giữ nguyên không khóa đọc ghi VACUUM FULL viết lại bảng gọn trả chỗ về OS nhưng khóa ACCESS EXCLUSIVE pg_repack thu nhỏ online gần như không khóa cần cài extension REINDEX dọn bloat của index dùng CONCURRENTLY để không khóa, VACUUM FULL mạnh nhưng khóa toàn bảng VACUUM FULL bl 269 MB xuống 13 MB nhưng chặn mọi truy vấn tới bảng production dùng pg_repack thay thế để tránh downtime

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:

Ảnh chụp bảng kết quả đo thật nền tối bloat bảng 2 triệu dòng xóa 95 phần trăm PostgreSQL 16 pgstattuple pg_relation_size EXPLAIN ANALYZE BUFFERS shared_buffers 128MB, sau khi xóa 95 phần trăm dòng file không nhỏ lại ban đầu 2 triệu dòng khoảng 284 MB dòng sống 2 triệu free_percent khoảng 0 sau DELETE 95 phần trăm 269 MB không đổi dòng sống 100000 free_percent 77,6 77 phần trăm file là chỗ trống dấu hiệu bloat pgstattuple bl free_percent cho con số này, ba cách dọn ba đánh đổi VACUUM 269 MB không đổi không khóa chỉ dọn dead thành free tái dùng VACUUM FULL 13 MB viết lại gọn ACCESS EXCLUSIVE chặn mọi truy vấn pg_repack nhỏ như VACUUM FULL online gần như không khóa cần extension, bloat làm chậm đọc quét cả trang trống truy vấn count v bảng bloat 269 MB 100k sống 34483 trang 31 mili giây bảng gọn sau VACUUM FULL 13 MB 1725 trang 8 mili giây cùng 100k dòng sống bảng bloat đọc khoảng 20 lần nhiều trang hơn quét cả chỗ trống chậm khoảng 3,7 lần, cốt lõi DELETE UPDATE để lại dòng chết VACUUM biến chúng thành chỗ trống tái dùng nhưng file không nhỏ lại bloat bloat làm đọc chậm quét cả trang trống và tốn đĩa phát hiện bằng pgstattuple free_percent dọn bằng VACUUM FULL khóa hoặc pg_repack online phòng autovacuum đều đặn

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_percent 77,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 CONCURRENTLY dự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ề

  1. 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_percent 77,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.
  2. 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.
  3. 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ì.