Bài 1 giới thiệu một hệ quả của MVCC mà lúc đó chỉ nhắc qua: UPDATE không sửa hàng tại chỗ, mà tạo một phiên bản mới và đánh dấu bản cũ là chết (dead tuple). Nghe vô hại — nhưng khi một hàng bị cập nhật hàng nghìn lần, các bản chết tích tụ và bảng phình to một cách đáng kinh ngạc, dù số hàng sống không đổi. Đây gọi là bloat, và nó là một trong những nguyên nhân phổ biến nhất khiến database PostgreSQL chậm dần và ngốn đĩa theo thời gian.

Bài này (phần 7 loạt PostgreSQL) đo thật bloat trên pg-lab, và cho thấy khác biệt quan trọng giữa VACUUM và VACUUM FULL mà nhiều người nhầm lẫn.

Cơ chế: mỗi UPDATE để lại một bản chết

Nhớ lại bài 1: UPDATE một hàng = tạo phiên bản mới (tuple mới) + đánh dấu phiên bản cũ bằng xmax (bản chết). Bản chết không bị xóa ngay — nó nằm lại trong trang dữ liệu cho tới khi VACUUM dọn. Nếu bạn cập nhật cùng một hàng 100.000 lần, bạn tạo ra 100.000 bản chết, tất cả chiếm chỗ trong bảng.

MVCC bloat PostgreSQL: mỗi UPDATE để lại một bản chết, MVCC UPDATE bằng tạo phiên bản mới cộng đánh dấu bản cũ là chết dead tuple, UPDATE bloat SET v 1 WHERE id 1 bản cũ chết bản mới sống, UPDATE bloat SET v 2 WHERE id 1 lại thêm một bản chết, dù chỉ 1 hàng sống bảng tích tụ hàng nghìn bản chết phình to bloat; đo kích thước và số bản chết kích thước file trên đĩa SELECT pg_size_pretty pg_relation_size bloat, số hàng sống hàng chết SELECT n_live_tup n_dead_tup FROM pg_stat_user_tables WHERE relname bloat; VACUUM vs VACUUM FULL, VACUUM dọn bản chết không gian tái dùng được nhưng không trả đĩa cho OS file không nhỏ lại, VACUUM FULL ghi lại bảng gọn gàng file nhỏ lại trả đĩa OS nhưng khóa bảng độc quyền chặn mọi truy cập

Hình 1: UPDATE để lại bản chết; cập nhật nhiều lần → tích tụ dead tuple → bảng phình to dù chỉ 1 hàng sống. Đo bằng pg_relation_size và pg_stat_user_tables. VACUUM dọn bản chết (không trả đĩa); VACUUM FULL thu nhỏ file (nhưng khóa bảng).

-- Moi UPDATE tao them 1 ban chet
UPDATE bloat SET v = 1 WHERE id = 1;   -- ban cu chet, ban moi song
UPDATE bloat SET v = 2 WHERE id = 1;   -- lai them 1 ban chet...
-- Do kich thuoc va so ban chet:
SELECT pg_size_pretty(pg_relation_size('bloat'));
SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname='bloat';

Đo thật: bảng phình dù chỉ 1 hàng sống

Mình tạo bảng chỉ có 1 hàng (tắt autovacuum để thấy bloat rõ), rồi UPDATE hàng đó lặp lại và đo kích thước. Kết quả thật từ pg-lab:

Bảng kết quả đo thật bloat từ UPDATE lặp trên pg-lab postgresql 16.15 bảng 1 hàng autovacuum tắt UPDATE cùng hàng nhiều lần: bảng phình to dù luôn chỉ có 1 hàng sống, ban đầu 1 INSERT hàng sống 1 kích thước 8192 bytes 1 trang n_dead_tup 0, sau 50.000 UPDATE hàng sống 1 kích thước 1.776 kB n_dead_tup 50.000, sau 100.000 UPDATE hàng sống 1 kích thước 3.544 kB n_dead_tup khoảng 50.221; VACUUM dọn bản chết VACUUM FULL mới trả đĩa, VACUUM bloat kích thước sau 3.544 kB không nhỏ lại n_dead_tup 0 đã dọn, VACUUM FULL bloat kích thước sau 8192 bytes trả đĩa n_dead_tup 0. Badge output thật màu xanh

Hình 2: Kết quả thật. Bảng 1 hàng sống phình từ 8 KB lên 3,5 MB sau 100.000 UPDATE (mỗi UPDATE một bản chết). VACUUM dọn bản chết (n_dead_tup về 0) nhưng giữ nguyên kích thước 3,5 MB; VACUUM FULL thu nhỏ về 8 KB.

  • Ban đầu: 8.192 bytes (1 trang), 1 hàng, 0 bản chết.
  • Sau 50.000 UPDATE: 1.776 kB, vẫn 1 hàng sống, 50.000 bản chết. Bảng đã phình hơn 200 lần mà dữ liệu thực vẫn là 1 hàng.
  • Sau 100.000 UPDATE: 3.544 kB, 1 hàng sống. Bảng 3,5 MB cho đúng một hàng dữ liệu — toàn bộ phần dôi ra là không gian chết.

