bài phần 1 ta thấy index scan đọc 4 trang thay vì cả bảng — nhưng vẫn phải nhảy vào bảng để lấy dữ liệu. Có một mức tối ưu cao hơn: khi mọi thứ truy vấn cần đã nằm sẵn trong chỉ mục, cơ sở dữ liệu không cần chạm vào bảng chút nào. Đó là index-only scan. Bài này đo mức tiết kiệm đó, và vấp một cái bẫy khiến index-only scan "không chịu" bật lên dù tôi đã làm đúng.

Chỉ mục phủ (index-only scan)

Nhảy vào bảng, và cách né nó

Một index scan thông thường làm hai bước: đi qua cây B-tree để tìm vị trí các hàng khớp, rồi nhảy vào bảng (heap) để đọc các cột mà index không có. Bước nhảy heap đó tốn thêm trang đọc — mỗi hàng khớp có thể nằm trên một trang bảng khác nhau. Chỉ mục lưu giá trị cột được đánh index cộng con trỏ tới hàng, chứ không lưu các cột khác; nên nếu bạn SELECT một cột không có trong index, cơ sở dữ liệu buộc phải vào bảng lấy.

Index-only scan né hẳn bước đó. Nếu mọi cột mà truy vấn cần — cả trong SELECT lẫn WHERE — đều nằm trong chính chỉ mục, cơ sở dữ liệu đọc thẳng đáp án từ chỉ mục, không chạm bảng. Ta gọi index đó là chỉ mục phủ (covering index) cho truy vấn. Có hai cách tạo: index ghép nhiều cột (a, b), hoặc — gọn hơn — (a) INCLUDE (b), tức đánh index theo a nhưng đính kèm b vào lá index để đọc mà không cần dùng b làm khóa tìm.

Đo: 103 trang xuống 4 trang

Tôi dựng bảng một triệu hàng với cột a, b, c, đánh index chỉ trên a, rồi chạy SELECT b FROM t WHERE a = 42 (khớp 100 hàng) với EXPLAIN (ANALYZE, BUFFERS):

Bitmap Heap Scan on t
  Heap Blocks: exact=100
  Buffers: shared hit=100 read=3

Cơ sở dữ liệu phải đọc 100 trang heap để lấy cột b (cột b không có trong index), tổng khoảng 103 trang. Giờ tôi tạo chỉ mục phủ (a) INCLUDE (b) — giờ b đã nằm trong index — và chạy lại đúng truy vấn:

Index Only Scan using idx_ab on t
  Heap Fetches: 0
  Buffers: shared hit=4

Chỉ 4 trang, Heap Fetches: 0 — không chạm bảng lần nào. Từ 103 trang xuống 4 trang, ít hơn khoảng 26 lần. Vì đáp án (b) đã có sẵn ngay tại chỗ index tìm được a, không còn cú nhảy heap nào. Với truy vấn chạy hàng nghìn lần mỗi giây, cắt được 96% số trang đọc là khác biệt lớn về tải và độ trễ. Nhưng — như tôi sắp kể — index-only scan không bật lên ngay khi tôi vừa tạo index phủ.

Một lần tôi đo hớ: quên VACUUM

Sau khi tạo (a) INCLUDE (b), tôi chạy lại truy vấn, chắc mẩm sẽ thấy Index Only Scan. Nhưng EXPLAIN vẫn cho Bitmap Heap Scan, vẫn đọc đúng 100 trang heap y như khi chưa có index phủ. Phản xạ đầu tiên: "chắc INCLUDE không hoạt động", hoặc "index-only scan không thật sự tránh được heap". Tôi suýt kết luận index phủ là vô ích.

Theo kỷ luật, tôi dừng lại tìm biến ẩn — vì kết quả mâu thuẫn với lý thuyết rõ ràng. Thủ phạm là một cơ chế đặc thù của PostgreSQL: visibility map. Do PostgreSQL dùng MVCC (mỗi hàng có thể có nhiều phiên bản, sẽ nói kỹ ở bài sau), một hàng tồn tại trong index chưa chắc hiển thị với giao dịch hiện tại. Index không giữ thông tin phiên bản, nên để chắc chắn một hàng còn sống và nhìn thấy được, index-only scan phải tham chiếu visibility map — một bản đồ bit đánh dấu những trang bảng mà mọi hàng trong đó đều hiển thị với mọi giao dịch. Nếu trang được đánh dấu "toàn hiển thị", index-only scan tin index và bỏ qua heap; nếu chưa, nó vẫn phải nhảy vào heap kiểm từng hàng.

