Ở bài giao dịch dài ta thấy bảng có thể phình vì hàng chết không được dọn. Nhưng bảng không phải thứ duy nhất phình: chỉ mục (index) cũng vậy, và nó phình theo một cách âm thầm hơn — vì VACUUM dọn được hàng chết trong bảng nhưng không nén lại được cây index. Bài này đo trực tiếp: một index phình gấp đôi sau khi cập nhật nhiều, VACUUM chạy đều mà không cứu được, và chỉ REINDEX mới đưa nó về gọn. Và tôi đã suýt bỏ sót toàn bộ chuyện này vì chỉ nhìn vào bảng.
Index cũng có "entry chết"
Một index B-tree (đã gặp ở bài chỉ mục) là một cây các entry trỏ tới hàng trong bảng. Khi bạn UPDATE một cột được đánh index, PostgreSQL không sửa entry tại chỗ: nó thêm một entry mới trỏ tới phiên bản mới của hàng, và entry cũ trở thành entry chết — y như hàng chết trong bảng. Xóa hàng cũng để lại entry chết trong index.
VACUUM dọn những entry chết đó, nhưng đây là chỗ mấu chốt: nó chỉ đánh dấu chúng để tái dùng, chứ không nén lại cây và không trả các trang trống về đĩa. Kết quả là các trang lá của index trở nên nửa rỗng — nhiều trang hơn mức cần để chứa cùng số entry sống. Đó là index bloat (chỉ mục phình). Bảng thì VACUUM giữ bloat trong tầm kiểm soát khá tốt vì heap dễ tái dùng chỗ trống tại chỗ; còn cây B-tree, một khi các trang lá đã tách và rải ra, chỉ được dồn lại khi dựng mới — đó là khác biệt cốt lõi khiến index cần một công cụ riêng.
Một lần tôi đo hớ: bảng gọn nhưng index kẹt ở gấp đôi
Tôi dựng bảng 200.000 hàng với một index trên cột k, tắt autovacuum để chỉ VACUUM tay tác động, rồi lặp 8 vòng: mỗi vòng UPDATE toàn bộ cột k (buộc index churn) rồi VACUUM. Tôi theo dõi kích thước bảng sau mỗi vòng:
bảng sau mỗi vòng: 14 MB, 14 MB, 14 MB, ... (đứng yên)
Bảng ổn định ở 14 MB suốt tám vòng. Nhìn con số đó, phản xạ của tôi là "VACUUM đang làm tốt, mọi thứ gọn sạch" — và tôi suýt dừng ở đó. Nhưng khi đo index, câu chuyện khác hẳn:
index ban đầu: 4408 kB, mật độ lá 90%
sau vòng 1 (+VACUUM): 8792 kB, mật độ lá 45%
sau vòng 2..8: 8792 kB, mật độ lá 45% (kẹt ở đây)
Index phình gấp đôi — từ 4408 lên 8792 kB — và mật độ lá tụt từ 90% xuống 45%, nghĩa là các trang chỉ còn đầy chưa tới một nửa. Rồi nó kẹt ở đó: VACUUM sau mỗi vòng giữ nó không phình thêm (tái dùng chỗ trống), nhưng cũng không bao giờ đưa nó về 4408 kB. Cái sai của tôi là đo bảng rồi tưởng đã đo cả kho: bảng và index là hai đối tượng vật lý riêng, VACUUM xử lý chúng khác nhau, và một bảng gọn hoàn toàn có thể đi kèm một index phình gấp đôi.
Cái hại của index phình không chỉ là tốn đĩa. Vì entry nằm rải trên nhiều trang nửa rỗng, một truy vấn quét index phải đọc nhiều trang hơn cho cùng số hàng. Tôi đo một range scan trả về 49.001 hàng, và điều đáng nói là dùng số trang đọc chứ không dùng đồng hồ — vì cache nóng làm thời gian nhiễu, còn số trang thì ổn định và trung thực:
range scan 49001 hàng:
index phình (8792 kB): đọc 272 trang
sau REINDEX (4408 kB): đọc 138 trang
Đúng gấp đôi số trang — khớp chằn chặn với việc index to gấp đôi. Đây là lý do tôi tin số trang (Buffers trong EXPLAIN) hơn con số mili giây: nó đo trực tiếp lượng công việc, không bị cache che.
REINDEX nén lại cây
Cách chữa index bloat là REINDEX — dựng lại index từ đầu, gói các entry sống vào số trang tối thiểu:
REINDEX INDEX idx_k;
-> index về 4408 kB, mật độ lá 90% (như mới)
Ngay sau REINDEX, index co về kích thước gốc và range scan chỉ còn đọc 138 trang. Một lưu ý quan trọng: REINDEX thường khóa bảng khỏi ghi trong lúc chạy, nên trên production nên dùng REINDEX INDEX CONCURRENTLY — nó dựng index mới song song rồi tráo, không khóa ghi dài, đổi lại chạy chậm hơn. Đừng chạy REINDEX thường trên một bảng nóng giữa giờ cao điểm: một khóa ghi kéo dài trên bảng lớn đủ làm treo mọi request đang cần ghi, biến một việc bảo trì thành một sự cố.
HOT update: giảm phình index ngay từ đầu
Có một cơ chế giúp index đỡ phình: HOT update (heap-only tuple). Nếu bạn UPDATE một cột không nằm trong index nào, và trang còn chỗ trống cho phiên bản mới, PostgreSQL có thể cập nhật hàng mà không thêm entry index nào — vì không index nào trỏ tới cột vừa đổi. Tôi đo điều này bằng cách để bảng có fillfactor=70 (chừa 30% chỗ trống mỗi trang) rồi update một cột không index:
1.600.000 update, trong đó 1.384.937 là HOT update (~87%)
Tám mươi bảy phần trăm số update không hề đụng tới index — đó là công việc và WAL tiết kiệm được. Nhưng có một điều kiện tôi phát hiện khi đo: HOT cần chỗ trống trên trang. Lần đầu tôi thử với fillfactor mặc định (100, trang đầy), tỷ lệ HOT gần như bằng 0 — không đủ chỗ cho phiên bản mới nằm cùng trang, nên update rơi sang trang khác và buộc phải thêm entry index. HOT không tự động chỉ vì cột không được index; nó còn cần headroom.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: theo dõi kích thước index, không chỉ bảng. Một bảng bị UPDATE/DELETE nhiều có thể có index phình gấp đôi, ba lần trong khi bảng nhìn vẫn ổn. Dùng pgstatindex (hay các truy vấn bloat quen thuộc) để xem mật độ lá; mật độ tụt sâu là dấu hiệu cần REINDEX. VACUUM một mình sẽ không bao giờ đưa mật độ đó lên lại.
Hệ quả thứ hai: thiết kế để giảm churn index. Cột nào bị cập nhật liên tục thì cân nhắc đừng đánh index lên nó nếu không thật cần, và để fillfactor có headroom cho HOT update hoạt động trên các bảng cập nhật nhiều. Con số mang theo: index phình như bảng khi UPDATE/DELETE nhiều (4408 → 8792 kB, mật độ lá 90% → 45%), và VACUUM chỉ dọn entry chết để tái dùng chứ KHÔNG nén lại cây — chỉ REINDEX mới co index về gọn; index phình khiến range scan đọc 272 trang thay vì 138, và số trang (không phải đồng hồ) là bằng chứng đáng tin. Khi đo sức khỏe một bảng, luôn đo cả index của nó — bảng gọn không đảm bảo index gọn.
Thử ba mươi giây
Trong psql, bật extension: CREATE EXTENSION pgstattuple;. Chọn một index trên bảng bị cập nhật nhiều của bạn rồi chạy SELECT avg_leaf_density, leaf_fragmentation FROM pgstatindex('ten_index');. Mật độ lá gần 90% là index khỏe; xuống 50% hay thấp hơn là nó đã phình khoảng gấp đôi, và mỗi lần quét đang đọc số trang gần gấp đôi mức cần. So pg_relation_size('ten_index') trước và sau REINDEX INDEX CONCURRENTLY ten_index; — bạn sẽ thấy con số co lại ngay, và đó là dung lượng cùng thời gian đọc bạn vừa lấy lại mà không đổi một dòng dữ liệu nào.