bài MVCC ta thấy mỗi UPDATE để lại một xác — dead tuple — và một bảng một hàng phình lên 360KB sau nhiều lần cập nhật. Vậy ai dọn đống xác đó? Câu trả lời là VACUUM. Nhưng VACUUM làm một việc khác với điều hầu hết mọi người tưởng, và tôi phát hiện điều đó khi đo kích thước bảng trước và sau — con số làm tôi ngỡ VACUUM không chạy.

VACUUM và hàng chết

VACUUM dọn xác, nhưng không trả đĩa

Dead tuple không tự biến mất; PostgreSQL có VACUUM để dọn chúng. Nhưng "dọn" ở đây nghĩa rất cụ thể: VACUUM thường đánh dấu chỗ mà các dead tuple đang chiếm là trống, dùng lại được — để các INSERT/UPDATE sau ghi hàng mới vào đó. Điểm mấu chốt: nó không trả không gian ấy về hệ điều hành. File bảng trên đĩa giữ nguyên kích thước; chỗ trống chỉ được tái dùng bên trong bảng.

Đây chính là giá trị thật của VACUUM: nó ngăn bảng phình mãi. Không có nó, mỗi UPDATE cứ thêm một xác và bảng lớn không giới hạn cho tới khi hết đĩa. Có nó, chỗ trống được tái dùng nên bảng ổn định ở một kích thước cân bằng — đủ chứa số hàng sống cộng một ít chỗ trống luân chuyển giữa các lần ghi. VACUUM thường rất nhẹ và chạy song song với truy vấn khác — không khóa bảng. Và autovacuum (mặc định bật) tự động chạy VACUUM ngầm để giữ bloat trong tầm kiểm soát.

Muốn thu nhỏ file thật sự, phải dùng VACUUM FULL: nó viết lại toàn bộ bảng vào một file mới nén chặt, rồi trả file cũ về hệ điều hành. Nhưng nó khóa bảng độc quyền trong suốt quá trình — không ai đọc/ghi được — nên là thao tác nặng, không chạy giờ cao điểm.

Đo: dead về 0, nhưng kích thước đứng yên

Tôi tạo một bảng một hàng, tắt autovacuum trên bảng đó để nó không dọn ngầm giữa chừng (giúp phép đo sạch), rồi UPDATE 20.000 lần cho bloat, và đo qua bốn mốc:

Mốc Dead tuple Kích thước file
(1) sau 20.000 UPDATE 20000 712 kB
(2) sau VACUUM 0 712 kB
(3) +20.000 UPDATE nữa, rồi VACUUM 0 712 kB
(4) sau VACUUM FULL 0 8 kB

Mốc (2) là điểm mấu chốt: VACUUM đưa dead tuple từ 20.000 về 0 — dọn sạch — nhưng kích thước file vẫn đúng 712 kB, không giảm một byte. Mốc (3) chứng minh giá trị của việc đó: tôi UPDATE thêm 20.000 lần nữa rồi VACUUM, và bảng vẫn 712 kB — không phình thêm một byte, vì các update mới lấp vào chỗ trống mà VACUUM đã giải phóng thay vì cấp thêm trang mới. Đúng cơ chế tái dùng mà VACUUM tồn tại để cung cấp. Chỉ tới VACUUM FULL ở mốc (4), bảng mới được nén xuống 8 kB (một trang, một hàng) — trả lại đĩa thật sự. Ba con số 712 kB đứng yên rồi đột ngột rơi xuống 8 kB kể trọn câu chuyện: VACUUM thường và VACUUM FULL là hai thao tác khác hẳn nhau, dù tên gần giống. Một cái tái dùng chỗ tại chỗ; một cái viết lại cả bảng.

Một lần tôi đo hớ: tưởng VACUUM không chạy

Cái bẫy đến ở mốc (2). Tôi chạy VACUUM trên bảng bloat 712 kB, rồi đo pg_relation_size, chờ thấy nó nhỏ lại — trong đầu tôi "dọn rác" đồng nghĩa với "bảng gọn hơn". Nhưng con số vẫn đúng 712 kB, không nhúc nhích. Phản xạ đầu tiên: "VACUUM không chạy? Hay nó vô dụng?".

