Lập trình · 03/10/2026

PostgreSQL HOT updates thực chiến: Giảm index bloat bằng fillfactor

Một câu lệnh UPDATE trong PostgreSQL thường tạo phiên bản tuple mới. Nếu mọi index đều phải nhận entry mới, workload cập nhật dày có thể làm tăng write amplification, index bloat và áp lực VACUUM. HOT (Heap-Only Tuple) giúp bỏ qua phần cập nhật index khi thay đổi thỏa đúng điều kiện; fillfactor là một trong những đòn bẩy quan trọng để tạo đủ chỗ trống trên heap page cho HOT.

PostgreSQL HOT updates thực chiến: Giảm index bloat bằng fillfactor

Một câu lệnh UPDATE trong PostgreSQL thường tạo phiên bản tuple mới. Nếu mọi index đều phải nhận entry mới, workload cập nhật dày có thể làm tăng write amplification, index bloat và áp lực VACUUM. HOT (Heap-Only Tuple) giúp bỏ qua phần cập nhật index khi thay đổi thỏa đúng điều kiện; fillfactor là một trong những đòn bẩy quan trọng để tạo đủ chỗ trống trên heap page cho HOT.

Bài viết này trình bày cách HOT hoạt động, cách đo bằng pg_stat_user_tables, cách chọn fillfactor và quy trình rollout không đánh đổi mù quáng dung lượng để lấy một tỷ lệ đẹp. Nội dung dựa trên tài liệu chính thức về HOT, thống kê bảng, CREATE TABLE và VACUUM thường kỳ.

1. Vì sao UPDATE có thể đắt?

PostgreSQL dùng MVCC: thay vì ghi đè tuple mà transaction khác có thể đang đọc, UPDATE tạo một phiên bản tuple mới và đánh dấu phiên bản cũ. Cách làm này giữ snapshot nhất quán nhưng phát sinh hai nhóm công việc:

  • ghi tuple mới vào heap và cuối cùng thu dọn tuple chết;
  • tạo entry index mới để truy cập phiên bản mới, nếu update không đủ điều kiện HOT.

Với bảng có nhiều index, một update logic có thể kéo theo nhiều lần ghi vật lý. Tỷ lệ update cao, transaction sống lâu và autovacuum không theo kịp sẽ khuếch đại tác động. Tuy nhiên, không nên quy mọi bloat cho HOT: delete, insert, transaction cũ, cấu hình autovacuum và kiểu workload đều có vai trò.

2. HOT thực sự làm gì?

Khi update là HOT, PostgreSQL đặt phiên bản mới trong cùng heap page và nối nó vào chuỗi phiên bản của tuple. Index entry hiện có tiếp tục dẫn tới chuỗi này, nên hệ thống không cần tạo index entry mới cho phiên bản vừa cập nhật.

Hai lợi ích chính là giảm ghi index và cho phép các phiên bản trung gian trong chuỗi HOT được prune trong quá trình vận hành bình thường khi chúng không còn được transaction nào nhìn thấy. HOT không biến update thành in-place update và không loại bỏ hoàn toàn dead tuples.

3. Hai điều kiện để update trở thành HOT

  1. Không thay đổi cột được index tham chiếu. Điều này bao gồm index thường, biểu thức, predicate của partial index và cột INCLUDE khi chúng tham chiếu cột đang đổi. Tài liệu hiện hành có ngoại lệ cho summarizing indexes như BRIN.
  2. Heap page cũ còn đủ chỗ. Phiên bản mới phải nằm trên cùng page với phiên bản cũ. Nếu page đã kín, PostgreSQL phải đặt tuple ở page khác và update đó không thể là HOT.

Điều kiện thứ nhất là bài toán thiết kế index; điều kiện thứ hai là nơi fillfactor phát huy tác dụng. HOT là quyết định cho từng update, không phải thuộc tính bật/tắt một lần cho cả bảng.

4. Đo HOT ratio trước khi thay đổi

SELECT schemaname,
       relname,
       n_tup_upd,
       n_tup_hot_upd,
       round(100.0 * n_tup_hot_upd
             / NULLIF(n_tup_upd, 0), 2) AS hot_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 0
ORDER BY n_tup_upd DESC;

n_tup_upd là số row được update ước tính; n_tup_hot_upd là phần được thực hiện theo HOT. Trên phiên bản PostgreSQL hiện hành có trường n_tup_newpage_upd, hữu ích để nhận diện update phải đặt tuple mới lên page khác:

SELECT relname,
       n_tup_upd,
       n_tup_hot_upd,
       n_tup_newpage_upd,
       round(100.0 * n_tup_newpage_upd
             / NULLIF(n_tup_upd, 0), 2) AS new_page_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 0
ORDER BY n_tup_newpage_upd DESC;

Nếu phiên bản đang dùng chưa có cột này, hãy bỏ nó khỏi truy vấn. Các counter là số liệu tích lũy và có thể trễ; chúng cũng có thể bị reset sau một số sự kiện. So sánh delta theo cùng một cửa sổ thời gian sẽ đáng tin hơn việc nhìn một snapshot. Trong transaction dài, gọi pg_stat_clear_snapshot() trước khi đọc lại nếu cần số liệu mới.

5. Tìm index đang chặn HOT

SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
  AND tablename = 'accounts'
ORDER BY indexname;

Đối chiếu danh sách cột được update thường xuyên với toàn bộ định nghĩa index. Một index trên updated_at, biểu thức như lower(email), predicate của partial index hoặc cột INCLUDE đều có thể khiến update liên quan không đủ điều kiện HOT.