Đây là bloat ở dạng thuần nhất. Trong thực tế, các bảng bị cập nhật nhiều (bảng trạng thái, counter, session, hàng đợi) bloat liên tục, và nếu không được dọn, chúng ngốn đĩa và làm mọi truy vấn quét bảng chậm hơn (phải đọc qua cả không gian chết).

VACUUM vs VACUUM FULL: khác biệt quan trọng

Nhiều người tưởng VACUUM thu nhỏ bảng. Không phải:

  • VACUUM: sau khi chạy, n_dead_tup về 0 (bản chết đã được dọn, không gian tái dùng được), nhưng kích thước file vẫn 3.544 kB — không nhỏ lại. VACUUM đánh dấu không gian chết là "trống để dùng lại", nên các UPDATE sau sẽ lấp vào đó thay vì phình thêm. Nhưng nó không trả đĩa cho hệ điều hành.
  • VACUUM FULL: thu nhỏ file về 8.192 bytes — ghi lại toàn bộ bảng một cách gọn gàng và trả đĩa cho OS. Nhưng cái giá: nó khóa bảng độc quyền suốt quá trình, chặn mọi đọc/ghi.

Khác biệt này cực kỳ quan trọng trong vận hành: VACUUM là bảo trì thường xuyên, nhẹ nhàng (giữ bloat không tăng); VACUUM FULL là "đại tu" hiếm khi, nặng nề (chỉ khi bloat đã quá lớn và cần lấy lại đĩa, chạy lúc bảo trì).

Đánh đổi cần cân nhắc

Bloat là cái giá của MVCC, không phải bug — nhưng phải quản lý. Đọc không chặn ghi (bài 1) có được là nhờ giữ nhiều phiên bản, và cái giá là bản chết tích tụ. Đây là đánh đổi cố ý, không phải lỗi. Nhưng nếu không dọn, bloat làm bảng và index phình, tốn đĩa, làm chậm quét tuần tự và index. Giải pháp không phải "tránh UPDATE" mà là để autovacuum (bài sau) dọn đều đặn — mình tắt nó trong demo để thấy bloat, nhưng trong production nó phải bật và cấu hình đúng.

VACUUM FULL khóa bảng — gần như không bao giờ chạy trên bảng đang phục vụ. Vì nó khóa độc quyền, chạy VACUUM FULL trên một bảng production lớn giữa giờ cao điểm sẽ treo toàn bộ ứng dụng trong thời gian ghi lại bảng (có thể hàng phút với bảng lớn). Nếu thật sự cần thu nhỏ bảng bị bloat nặng mà không thể khóa, dùng công cụ như pg_repack (ghi lại bảng không cần khóa độc quyền) thay vì VACUUM FULL. Thường thì VACUUM thường xuyên + chấp nhận file không nhỏ lại là đủ.

Một số workload bloat nhanh hơn — cần chú ý đặc biệt. Bảng bị UPDATE/DELETE cường độ cao (counter, trạng thái job, session, hàng đợi) bloat nhanh nhất. Với chúng, cân nhắc: cấu hình autovacuum mạnh hơn riêng cho bảng đó (ngưỡng thấp hơn), dùng fillfactor thấp để HOT update tái dùng chỗ trong trang (bài 9), hoặc thiết kế lại (ví dụ dùng một bảng append-only + tổng hợp định kỳ thay vì UPDATE tại chỗ). Giao dịch mở lâu (bài 1) cũng làm bloat tệ hơn vì cản VACUUM dọn.

Ba ý mang về

  1. UPDATE để lại bản chết, tích tụ thành bloat. Đo thật: bảng chỉ 1 hàng sống phình từ 8 KB lên 3,5 MB sau 100.000 UPDATE — mỗi UPDATE một dead tuple. Đây là hệ quả trực tiếp của MVCC (đọc không chặn ghi), không phải bug. Đo bằng pg_relation_size và n_dead_tup.
  2. VACUUM dọn bản chết nhưng KHÔNG trả đĩa; VACUUM FULL mới thu nhỏ file. Đo thật: sau VACUUM, n_dead_tup=0 nhưng kích thước vẫn 3,5 MB (không gian tái dùng được); VACUUM FULL ghi lại bảng về 8 KB nhưng khóa bảng độc quyền. Đừng nhầm hai lệnh này.
  3. Để autovacuum quản lý bloat; tránh VACUUM FULL trên bảng đang phục vụ. VACUUM thường xuyên giữ bloat không tăng; VACUUM FULL khóa bảng nên chỉ dùng khi bảo trì (hoặc pg_repack không khóa). Workload UPDATE nặng cần autovacuum mạnh hơn, fillfactor thấp, hoặc thiết kế lại.

Nguồn

Phần sau ta đi sâu vào VACUUM và autovacuum: cách nó dọn bản chết, vì sao autovacuum là cơ chế sống còn, và đo thật trước/sau khi dọn qua pg_stat_user_tables.