Theo kỷ luật, tôi không kết luận vội mà đo một đại lượng khác: n_dead_tup. Nó đã từ 20.000 về 0. Vậy VACUUM rõ ràng chạy và làm việc — chỉ là việc nó làm không phải "thu nhỏ file". Hai con số vênh nhau — dead tuple giảm (VACUUM có tác dụng) nhưng kích thước đứng yên (trông như không) — và theo kỷ luật, hai số mâu thuẫn nghĩa là tôi đang đo nhầm đại lượng để đánh giá. pg_relation_size là sai chỉ số ở đây; VACUUM thường không hứa giảm nó. Chỉ số đúng để biết VACUUM có việc là n_dead_tup (đã về 0) và thực tế bảng ngừng phình (mốc 3).

Cái tôi đo hớ là đánh giá VACUUM bằng kích thước file — trong khi VACUUM thường không bao giờ trả đĩa; nó chỉ tái dùng chỗ nội bộ. Muốn kích thước giảm phải VACUUM FULL, và cái đó khóa bảng. Bài học đo lường: khi đánh giá một thao tác, phải đo đúng đại lượng nó ảnh hưởng. Nếu tôi chỉ nhìn pg_relation_size, tôi đã kết luận sai rằng VACUUM hỏng, và có thể vội chạy VACUUM FULL khóa cả bảng lúc cao điểm — một sai lầm tệ hơn nhiều.

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

Hệ quả đầu tiên: VACUUM thường (và autovacuum) là thứ giữ cho cơ sở dữ liệu khỏe, không phải để thu nhỏ. Đừng lo lắng khi thấy bảng không nhỏ đi sau VACUUM — đó là hành vi đúng. Việc cần theo dõi là n_dead_tup và tỉ lệ dead/live: nếu autovacuum không theo kịp một bảng ghi rất nhiều, dead tuple tích lại làm bảng phình và mọi truy vấn chậm dần. Cách xử lý thường là chỉnh autovacuum tích cực hơn cho bảng nóng đó (ngưỡng thấp hơn, chạy thường xuyên hơn), chứ không phải chạy VACUUM FULL tay. Ngoài dọn dead tuple, VACUUM còn cập nhật visibility map (giúp index-only scan, như bài chỉ mục phủ đã đo) và ngăn một sự cố nghiêm trọng gọi là transaction ID wraparound — nên để autovacuum chạy đều là thiết yếu, không phải tùy chọn.

Hệ quả thứ hai: VACUUM FULL mạnh nhưng nguy hiểm — nó khóa bảng. Chỉ dùng khi bảng đã bloat nặng và bạn thật sự cần trả đĩa (ví dụ sau khi xóa phần lớn dữ liệu một lần), và chạy vào lúc ít tải hoặc dùng công cụ như pg_repack (nén mà không khóa lâu). Chạy VACUUM FULL một bảng lớn giữa giờ cao điểm sẽ đóng băng mọi truy vấn chạm tới nó — một sự cố tự gây ra. Phân biệt rõ hai lệnh gần tên nhưng khác hẳn về tác động là điều bắt buộc.

Hệ quả thứ ba là bài học đo lường. Con số mang theo: VACUUM thường đưa dead tuple về 0 và cho tái dùng chỗ trống (ngăn bảng phình thêm) nhưng KHÔNG giảm kích thước file — bảng bloat 712kB vẫn 712kB sau VACUUM; chỉ VACUUM FULL mới nén (còn 8kB) và trả đĩa, đổi lại khóa bảng. Khi một thao tác "không có tác dụng" theo con số bạn nhìn, hãy hỏi bạn có đang nhìn đúng con số không — VACUUM làm việc thật, chỉ là ở n_dead_tup chứ không ở pg_relation_size.

Thử ba mươi giây

Trong PostgreSQL, tìm một bảng hay bị cập nhật và xem sức khỏe của nó: SELECT relname, n_live_tup, n_dead_tup, last_autovacuum FROM pg_stat_user_tables WHERE relname = 'ten_bang';. Nếu n_dead_tup cao so với n_live_tup, bảng đang bloat. Chạy VACUUM (VERBOSE) ten_bang; và đọc log — nó cho biết dọn được bao nhiêu tuple. Rồi so pg_relation_size('ten_bang') trước và sau: bạn sẽ thấy nó không đổin_dead_tup về 0 — đúng cái bài này đo, và là lý do đừng đánh giá VACUUM bằng kích thước file. Chỉ khi bạn thật sự cần trả đĩa và chấp nhận khóa bảng mới cân nhắc VACUUM FULL.