cat blog/.md
Postgres VACUUM và table bloat: hiểu để không sợ khi DB phình to
Có một lần on-call lúc nửa đêm, một service đang chạy mượt bỗng query chậm gấp 5 lần — cùng một câu SQL, cùng index, cùng plan. Mở pg_stat_user_tables ra, một bảng 12 triệu row mà n_dead_tup lên gần 8 triệu. pg_relation_size trả về 47GB cho bảng đó, trong khi tính tay theo số row sống chỉ tầm 9GB. Bloat đã ăn gần 80% dung lượng, và autovacuum thì kẹt vì lock contention từ một transaction dài chạy nãy giờ.
Đó là lần đầu tôi thực sự ngồi xuống đọc kỹ về VACUUM. Trước đó tôi nghĩ “autovacuum tự lo, mình kệ” — sai. Postgres rất chăm chỉ nhưng config mặc định quá nhẹ tay cho workload write-heavy. Bài này gom lại những thứ tôi học được sau vài năm vật lộn với bloat trên Postgres production.
Vì sao Postgres cần VACUUM
Postgres dùng cơ chế MVCC (Multi-Version Concurrency Control). Khi bạn UPDATE một row, Postgres không sửa tại chỗ — nó tạo một version mới và đánh dấu version cũ là “dead”. DELETE cũng vậy, row không biến mất, chỉ bị flag.
Lý do MVCC tồn tại: nhiều transaction có thể đọc snapshot khác nhau cùng lúc mà không khóa nhau. Transaction A vừa update row, transaction B vẫn thấy version cũ vì nó bắt đầu trước đó. Đây là lý do Postgres rất tốt cho workload đọc-ghi đồng thời.
Cái giá: dead tuple tích tụ. Nếu không dọn, table sẽ phình to mãi, index cũng phình theo. Query phải scan qua nhiều page hơn dù số row hữu ích không tăng.
VACUUM làm hai việc chính:
- Đánh dấu dead tuple là không gian có thể tái sử dụng (cho
INSERT/UPDATEsau đó). - Cập nhật visibility map — bitmap cho biết page nào toàn tuple “all visible”, giúp index-only scan hoạt động.
Lưu ý quan trọng: VACUUM thường (không phải VACUUM FULL) không trả không gian về OS. Nó chỉ làm space đó được reuse trong cùng table. File trên đĩa vẫn giữ kích thước. Đây là điều rất nhiều dev hiểu nhầm.
Bloat là gì và đo nó thế nào
Bloat = không gian “lãng phí” trong table và index do dead tuple chưa được reuse + free space chưa lấp đầy.
Cách đơn giản nhất để có cái nhìn ban đầu:
SELECT
schemaname,
relname,
n_live_tup,
n_dead_tup,
ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
last_autovacuum,
last_vacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC
LIMIT 20;
Nếu dead_pct trên 20% và last_autovacuum cách đây nhiều giờ trong khi table active — có vấn đề. Nhưng đây mới là ước lượng từ stats; con số chính xác hơn cần extension hoặc query phức tạp hơn.
Với extension pgstattuple (cần superuser hoặc grant riêng):
CREATE EXTENSION pgstattuple;
SELECT * FROM pgstattuple('public.orders');
Kết quả có tuple_percent, dead_tuple_percent, free_percent. Nếu free_percent lớn và dead_tuple_percent thấp tức là VACUUM đã chạy nhưng table không co lại — chỉ VACUUM FULL mới trả lại space cho OS.
Với index, bloat dễ thấy hơn vì index B-tree không tự reuse space hiệu quả bằng heap:
SELECT
schemaname,
indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS size,
idx_scan
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 10;
Index không bao giờ được scan (idx_scan = 0) mà vẫn nặng vài GB là dấu hiệu vừa bloat vừa thừa — drop luôn.
Autovacuum hoạt động ra sao
Postgres có process autovacuum launcher đẻ ra các worker autovacuum worker. Worker scan stats và quyết định table nào cần VACUUM/ANALYZE.
Ngưỡng kích hoạt VACUUM cho một table mặc định:
threshold = autovacuum_vacuum_threshold (50)
+ autovacuum_vacuum_scale_factor (0.2) * n_live_tup
Tức là 20% số row sống. Với table 10 triệu row, autovacuum chỉ kick khi có 2 triệu dead tuple. Quá trễ cho hot table. Default này hợp với DB nhỏ, không hợp với production lớn.
Số worker mặc định: autovacuum_max_workers = 3. Nếu bạn có 50 schema, mỗi worker bận một table to, các table khác phải xếp hàng.
Cost limit: autovacuum cố tình chậm để không ăn IO của user query. autovacuum_vacuum_cost_limit = 200 (hoặc inherit từ vacuum_cost_limit). Trên SSD modern, con số này thường quá thấp.
Tune autovacuum đúng cách
Đây là setting tôi hay dùng làm điểm xuất phát cho DB tầm trung (CPU 8 core, SSD NVMe):
# postgresql.conf
autovacuum_max_workers = 6
autovacuum_naptime = 30s
# Cho phép vacuum aggressive hơn
autovacuum_vacuum_scale_factor = 0.05 # 5% thay vì 20%
autovacuum_vacuum_threshold = 1000
autovacuum_analyze_scale_factor = 0.02
# Cho phép vacuum ăn IO mạnh hơn
autovacuum_vacuum_cost_limit = 2000
autovacuum_vacuum_cost_delay = 2ms
Sau khi sửa, reload:
SELECT pg_reload_conf();
Với những table đặc biệt hot (queue table, session table, audit log…), override per-table còn hiệu quả hơn config global:
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_threshold = 5000,
autovacuum_vacuum_cost_limit = 5000
);
Table này sẽ được vacuum khi dead tuple đạt 1% — rất aggressive, nhưng đáng với một table 50M row mà update liên tục.
Khi autovacuum bị chặn
Tune xong rồi mà table vẫn bloat? Thường là một trong các nguyên nhân sau:
Long-running transaction. Một transaction mở từ 30 phút trước giữ snapshot — VACUUM không thể dọn tuple sinh ra sau thời điểm đó vì transaction kia có thể còn cần nhìn thấy. Query đáng nghi:
SELECT
pid,
now() - xact_start AS duration,
state,
query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 10;
Transaction idle in transaction chạy hàng giờ là kẻ thù số một của vacuum. Thường do app quên commit/rollback sau khi xử lý xong, hoặc connection pool giữ session quá lâu.
Replication slot không tiêu thụ. Logical replication slot không advance khiến Postgres giữ lại WAL và snapshot. Check:
SELECT slot_name, active, restart_lsn,
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS lag_bytes
FROM pg_replication_slots;
Slot inactive nhiều ngày → drop nó đi nếu không còn dùng.
Lock contention. Autovacuum cần SHARE UPDATE EXCLUSIVE lock. Bình thường nó nhường khi có DDL chờ, nhưng VACUUM FULL thì khác — block hết.
VACUUM FULL: khi nào và đừng
VACUUM FULL viết lại nguyên table sang một file mới, không có dead tuple, rồi swap. Nó trả space về OS nhưng:
- Cần
ACCESS EXCLUSIVElock — không ai đọc/ghi được trong lúc chạy. - Cần đủ disk free ít nhất bằng size table hiện tại (vì copy ra file mới).
- Với table to, có thể chạy nhiều giờ.
Nên gần như không bao giờ chạy VACUUM FULL trên production live. Thay vào đó, dùng pg_repack hoặc pg_squeeze — extension làm tương tự nhưng online (chỉ lock ngắn ở cuối):
pg_repack -h localhost -d mydb -t orders --no-superuser-check
pg_repack tạo bản copy trong khi user vẫn dùng table, sync change qua trigger, rồi swap nhanh ở cuối. Tốn thêm IO + disk tạm nhưng không downtime.
Nếu không cài được extension, một cách “thủ công”: tạo table mới CREATE TABLE new_orders (LIKE orders INCLUDING ALL), copy data theo batch, đổi tên. Phức tạp hơn nhưng controllable.
Transaction ID wraparound — chuyện đáng sợ hơn bloat
Postgres dùng 32-bit transaction ID, sau 2 tỷ giao dịch sẽ wrap. Nếu không VACUUM kịp, Postgres sẽ dừng toàn bộ ghi để bảo vệ data — chế độ “wraparound protection”.
Một dấu hiệu trong log đáng báo động:
WARNING: database "mydb" must be vacuumed within 177009986 transactions
HINT: To avoid a database shutdown, execute a database-wide VACUUM in that database.
Lúc đó autovacuum sẽ kick VACUUM FREEZE aggressive trên các table có age(relfrozenxid) cao. Để biết tình hình:
SELECT
c.relname,
age(c.relfrozenxid) AS xid_age,
pg_size_pretty(pg_relation_size(c.oid)) AS size
FROM pg_class c
WHERE c.relkind = 'r'
ORDER BY age(c.relfrozenxid) DESC
LIMIT 10;
Default autovacuum_freeze_max_age = 200_000_000. Bình thường không phải lo, nhưng nếu thấy xid_age trên 1 tỷ — đã đến lúc paranoid.
Checklist khi gặp table chậm bất thường
Thứ tự kiểm tra tôi hay làm:
pg_stat_user_tables—n_dead_tupvsn_live_tup,last_autovacuumcách đây bao lâu.pg_stat_activity— có transaction nào treo trên 5 phút không.pg_replication_slots— slot inactive nào đang giữ WAL.- Size table và size index — index có lớn bất hợp lý so với data không.
- EXPLAIN ANALYZE — có chuyển từ index scan sang seq scan không (planner ghét table bloat vì stats lệch).
Nếu xác định bloat thật:
- Manual
VACUUM (VERBOSE, ANALYZE) tablenamethử trước. Đọc output xem có bao nhiêu tuple được dọn, còn bao nhiêu pages dead. - Nếu free space lớn mà size không giảm →
pg_repack. - Tune lại per-table autovacuum để lần sau không lặp lại.
Điều dễ quên
- Autovacuum không reset stats. Sau khi vacuum xong,
n_dead_tupcó thể vẫn cao trong vài giây trước khi stats collector cập nhật. Đừng panic vội. TRUNCATEkhông cần VACUUM — nó xóa file luôn, instant. Nhưng nó cần lock mạnh, không dùng được nếu table có FK đang được tham chiếu.pg_total_relation_size()bao gồm cả index và toast;pg_relation_size()chỉ heap. So sánh hai con số dễ nhầm.- Index bloat tệ hơn heap bloat ở chỗ B-tree không tự reuse trang giữa cây. Đôi khi
REINDEX CONCURRENTLY indexnamelà giải pháp ngắn gọn cho index đã sưng.
Tóm lại
VACUUM không phải thứ “đôi khi nhớ chạy”. Nó là một phần thiết yếu của Postgres, và config mặc định thường quá nhẹ với DB sản xuất. Hiểu MVCC sinh ra dead tuple thế nào, tune autovacuum cho aggressive vừa đủ, theo dõi long transaction và replication slot — bạn sẽ tránh được hầu hết các tình huống bloat ăn vào hiệu năng.
Còn VACUUM FULL? Chỉ chạy trong maintenance window có downtime chính thức. Production live → pg_repack. Phòng bệnh tốt hơn chữa bệnh: cấu hình tốt từ đầu, monitoring n_dead_tup và xact_start lâu, là bạn đã đi trước hầu hết các team rồi.
cat comments.log