Lời khuyên phổ biến nhất về bloat là "hạ autovacuum_vacuum_scale_factor cho bảng lớn". Tôi đã đo lời khuyên đó ba lần với ba cấu hình khác nhau, và không lần nào nó cho kết quả như hứa hẹn.

Ba cấu hình, cùng một tải ghi, và lý do hạ ngưỡng không có tác dụng

Cách bố trí phép đo

Hai bảng giống hệt nhau — cùng 3 triệu dòng, cùng khoá chính, cùng một chỉ mục phụ, cùng 127 MB. Khác biệt duy nhất là thiết lập autovacuum của bảng thứ hai:

alter table chinh_tay set (
  autovacuum_vacuum_scale_factor  = 0.02,   -- mặc định 0.2
  autovacuum_vacuum_cost_delay    = 0,      -- mặc định 2 ms
  autovacuum_vacuum_cost_limit    = 2000,   -- mặc định 200
  autovacuum_analyze_scale_factor = 0.02
);

Ngưỡng dọn vì thế là 600.050 dòng chết cho bảng mặc định và 60.050 cho bảng đã chỉnh — nhỏ hơn mười lần.

Một tiến trình cập nhật luân phiên cả hai bảng, mỗi lần khoảng 30.000 dòng, chạy liên tục trong bảy phút. Cả hai chịu cùng một tải.

Lần đo 1: bản đã chỉnh phình hơn

Phút Mặc định (0,2) Đã chỉnh (0,02)
kích thước dòng chết lần dọn kích thước dòng chết lần dọn
0 148 MB 498.305 0 140 MB 0 0
1 232 MB 1.388.508 1 236 MB 2.948.355 0
2 232 MB 1.177.741 2 319 MB 889.988 1
4 232 MB 1.452.349 4 319 MB 1.727.383 3
6 232 MB 1.493.574 6 319 MB 1.753.778 5

Bảng đã chỉnh kết thúc ở 319 MB, lớn hơn bảng mặc định 37%. Nó cũng được dọn ít hơn — 5 lần so với 6.

Nhìn dòng phút 1 sẽ thấy chuyện gì đã xảy ra: bảng đã chỉnh có 2.948.355 dòng chết mà chưa được dọn lần nào, dù ngưỡng của nó chỉ là 60.050. Nó vượt ngưỡng từ rất sớm nhưng phải đợi. Trong lúc đợi, tệp phình lên 319 MB — và tệp đã phình thì không bao giờ nhỏ lại (phần 22).

Vì sao hạ ngưỡng không có tác dụng

Cột "lần dọn" trả lời: 6 lần và 5 lần trong 6 phút, tức là khoảng một lần mỗi phút cho cả hai.

autovacuum_naptime mặc định là 60 giây. Tiến trình quản lý autovacuum ngủ 60 giây, tỉnh dậy, xem bảng nào vượt ngưỡng, giao việc cho worker, rồi lại ngủ. Nó không quan tâm bảng vượt ngưỡng 10 lần hay 100 lần — mỗi chu kỳ nó chỉ giao việc một lượt.

Khi tải ghi đủ nặng để bảng vượt ngưỡng trong mọi chu kỳ 60 giây, thì ngưỡng là 600.050 hay 60.050 không còn ý nghĩa gì. Cả hai đều "đã vượt", cả hai đều được dọn một lần mỗi phút.

Hạ scale_factor chỉ có tác dụng khi bảng chưa vượt ngưỡng ở mỗi chu kỳ — tức là với bảng lớn có tải ghi thưa, nơi ngưỡng 20% có thể mất hàng giờ mới đạt tới. Đó là trường hợp thật, nhưng nó không phải trường hợp mà người ta hay gặp khi kêu ca về bloat.

Lần đo 2: chỉnh đúng cái nút thắt

Bỏ hết thay đổi ngưỡng, giữ scale_factor mặc định 0,2, chỉ đổi hai thứ ở mức máy chủ:

