DEV Community

VACUUM — Space Reclamation

VACUUM: dọn dead tuple để tái dùng chỗ, không trả disk cho OS

VACUUM là câu lệnh bảo trì lõi của Postgres cho không gian bên trong heap. Sau khi UPDATE/DELETE, tuple cũ vẫn nằm trên page với t_xmax được set — MVCC bắt buộc phải giữ để các snapshot cũ còn thấy. VACUUM chạy đủ trễ để đảm bảo không snapshot nào còn cần tuple đó nữa, rồi đánh dấu line pointer là LP_DEAD, cập nhật FSM (free space map) và VM (visibility map). Kết quả: chỗ trống được tái dùng cho tuple mới trong cùng file. Ngộ nhận thường gặp — và cũng là chỗ sinh incident nghiêm trọng — là tưởng VACUUM "giải phóng disk"; nó không trả file về OS. Muốn thu hồi disk phải VACUUM FULL hoặc pg_repack, và cả hai đều rewrite bảng với đặc điểm lock hoàn toàn khác nhau. Chạy VACUUM FULL giờ cao điểm trên bảng lớn là kịch bản "một câu lệnh khoá cả service vài chục phút" xuất hiện đều đặn trong postmortem.

Cơ chế hoạt động

VACUUM bình thường (không kèm FULL) là in-place: nó không rewrite bảng, không tạo file mới. Nó đọc heap page-by-page, cùng với danh sách dead tuple thu thập được, làm các việc sau theo pha (các pha này lộ ra trong view pg_stat_progress_vacuum, được tài liệu hoá trong Postgres docs "Progress Reporting"):

  1. initializing — thiết lập worker, cấp buffer dead-tuple.
  2. scanning heap — quét heap, thu thập CTID của tuple đã chết (t_xmax committed và cũ hơn xmin horizon).
  3. vacuuming indexes — với mỗi index, duyệt và xoá entry trỏ tới các CTID vừa thu thập.
  4. vacuuming heap — quay lại heap, chuyển line pointer của các CTID đó thành LP_UNUSED, cập nhật FSM cho page.
  5. cleaning up indexes — dọn dở sau vacuuming indexes.
  6. truncating heap — nếu các page cuối file hoàn toàn rỗng và acquire được ACCESS EXCLUSIVE lock ngắn, cắt bớt file. Đây là con đường duy nhất VACUUM (không FULL) có thể trả space cho OS — và chỉ áp dụng cho page trailing rỗng.
  7. performing final cleanup — flush thống kê, cập nhật pg_class.relpagesrelfrozenxid.

Lock của VACUUMSHARE UPDATE EXCLUSIVE — không block SELECT/INSERT/UPDATE/DELETE, chỉ block DDL và VACUUM khác trên cùng bảng. Đây là lý do có thể chạy trên bảng đang serving traffic.

-- VACUUM verbose in-place trên bảng đang hot
VACUUM (VERBOSE, ANALYZE) orders;

-- Xem pha đang chạy khi VACUUM còn đang lâu
SELECT pid, datname, relid::regclass AS table,
       phase, heap_blks_total, heap_blks_scanned, heap_blks_vacuumed,
       index_vacuum_count, num_dead_tuples, max_dead_tuples
FROM pg_stat_progress_vacuum;
Enter fullscreen mode Exit fullscreen mode

VACUUM FULL không làm những việc trên. Nó rewrite toàn bộ bảng vào một file mới, chỉ chép các tuple còn sống, sau đó swap relfilenode và xoá file cũ. Rewrite này yêu cầu ACCESS EXCLUSIVE lock trong toàn bộ thời gian chạy — block cả SELECT. Đổi lại, file mới compact, dung lượng thực sự giảm về đúng phần live, và index cũng được rebuild luôn. Tiến độ của VACUUM FULL (và CLUSTER) xem qua pg_stat_progress_cluster, không phải pg_stat_progress_vacuum.

FSM và VM là hai fork phụ của bảng nhỏ hơn heap nhiều lần; VACUUM giữ chúng đồng bộ. FSM cho INSERT/UPDATE biết page nào còn chỗ để tái dùng — thiếu update FSM, backend sẽ bỏ qua page vừa được vacuum sạch và tiếp tục append vào cuối file, heap phình dù bên trong thừa chỗ.

Vấn đề gặp trong production

