Họ đang đo gì
Bạn có đọc EXPLAIN thật không. Người chưa từng đọc sẽ trả lời bằng cảm giác; người từng đọc sẽ hỏi lại “kế hoạch nói gì, và bảng có bao nhiêu hàng”.
Trả lời ngắn~30 giây
Thường là một trong bốn: cột bị bọc trong hàm nên index trên cột gốc vô dụng; LIKE '%x' có ký tự đại diện ở đầu nên không dùng được thứ tự B-tree; điều kiện lọc ra quá nhiều hàng nên planner tính quét tuần tự còn rẻ hơn; hoặc index ghép sai thứ tự cột. Trường hợp thứ ba không phải lỗi — đó là planner làm đúng việc của nó.
Bảng có 1,000,000 hàng, khoảng 11,111 trang. Mỗi ô dưới đây là một nhóm trang.
Giải thích sâu
B-tree sắp xếp theo GIÁ TRỊ CỦA CỘT. Ngay khi bạn viết WHERE lower(email) = $1, thứ được so sánh không còn là giá trị đã sắp xếp nữa, nên engine không có cách nào đi xuống cây. Cách sửa không phải là bỏ hàm mà là đánh index đúng thứ đang được so sánh: CREATE INDEX ON users (lower(email)). Cùng lý do đó, WHERE created_at::date = '2026-08-16' không dùng được index trên created_at, còn WHERE created_at >= '2026-08-16' AND created_at < '2026-08-17' thì dùng được.
Ký tự đại diện đứng đầu là cùng một vấn đề nhìn từ góc khác. LIKE 'nguyen%' biết được điểm bắt đầu trên cây nên quét được một khoảng; LIKE '%nguyen' thì không, vì các chuỗi kết thúc bằng “nguyen” nằm rải khắp thứ tự từ điển. Muốn tìm kiếm kiểu đó thì cần cấu trúc khác: trigram (pg_trgm) hoặc full-text index, chứ không phải B-tree.
Trường hợp gây tranh cãi nhất là độ chọn lọc. Nếu status = 'active' khớp 95% bảng, dùng index nghĩa là đọc gần hết index RỒI nhảy ngẫu nhiên vào heap để lấy từng hàng — chậm hơn hẳn đọc tuần tự theo khối. Planner ước lượng điều này từ thống kê, nên khi kế hoạch bỗng xấu đi, việc đầu tiên cần kiểm tra là thống kê có cũ không (ANALYZE), chứ không phải thêm index mới.
Cuối cùng là index ghép. (a, b) phục vụ được WHERE a = …, WHERE a = … AND b = …, nhưng không phục vụ được WHERE b = … một mình — đúng như bạn không thể tra danh bạ theo tên khi nó sắp theo họ. Quy tắc thường dùng là cột lọc bằng đứng trước, cột lọc khoảng đứng sau, vì sau một điều kiện khoảng thì các cột phía sau không còn thu hẹp được nữa.
Đọc kế hoạch trước, sửa sau
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM users WHERE lower(email) = 'an@example.com';
-- Seq Scan on users (cost=0.00..24418 rows=1 width=…)
-- (actual time=182.4..182.4 rows=1 loops=1)
-- Buffers: shared hit=12 read=14406 <- đọc 14k trang cho 1 hàng
CREATE INDEX users_email_lower_idx ON users (lower(email));
-- -> Index Scan using users_email_lower_idx (actual time=0.03..0.04 rows=1)
-- Buffers: shared hit=4Con số cần nhìn là Buffers, không phải cost: 14.406 trang xuống còn 4.
Câu hỏi tiếp theo họ sẽ hỏi
?Vậy cứ thêm index cho mọi cột hay lọc là xong?
Không. Mỗi index là một cấu trúc phải ghi thêm ở mọi INSERT/UPDATE/DELETE, chiếm bộ nhớ đệm, và làm planner có thêm lựa chọn để chọn sai. Bảng ghi nhiều mà mười index thì INSERT trả giá gấp nhiều lần. Index là đánh đổi đọc–ghi, không phải nút tăng tốc.
?“Index-only scan” là gì?
Khi index chứa đủ mọi cột truy vấn cần, engine trả lời thẳng từ index mà không chạm vào heap. Đó là lý do có INCLUDE (Postgres) hay covering index (MySQL). Ở Postgres nó còn cần visibility map đủ mới, nên bảng vừa ghi nhiều mà chưa VACUUM thì lợi ích biến mất.
?Bảng có 500 hàng, vì sao index không được dùng?
Vì cả bảng nằm gọn trong vài trang, đọc tuần tự xong trước khi đi cây kịp có ích. Đây là kết quả đúng. Đo hiệu năng index trên bảng đồ chơi luôn cho kết luận sai.
Trả lời thế này là mất điểm
- Đề xuất
FORCE INDEX/ index hint làm cách sửa đầu tiên. Nó che triệu chứng và khoá cứng kế hoạch lại khi dữ liệu đổi. - Nói “planner bị lỗi”. Đôi khi đúng, nhưng trong 95% trường hợp thống kê cũ hoặc index sai thiết kế mới là nguyên nhân.
Trả lời thế này là ghi điểm
- Hỏi ngược “bảng bao nhiêu hàng, truy vấn lọc ra bao nhiêu phần trăm?” trước khi đoán. Đó là câu hỏi đầu tiên của người từng làm thật.