autovacuum_naptime            = 10s     (mặc định 60s)
autovacuum_vacuum_cost_delay  = 0       (mặc định 2ms)
Phút Kích thước Dòng chết Lần dọn
0 128 MB 0 0
1 194 MB 569.052 5
2 217 MB 923.905 10
4 217 MB 859.688 22
6 217 MB 1.207.115 34

Dọn 34 lần thay vì 6 — nhiều gấp 5,7 lần. Kết quả: 217 MB thay vì 232 MB, nhỏ hơn 6%.

Đây là con số tôi thấy đáng nhớ nhất trong bài. Tăng khối lượng công việc dọn dẹp lên gần sáu lần, đổi lại 6% dung lượng. Với cost_delay = 0, autovacuum không còn nhường băng thông đĩa cho ai cả — trên máy chủ thật, cái giá đó rơi thẳng vào các truy vấn của người dùng.

Lần đo 3: tải ghi thưa

Có thể tải của tôi quá nặng. Giảm xuống còn khoảng 5.000 dòng mỗi ba giây, và so mặc định 0,2 với 0,005 (ngưỡng 15.050 dòng chết):

Phút Mặc định Đã chỉnh (0,005)
2 170 MB 165 MB
4 172 MB 167 MB
6 172 MB 167 MB

Nhỏ hơn 3%.

Cái thật sự quyết định

Ba lần đo, ba cấu hình, và cả ba đều dừng ở khoảng 1,3 đến 1,7 lần kích thước gốc. Mức phình ổn định do tốc độ ghi quyết định, không do thiết lập autovacuum.

Lý do nằm ở số học đơn giản. Một lượt VACUUM trên chính bảng này mất:

vacuum_cost_delay Thời gian
0 ms (gõ tay) 1,54 s
2 ms (autovacuum) 11,28 s

Autovacuum dọn 11 giây rồi ngồi im 49 giây. Tải ghi thì chạy suốt cả 60 giây. Bảng phình lên trong 49 giây đó, được dọn bớt trong 11 giây, và cân bằng ở đâu đó — chỗ cân bằng ấy do tỷ lệ giữa tốc độ sinh dòng chết và tốc độ dọn quyết định. Hạ ngưỡng không đổi được tỷ lệ nào trong hai cái.

Từ đó rút ra một cách nghĩ khác về việc chỉnh autovacuum: mục đích không phải giảm kích thước ổn định, mà là chặn trường hợp xấu nhất. Bảng lớn ít bị ghi mà ngưỡng 20% khiến nó không được dọn suốt ba tuần — đó mới là chỗ scale_factor đáng hạ, vì ở đó bloat không ổn định mà tăng đều.

Kiểu hỏng thật: autovacuum bị huỷ

Trong lúc autovacuum đang dọn một bảng 833 MB — mới quét được 26 trên 106.667 trang — tôi chạy một câu ALTER TABLE:

ALTER TABLE hoàn thành sau 1,11 giây

Và trong log máy chủ:

ERROR:  canceling autovacuum task

ALTER TABLE cần khoá ACCESS EXCLUSIVE, xung đột với khoá SHARE UPDATE EXCLUSIVE của autovacuum. PostgreSQL xử lý bằng cách huỷ autovacuum để lệnh của người dùng đi tiếp. Đây là quyết định đúng — không ai muốn một câu ALTER TABLE phải đợi hết một lượt dọn hai tiếng.

Nhưng hệ quả thì nặng: autovacuum bị huỷ không lưu lại tiến độ. Lần sau nó bắt đầu từ trang số 0. Một bảng lớn chịu DDL đều đặn — công cụ migration chạy mỗi giờ, script thêm cột, TRUNCATE bảng liên quan — có thể không bao giờ được dọn xong.