Failure mode 1: VACUUM FULL giờ cao điểm khoá cả bảng. Kịch bản kinh điển: bảng orders bloat 60GB dù heap live chỉ 8GB. Một dev quyết định "dọn nhanh" bằng VACUUM FULL orders ngay giữa ngày.

-- Sai: dùng VACUUM FULL trên bảng đang serving để "reclaim disk"
VACUUM FULL orders;   -- ACCESS EXCLUSIVE, block cả SELECT tới khi rewrite xong
Enter fullscreen mode Exit fullscreen mode

Trong lúc rewrite (có thể vài chục phút với bảng chục GB, chưa kể index rebuild), mọi query đọc/ghi tới orders xếp hàng chờ lock. Application timeout, connection pool đầy, các query không liên quan cũng chậm theo vì thread bị giữ. Đây là "table lock" mà node CSV nhắc tới. Bản đúng: dùng pg_repack (extension NTT open source, cách phổ biến nhất) hoặc pg_squeeze để rewrite online — chúng dùng trigger + logical work để copy sang bảng shadow, chỉ cần ACCESS EXCLUSIVE trong vài giây cuối để swap.

# Bản đúng: rewrite online, không block traffic
pg_repack --dbname=app --table=public.orders --jobs=4
Enter fullscreen mode Exit fullscreen mode

pg_repack cần primary key hoặc unique index, và cần chạy với superuser (hoặc user có pg_repack extension owner). Nó tạo bảng phụ, apply thay đổi qua trigger trong lúc copy, rồi swap; log/wal tăng gấp đôi tạm thời — nên chạy giờ off-peak vẫn tốt hơn.

Failure mode 2: chạy VACUUM bình thường xong disk usage không đổi, tưởng lệnh không hoạt động. Sau VACUUM (VERBOSE) big_table, log báo "removed N dead row versions", nhưng du -sh $PGDATA/base không giảm. Đúng như thiết kế: VACUUM chỉ tái dùng nội bộ, không truncate trừ khi có page trailing rỗng. Reaction sai là leo thang lên VACUUM FULL ngay (quay lại failure mode 1). Reaction đúng: kiểm bloat thực (pgstattuple), quyết định xem có thật sự cần thu hồi disk hay chỉ cần ngăn tiếp tục phình (điều chỉnh autovacuum threshold + fillfactor là đủ trong đa số case).

Failure mode 3: VACUUM chạy hoài không xong vì maintenance_work_mem quá nhỏ. VACUUM gom CTID dead tuple vào một mảng trong RAM có kích thước bị cap bởi maintenance_work_mem (hoặc autovacuum_work_mem nếu set). Bảng nhiều dead tuple mà work_mem nhỏ → VACUUM phải quét index nhiều vòng (mỗi vòng tương ứng buffer dead-tuple đầy một lần). Trong pg_stat_progress_vacuum, cột index_vacuum_count tăng dần lên 2, 3, 4… mỗi lần là một full index scan lặp lại — trên bảng có index tổng cỡ chục GB, mỗi vòng vài phút, tổng thời gian nhân theo.

-- Sai: mặc định maintenance_work_mem chỉ 64MB, VACUUM lặp index nhiều vòng
VACUUM VERBOSE huge_table;

-- Đúng: nâng maintenance_work_mem session-level trước khi VACUUM thủ công
SET maintenance_work_mem = '2GB';
VACUUM (VERBOSE, PARALLEL 4) huge_table;
Enter fullscreen mode Exit fullscreen mode

Với autovacuum, chỉnh qua autovacuum_work_mem ở cluster level. Từ Postgres 13, VACUUM có option PARALLEL n để chạy vacuum index song song, giúp giảm wall time trên bảng nhiều index.

Failure mode 4: VACUUM "xong" mà n_dead_tup không giảm. pg_stat_user_tables.n_dead_tup sau khi vacuum vẫn cao là dấu hiệu horizon bị kéo (transaction dài, replication slot idle, prepared transaction bỏ quên). VACUUM chỉ dọn được tuple chết cho mọi snapshot; tuple mới hơn xmin horizon buộc phải giữ lại — không phải lỗi VACUUM. Sửa bằng cách tìm holder qua pg_stat_activity.backend_xmin, pg_replication_slots.xmin, pg_prepared_xacts, rồi terminate/drop cái gây kéo trước.