Không xóa index chỉ để tăng HOT ratio. Hãy dùng pg_stat_user_indexes, execution plan và lịch sử truy vấn để chứng minh index không phục vụ read path, constraint hay vận hành. Chi phí đọc tăng có thể lớn hơn khoản tiết kiệm khi ghi.

6. Fillfactor tạo khoảng thở trên heap page

fillfactor của table nằm trong khoảng 10–100 và mặc định là 100. Giá trị 85 hướng thao tác nạp/ghi lại bảng chỉ dùng khoảng 85% page, dành phần còn lại cho phiên bản tuple mới trên cùng page:

ALTER TABLE accounts SET (fillfactor = 85);

Lệnh này thay đổi storage parameter nhưng không lập tức viết lại các page hiện có. Page mới và page được hình thành qua vòng đời dữ liệu sẽ dần phản ánh chính sách mới. Nếu cần hiệu lực đồng đều ngay, một thao tác rewrite như VACUUM FULL hoặc CLUSTER có thể tái bố trí bảng, nhưng chúng có chi phí I/O, WAL, lock và dung lượng tạm đáng kể. Chỉ thực hiện trong kế hoạch migration đã diễn tập.

7. Vì sao fillfactor càng thấp chưa chắc càng tốt?

Chừa nhiều khoảng trống làm bảng lớn hơn, giảm số tuple trên mỗi page và có thể tăng cache miss, sequential scan lẫn I/O. Một bảng append-only hoặc gần như không update thường không hưởng lợi đủ để bù chi phí này.

Thay vì chọn 70 theo cảm tính, hãy thử 90, 85 và 80 trên workload đại diện. Đo đồng thời:

  • HOT ratio và new-page update ratio;
  • TPS, p95/p99 latency và WAL bytes;
  • kích thước heap/index, cache hit và read I/O;
  • dead tuples, thời gian và tần suất autovacuum.

Mục tiêu là tối ưu tổng chi phí, không phải đạt HOT 100%.

8. HOT không thay thế VACUUM

HOT vẫn tạo phiên bản tuple cũ. Pruning có thể loại bỏ các phiên bản trung gian khi an toàn, nhưng VACUUM/autovacuum vẫn cần để thu hồi dead tuples, cập nhật visibility map và bảo vệ transaction ID. Một transaction hoặc replication slot giữ horizon quá lâu có thể trì hoãn việc dọn dẹp dù HOT ratio cao.

SELECT relname,
       n_live_tup,
       n_dead_tup,
       last_autovacuum,
       autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

Dùng số liệu này cùng log autovacuum và tuổi transaction. Extension pgstattuple có thể đo chi tiết hơn nhưng cần quyền và có chi phí quét; chỉ dùng theo runbook của hệ thống.

9. Thí nghiệm có kiểm soát

CREATE TABLE hot_demo (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_id bigint NOT NULL,
    payload text NOT NULL,
    updated_at timestamptz NOT NULL DEFAULT now()
) WITH (fillfactor = 80);

CREATE INDEX hot_demo_tenant_idx ON hot_demo (tenant_id);

INSERT INTO hot_demo (tenant_id, payload)
SELECT g % 100, repeat('a', 200)
FROM generate_series(1, 100000) AS g;

-- payload không được index: có cơ hội HOT nếu page còn chỗ
UPDATE hot_demo
SET payload = repeat('b', 200)
WHERE id % 10 = 0;

SELECT n_tup_upd, n_tup_hot_upd, n_tup_newpage_upd
FROM pg_stat_user_tables
WHERE relname = 'hot_demo';

Hãy commit update trước khi đọc counter từ session khác. Chạy lại với fillfactor 100 và 80 trên database thử nghiệm có cấu hình, dữ liệu và concurrency gần production. Không reset thống kê toàn cluster chỉ để làm dashboard đẹp.

10. Quy trình rollout an toàn

  1. Chọn bảng update-heavy có đủ volume để kết quả có ý nghĩa.
  2. Ghi baseline theo cùng cửa sổ tải: HOT ratio, WAL, latency, bloat và autovacuum.
  3. Kiểm tra cột bị update và mọi index tham chiếu chúng.
  4. Thử mức fillfactor bảo thủ, thường bắt đầu 90 hoặc 85 trên staging/canary.
  5. Quyết định có cần rewrite hay chấp nhận hiệu lực dần; dự toán lock, WAL, replica lag và disk headroom.
  6. Rollout một bảng, quan sát ít nhất một chu kỳ tải và autovacuum đầy đủ.
  7. Giữ ngưỡng rollback dựa trên latency, I/O và dung lượng, không chỉ HOT ratio.

Checklist chẩn đoán nhanh

  • HOT ratio được tính theo delta trong cùng cửa sổ thời gian.
  • Đã kiểm tra index thường, expression, partial predicate và INCLUDE.
  • Không đánh đổi index phục vụ truy vấn quan trọng chỉ để tăng HOT.
  • Fillfactor được benchmark ở nhiều mức cùng dữ liệu thực tế.
  • Đã dự toán kích thước bảng, cache density, WAL và replica lag.
  • Autovacuum và transaction horizon vẫn được theo dõi.
  • Rewrite chỉ chạy với kế hoạch lock, disk và rollback rõ ràng.

Kết luận

HOT là tối ưu hóa rất giá trị cho bảng PostgreSQL cập nhật thường xuyên: giữ phiên bản mới trên cùng heap page và tránh ghi lại index khi các cột được index không đổi. Hiệu quả thực tế đến từ ba việc phối hợp: thiết kế index có chủ đích, dành vừa đủ không gian bằng fillfactor và vận hành VACUUM đúng. Đo trên workload thật, thay đổi từng bước và tối ưu tổng chi phí hệ thống sẽ bền vững hơn việc chạy theo một HOT ratio tuyệt đối.

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.