Và chuyện này hoàn toàn im lặng ở phía ứng dụng. ALTER TABLE chạy bình thường, không ai thấy gì. Dấu vết duy nhất là dòng canceling autovacuum task trong log máy chủ, và cột autovacuum_count trong pg_stat_user_tables không tăng — vì lượt dọn đó không hoàn thành.

Kiểm tra:

-- trong log máy chủ
grep "canceling autovacuum" postgresql.log | wc -l

Nếu con số đó lớn, chỉnh scale_factor không giúp được gì — phải giảm tần suất DDL hoặc dời nó vào cửa sổ bảo trì.

Thứ tự nên làm

Dựa trên những gì đo được:

  1. Kiểm giao dịch dài trước tiên (phần 21). Đây là nguyên nhân số một, và không thiết lập autovacuum nào chữa được.
  2. Kiểm log xem autovacuum có bị huỷ không. Nguyên nhân số hai, cũng không chữa bằng ngưỡng.
  3. Xem bloat có ổn định không. Nếu bảng đã dừng ở 1,5–2 lần và không tăng nữa thì đó là trạng thái bình thường — đừng đụng vào.
  4. Chỉ khi bloat tăng đều mới chỉnh. Và nút đầu tiên nên là autovacuum_naptime cùng autovacuum_vacuum_cost_delay, không phải scale_factor.
  5. Hạ scale_factor cho bảng rất lớn ghi thưa — nơi ngưỡng 20% khiến lần dọn đầu tiên bị hoãn hàng giờ.

Theo dõi autovacuum đang làm gì ngay lúc này:

select p.relid::regclass as bang,
       p.phase,
       p.heap_blks_scanned || '/' || p.heap_blks_total as tien_do,
       round(100.0 * p.heap_blks_scanned / nullif(p.heap_blks_total,0), 1) as phan_tram,
       round(extract(epoch from now() - a.query_start)) as chay_bao_lau_giay
from pg_stat_progress_vacuum p
join pg_stat_activity a using (pid);

pg_stat_progress_vacuum là cách duy nhất biết một lượt dọn đang ở đâu. Nếu bạn thấy phan_tram luôn ở mức thấp mỗi lần nhìn, khả năng cao là nó đang bị huỷ và khởi động lại liên tục.

Sai lầm khi đo

Lần bố trí đầu tiên tôi hạ autovacuum_naptime xuống 5 giây "cho nhanh thấy kết quả", rồi kết luận rằng autovacuum theo kịp tốt — bảng ổn định ở 77 MB.

Kết luận đó vô nghĩa: tôi đã đổi đúng cái biến quan trọng nhất trước khi bắt đầu đo, nên phép đo không nói gì về hành vi mặc định. Phải dựng lại máy chủ với thiết lập nguyên bản hoàn toàn rồi mới đo được, và khi đó mới thấy naptime = 60 chính là nút thắt.

Bài học chung: khi đo một hệ thống có nhiều tham số, đừng chỉnh bất cứ thứ gì "cho tiện quan sát" — tham số bạn chỉnh để đo nhanh hơn thường chính là tham số đang gây ra hiện tượng.

Thử ba mươi giây

select relname,
       pg_size_pretty(pg_relation_size(relid)) as kich_thuoc,
       n_dead_tup,
       autovacuum_count,
       round(extract(epoch from now() - last_autovacuum) / 60) as phut_tu_lan_don_cuoi
from pg_stat_user_tables
where n_dead_tup > 10000
order by n_dead_tup desc limit 10;

Cột autovacuum_count là cột đáng nhìn nhất. Nếu nó bằng 0 hoặc rất nhỏ trên một bảng ghi nhiều, autovacuum chưa từng hoàn thành lượt nào ở đó — và lý do gần như chắc chắn là một trong hai điều ở bước 1 và 2 phía trên, không phải ngưỡng.

Phần sau đo transaction ID wraparound: chuyện gì xảy ra khi số hiệu giao dịch chạy hết vòng, và vì sao nó là lý do PostgreSQL bắt buộc phải dọn dù bạn có muốn hay không.