Failure mode 5: truncate phase gây lag ngắn trên hot table. Ở pha truncating heap, VACUUM cần acquire ACCESS EXCLUSIVE lock ngắn để cắt trailing page. Trên bảng cực nóng, việc acquire lock này có thể conflict với transaction đang chạy và block ngắn tất cả người khác. Từ Postgres 12 có reloption vacuum_truncate = off để tắt hẳn pha này cho bảng nhất định — đánh đổi disk lấy độ ổn định latency.

Cách debug và monitor

pg_stat_progress_vacuum là view chính để nhìn VACUUM đang làm gì. Xem phase, heap_blks_scanned / heap_blks_total để đo tiến độ, và index_vacuum_count để phát hiện lặp index do work_mem nhỏ:

SELECT p.pid,
       a.datname, p.relid::regclass AS table,
       p.phase,
       pg_size_pretty(p.heap_blks_total * 8192::bigint)   AS heap_size,
       pg_size_pretty(p.heap_blks_scanned * 8192::bigint) AS scanned,
       p.index_vacuum_count,
       p.num_dead_tuples, p.max_dead_tuples,
       now() - a.query_start AS elapsed,
       left(a.query, 80) AS query
FROM pg_stat_progress_vacuum p
JOIN pg_stat_activity a ON a.pid = p.pid
ORDER BY elapsed DESC;
Enter fullscreen mode Exit fullscreen mode

Với VACUUM FULL / CLUSTER, dùng pg_stat_progress_cluster — cùng pattern nhưng pha khác (initializing, seq scanning heap, sorting tuples, writing new heap, swapping relation files, rebuilding index).

Metric cần lên monitoring:

  • last_autovacuum / last_vacuum trong pg_stat_user_tables — cũ bất thường trên bảng churn = autovacuum bị chặn hoặc quá tải.
  • n_dead_tup / n_live_tup — không giảm sau autovacuum = horizon bị kéo.
  • Vacuum duration — thời gian từ khi VACUUM xuất hiện trong pg_stat_activity tới khi biến mất. Bật log_autovacuum_min_duration = 0 (hoặc một ngưỡng dương như 1s) để Postgres log mọi autovacuum run kèm buffer stats, WAL usage, elapsed time — đây là log line định danh xu hướng vacuum chạy lâu dần.
  • pg_stat_io (Postgres 16+) hoặc pg_stat_bgwriter cho throughput I/O do vacuum tạo ra.

Extension bổ trợ (đều là contrib, không phải third-party lạ):

  • pgstattuplepgstattuple('t') cho tuple_percent, dead_tuple_percent, free_percent. Đây là con số chính thức để quyết định "có cần rewrite bảng không". Trên bảng lớn dùng pgstattuple_approx để tránh full scan.
  • pg_freespacemappg_freespace('t') để xem FSM đang báo bao nhiêu free space cho từng page. Free trong FSM cao mà file vẫn phình = FSM out-of-sync hoặc bảng insert-only append-heavy.
  • pg_visibility — kiểm all-visible/all-frozen coverage; Index Only Scan cần page all-visible.

Rule phòng ngừa:

  • Không bao giờ VACUUM FULL trên bảng đang serving — mặc định coi lệnh này là "cần maintenance window". Với bảng cần thu hồi disk online, dùng pg_repack.
  • Bật log_autovacuum_min_duration = 0 hoặc = '1s' trên production. Log line rẻ, giá trị debug lớn.
  • Đặt maintenance_work_mem (session) hoặc autovacuum_work_mem (cluster) ở mức 512MB–2GB tuỳ RAM, để VACUUM không phải lặp index.
  • Alert khi có row trong pg_stat_progress_vacuum với index_vacuum_count > 1 — dấu hiệu work_mem không đủ.

Tradeoff