Và visibility map chỉ được cập nhật bởi VACUUM. Bảng của tôi vừa được nạp một triệu hàng bằng INSERT, chưa VACUUM lần nào, nên visibility map trống trơn — không trang nào được đánh dấu toàn hiển thị. Kết quả: dù index đã phủ đủ cột, index-only scan sẽ vẫn phải nhảy heap cho mọi hàng để kiểm hiển thị, nên planner tính ra nó chẳng lợi gì và chọn bitmap heap scan. Tôi chạy VACUUM t; một lần — nó quét bảng, đánh dấu các trang toàn hiển thị vào visibility map — rồi chạy lại: Index Only Scan, Heap Fetches: 0, 4 trang. Đúng như lý thuyết hứa.

Bài học đo lường: kết quả "index-only scan không hiệu quả" của tôi là do một biến ẩn — trạng thái VACUUM của bảng — chứ không phải index phủ vô dụng. Khi một tối ưu "đúng trên giấy" mà đo ra không thấy tác dụng, đừng vội kết luận lý thuyết sai; hãy tìm điều kiện môi trường chưa thỏa (ở đây là visibility map chưa được VACUUM cập nhật). Heap Fetches trong EXPLAIN ANALYZE chính là con số tố cáo điều đó: còn lớn hơn 0 nghĩa là index-only scan vẫn đang phải nhảy heap.

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

Hệ quả đầu tiên: với truy vấn nóng chạy rất nhiều, một chỉ mục phủ có thể là cú tối ưu lớn — cắt phần lớn số trang đọc bằng cách loại hẳn bước nhảy heap. Nếu bạn có một truy vấn kiểu SELECT vài_cột FROM bang WHERE dieu_kien chạy liên tục, cân nhắc đưa vài_cột vào index qua INCLUDE, biến nó thành chỉ mục phủ. Đánh đổi là index to hơn (chứa thêm dữ liệu) và ghi chậm hơn chút, nên chỉ làm cho truy vấn thật sự nóng, không rải bừa.

Hệ quả thứ hai, đặc thù PostgreSQL: index-only scan phụ thuộc VACUUM. Một bảng ghi nhiều mà autovacuum chạy không kịp sẽ có visibility map lạc hậu, khiến index-only scan thoái hóa thành index scan thường (nhảy heap) — hiệu năng tụt mà không đổi một dòng code nào. Khi thấy Heap Fetches cao trong một truy vấn lẽ ra là index-only, đó là dấu hiệu bảng cần VACUUM (hoặc chỉnh autovacuum tích cực hơn). Đây là một trong những lý do VACUUM không chỉ để dọn rác mà còn trực tiếp ảnh hưởng tốc độ đọc.

Hệ quả thứ ba là bài học đo lường chung. Con số mang theo: index phủ (mọi cột truy vấn cần đều trong index) cho index-only scan, cắt từ ~103 trang xuống 4 trang (Heap Fetches=0) — nhưng chỉ sau khi VACUUM cập nhật visibility map; chưa VACUUM thì vẫn nhảy heap. Một tối ưu có thể "đúng" mà vẫn không thấy tác dụng vì một điều kiện môi trường chưa thỏa; đo bằng EXPLAIN (ANALYZE, BUFFERS) và đọc Heap Fetches cùng số trang thật, đừng tin rằng "tạo index phủ là xong".

Thử ba mươi giây

Nếu bạn có một truy vấn chỉ đọc vài cột từ một bảng lớn, chạy EXPLAIN (ANALYZE, BUFFERS) nó và xem dòng scan: nếu là Index Scan (không phải Index Only) và có Heap Blocks/nhiều buffer, đó là nó đang nhảy heap để lấy cột thiếu. Thử tạo một chỉ mục phủ — CREATE INDEX ... ON bang (cot_dieu_kien) INCLUDE (cot_can_lay); — rồi VACUUM bang; (quan trọng!) và chạy lại. Nếu bạn thấy Index Only Scan với Heap Fetches: 0 và số buffer tụt hẳn, bạn vừa loại được bước nhảy heap. Và nhớ: nếu Heap Fetches vẫn lớn hơn 0, bảng chưa được VACUUM đủ — đó là biến ẩn bài này đo.