VACUUM (không FULL) tối ưu cho online: lock nhẹ (SHARE UPDATE EXCLUSIVE), không block query thường, chạy được thường xuyên. Đổi lại, nó không thu hồi disk cho OS — heap size chỉ giữ nguyên và tái dùng nội bộ; muốn nhỏ lại phải rewrite. VACUUM FULL thu hồi disk triệt để và rebuild index luôn, nhưng cần ACCESS EXCLUSIVE trong toàn bộ thời gian — hoàn toàn không dùng được online, và cần disk trống ≥ kích thước bảng vì viết vào file mới trước khi xoá cũ. pg_repack chen vào giữa: rewrite online, chỉ block vài giây cuối để swap; đánh đổi bằng WAL/log tăng gấp đôi và cần primary key. Quy tắc thực tế: routine dọn = autovacuum + VACUUM manual khi cần; disk thật sự cần thu hồi = pg_repack trong window có kế hoạch; VACUUM FULL chỉ để giành cho môi trường có thể chịu downtime hoặc bảng nhỏ ít traffic. Đừng dùng VACUUM FULL như phản xạ khi thấy disk cao — 90% case, câu trả lời là điều chỉnh autovacuum, không phải rewrite.

Câu hỏi phỏng vấn

VACUUM làm gì? Vì sao chạy VACUUM mà disk không giảm, và khác gì VACUUM FULL?

VACUUM là câu lệnh dọn dead tuple do MVCC sinh ra. Nó quét heap thu thập CTID của tuple có t_xmax committed và cũ hơn xmin horizon toàn cluster, xoá entry index tương ứng, chuyển line pointer trong heap thành LP_UNUSED, cập nhật FSM/VM. Điểm mấu chốt: VACUUMin-place — chỉ tái dùng chỗ trống bên trong file, không shrink file, không trả disk cho OS. Trường hợp duy nhất trả disk là pha truncating heap cắt các page trailing hoàn toàn rỗng, nhưng phải acquire ACCESS EXCLUSIVE lock ngắn và chỉ áp dụng ở đuôi file. Lock nhẹ (SHARE UPDATE EXCLUSIVE) là lý do VACUUM chạy được online, không block SELECT/INSERT/UPDATE/DELETE. VACUUM FULL khác hoàn toàn: nó rewrite bảng vào file mới, chỉ chép tuple live, rebuild index, rồi swap relfilenode — nhờ đó dung lượng thật sự giảm, nhưng cần ACCESS EXCLUSIVE trong toàn bộ thời gian (block cả reader) và cần disk trống ≥ size bảng. Điểm ăn điểm là (a) nêu được cơ chế "in-place vs rewrite" và tại sao truncate chỉ áp dụng trailing page, (b) chỉ ra hậu quả production của VACUUM FULL (khoá bảng cả chục phút, gây timeout hàng loạt) và giải pháp thay thế online (pg_repack/pg_squeeze), (c) gắn với các failure mode thực: n_dead_tup không giảm vì horizon bị kéo, VACUUM chạy lâu vì maintenance_work_mem nhỏ (thấy qua index_vacuum_count > 1 trong pg_stat_progress_vacuum), truncate phase gây lag ngắn trên hot table (khắc phục bằng vacuum_truncate = off).

Hands-on

Mục tiêu: quan sát trực tiếp một lần VACUUM qua pg_stat_progress_vacuum, xác nhận file không giảm sau VACUUM, thấy khác biệt với VACUUM FULL, và ép index_vacuum_count > 1 bằng maintenance_work_mem nhỏ.

Dựng Postgres:

docker run -d --name pg-vac \
  -e POSTGRES_PASSWORD=pw -p 5440:5432 \
  postgres:16 \
  -c autovacuum=off \
  -c log_autovacuum_min_duration=0 \
  -c log_min_messages=info

sleep 5
docker exec -i pg-vac psql -U postgres <<'SQL'
CREATE EXTENSION pgstattuple;
CREATE EXTENSION pg_freespacemap;

CREATE TABLE big (
  id  bigint PRIMARY KEY,
  pad text
);
CREATE INDEX idx_big_pad ON big(pad);

-- ~500MB sau vài lần update
INSERT INTO big
SELECT g, repeat('x', 200)
FROM generate_series(1, 2000000) g;
SQL
Enter fullscreen mode Exit fullscreen mode

Bước 1 — sinh dead tuple, xem file không giảm sau VACUUM:

docker exec -it pg-vac psql -U postgres
Enter fullscreen mode Exit fullscreen mode
-- Ép autovacuum tắt cho chắc
ALTER TABLE big SET (autovacuum_enabled = false);

-- Update toàn bảng → 2M dead tuple
UPDATE big SET pad = repeat('y', 200);

SELECT pg_size_pretty(pg_relation_size('big'))      AS heap_before,
       pg_size_pretty(pg_indexes_size('big'))       AS idx_before;

SELECT (pgstattuple('big')).dead_tuple_percent      AS dead_pct_before;

VACUUM (VERBOSE) big;

SELECT pg_size_pretty(pg_relation_size('big'))      AS heap_after_vacuum,
       pg_size_pretty(pg_indexes_size('big'))       AS idx_after_vacuum;

SELECT (pgstattuple('big')).dead_tuple_percent      AS dead_pct_after,
       (pgstattuple('big')).free_percent            AS free_pct_after;
-- heap_after_vacuum ~= heap_before (không giảm),
-- nhưng dead_pct → 0 và free_pct tăng cao (chỗ trống sẵn sàng tái dùng).
Enter fullscreen mode Exit fullscreen mode

Bước 2 — quan sát pha VACUUM qua pg_stat_progress_vacuum. Sinh churn tiếp rồi mở hai terminal:

-- Terminal A: sinh 3M dead tuple để VACUUM có gì để làm
UPDATE big SET pad = repeat('z', 200);
UPDATE big SET pad = repeat('w', 200);

-- Terminal A: hạ maintenance_work_mem để ép lặp index
SET maintenance_work_mem = '16MB';
VACUUM (VERBOSE) big;
Enter fullscreen mode Exit fullscreen mode
-- Terminal B: chạy song song, poll progress
\watch 1
SELECT phase,
       heap_blks_total, heap_blks_scanned, heap_blks_vacuumed,
       index_vacuum_count, num_dead_tuples, max_dead_tuples
FROM pg_stat_progress_vacuum;
-- Sẽ thấy phase chạy qua 'scanning heap' → 'vacuuming indexes' → 'vacuuming heap',
-- và index_vacuum_count leo lên 2, 3, 4... vì work_mem 16MB không đủ chứa CTID.
Enter fullscreen mode Exit fullscreen mode

Bước 3 — chạy lại với maintenance_work_mem lớn, quan sát index_vacuum_count = 1:

UPDATE big SET pad = repeat('a', 200);

SET maintenance_work_mem = '1GB';
VACUUM (VERBOSE, PARALLEL 4) big;
-- index_vacuum_count = 1, wall time giảm rõ so với bước 2.
Enter fullscreen mode Exit fullscreen mode

Bước 4 — chứng minh chỉ VACUUM FULL (hoặc pg_repack) trả disk cho OS:

SELECT pg_size_pretty(pg_relation_size('big'))  AS before_full;

-- ACCESS EXCLUSIVE: mọi query khác vào 'big' sẽ chờ tới khi xong
VACUUM FULL big;

SELECT pg_size_pretty(pg_relation_size('big'))  AS after_full;
-- Giảm mạnh về gần đúng phần live.
Enter fullscreen mode Exit fullscreen mode

Bước 5 — vô hiệu pha truncate cho bảng hot để tránh lag ngắn:

ALTER TABLE big SET (vacuum_truncate = off);

-- Xác nhận reloption
SELECT reloptions FROM pg_class WHERE relname = 'big';
Enter fullscreen mode Exit fullscreen mode

Bước 6 — bật autovacuum lại và log-monitor:

ALTER TABLE big SET (
  autovacuum_enabled              = true,
  autovacuum_vacuum_scale_factor  = 0.05,
  autovacuum_vacuum_threshold     = 1000
);
Enter fullscreen mode Exit fullscreen mode
docker logs -f pg-vac | grep -E "automatic vacuum|elapsed"
# Mỗi dòng "automatic vacuum of table ..." cho biết duration, buffer stats,
# WAL usage — đây là log line dùng để đo Vacuum Duration trên production.
Enter fullscreen mode Exit fullscreen mode

Dọn:

docker rm -f pg-vac
Enter fullscreen mode Exit fullscreen mode

Sau bài này, ba câu hay bị lẫn — "VACUUM có trả disk không?", "khi nào dùng VACUUM FULL?", "vì sao n_dead_tup không giảm sau VACUUM?" — đều có gốc rõ ràng: VACUUM in-place tái dùng chỗ, VACUUM FULL rewrite và khoá bảng, và mọi thứ tuỳ vào việc xmin horizon và maintenance_work_mem có cho phép nó dọn xong hay không.

Top comments (0)