Bạn có index trên cột email, viết WHERE lower(email) = 'abc@mail.com' để tra không phân biệt hoa thường — và truy vấn chậm bất ngờ. Index vẫn đó, nhưng PostgreSQL không dùng. Đây là một trong những lỗi tối ưu phổ biến và âm thầm nhất: bọc một hàm quanh cột trong WHERE làm index trên cột đó thành vô dụng. Bài này đo thật cái giá, giải thích khái niệm "sargable", và trình bày hai cách gỡ — một trong hai còn không cần index mới.

Vì sao index mất tác dụng: nó lưu cột, không lưu hàm

Nguyên tắc gốc rất đơn giản: một index trên email lưu giá trị nguyên gốc của cột — 'User777@Mail.com' — được sắp xếp. Nó không lưu lower(email). Khi bạn viết WHERE lower(email) = 'user777@mail.com', PostgreSQL không có cấu trúc nào sắp theo lower(email) để tra nhanh, nên nó buộc phải tính lower() cho từng dòng rồi so — tức quét cả bảng.

SELECT id FROM tk WHERE lower(email) = 'user777777@mail.com';  -- index vô dụng, Seq Scan
SELECT id FROM tk WHERE email = 'User777777@Mail.com';         -- cột trần, Index Scan tức thì

Ảnh chụp đoạn mã SQL nền tối minh hoạ hàm bọc quanh cột làm mất index và cách gỡ, quy tắc index lưu giá trị cột không lưu giá trị của hàm index trên email lưu User777 Mail.com không lưu lower SELECT id FROM tk WHERE lower email bằng user777 mail.com planner phải tính lower cho từng dòng index vô dụng Seq Scan, SELECT id FROM tk WHERE email bằng User777 Mail.com cột trần khớp thẳng index Index Scan tức thì, điều kiện sargable cột đứng một mình một vế mất index lower email bằng date tao_luc bằng tao_luc cộng interval một ngày lớn hơn now gia nhân 2 lớn hơn 100 dùng index email bằng tao_luc lớn hơn bằng AND tao_luc nhỏ hơn gia lớn hơn 50 tao_luc lớn hơn now trừ interval một ngày đưa phép tính sang vế phải để cột trần bên trái, cách sửa 1 index biểu thức khi buộc phải dùng hàm tra không phân biệt hoa thường index chính biểu thức đó CREATE INDEX idx_lower_email ON tk lower email giờ lower email bằng khớp index Index Scan, cách sửa 2 viết lại thành khoảng không cần index mới thay date tao_luc bằng 2021-06-24 bằng nửa khoảng WHERE tao_luc lớn hơn bằng timestamp 2021-06-24 AND tao_luc nhỏ hơn timestamp 2021-06-25 dùng luôn index có sẵn trên tao_luc không tốn index thứ hai

Hình 1: Index lưu giá trị cột nguyên gốc, không lưu giá trị hàm. Điều kiện "sargable" giữ cột đứng một mình một vế. Hai cách gỡ: index biểu thức, hoặc viết lại thành khoảng.

Đo thật: 160 ms so với 0,036 ms

Bảng tk 3 triệu dòng, có index trên email và tao_luc. Cùng ý định "tìm một tài khoản", hai cách viết:

Ảnh chụp bảng kết quả đo thật nền tối hàm bọc quanh cột tk 3 triệu dòng PostgreSQL 16 index có sẵn email tao_luc EXPLAIN ANALYZE BUFFERS shared_buffers 128MB, ca chữ hoa thường lower email WHERE lower email bằng user777777 mail.com Parallel Seq Scan Rows Removed 1 triệu nhân 3 160 mili giây, email bằng User777777 Mail.com cột trần Index Scan using idx_tk_email 0,036 mili giây, lower email bằng cộng CREATE INDEX lower email Index Scan using idx_lower_email 0,035 mili giây, ca ngày tháng date tao_luc WHERE date tao_luc bằng 2021-06-24 Parallel Seq Scan Rows Removed 999040 nhân 3 47,6 mili giây, tao_luc lớn hơn bằng 2021-06-24 AND nhỏ hơn 2021-06-25 Index Only Scan using idx_tk_tao 2880 dòng 0,433 mili giây, viết lại thành nửa khoảng dùng luôn index tao_luc có sẵn khoảng 110 lần nhanh hơn không tốn index mới, cốt lõi index lưu giá trị cột không lưu giá trị hàm bọc hàm quanh cột trong WHERE lower date phép cộng buộc PostgreSQL tính lại cho từng dòng Seq Scan toàn bảng gỡ giữ cột trần một vế viết lại thành khoảng hoặc đánh index biểu thức khi buộc phải dùng hàm

Hình 2: lower(email)=... chạy Parallel Seq Scan toàn bảng 160 ms; email=... cột trần dùng Index Scan 0,036 ms. Với ngày tháng, date(tao_luc)=... seq scan 47,6 ms so với viết lại thành khoảng Index Only Scan 0,433 ms — nhanh hơn ~110 lần, không cần index mới.

  • lower(email) = '...': Parallel Seq Scan, Rows Removed by Filter: 1.000.000 mỗi worker — 160 ms. Index trên email nằm im.
  • email = '...' (cột trần, đúng chữ hoa): Index Scan using idx_tk_email — 0,036 ms. Nhanh hơn ~4.400 lần.

Điều đáng nói: chỉ khác nhau ở việc có lower() bọc quanh cột hay không. Bản thân cột và index không đổi.

Điều kiện "sargable": cột đứng một mình một vế

Từ chuyên môn cho "điều kiện dùng được index" là sargable (Search ARGument able). Quy tắc thực dụng: giữ cột trần ở một vế của phép so sánh, đưa mọi phép tính sang vế kia.

Mất index (không sargable) Dùng index (sargable)
lower(email) = '...' email = '...'
date(tao_luc) = '2021-06-24' tao_luc >= '2021-06-24' AND tao_luc < '2021-06-25'
tao_luc + interval '1 day' > now() tao_luc > now() - interval '1 day'
gia * 2 > 100 gia > 50

Mấu chốt: PostgreSQL không tự "đảo ngược" hàm để khôi phục dạng sargable — bạn phải tự viết lại. tao_luc + interval '1 day' > now() và tao_luc > now() - interval '1 day' cho cùng kết quả, nhưng chỉ dạng thứ hai dùng được index vì cột tao_luc đứng trần bên trái.

Cách sửa 1: index biểu thức

Khi bạn buộc phải dùng hàm — ví dụ tra email không phân biệt hoa thường là yêu cầu thật — đánh index thẳng lên chính biểu thức đó:

CREATE INDEX idx_lower_email ON tk (lower(email));

Giờ index lưu sẵn giá trị lower(email) đã sắp xếp, nên WHERE lower(email) = '...' khớp thẳng vào nó. Đo thật: sau khi tạo index này, cùng truy vấn chạy Index Scan using idx_lower_email — 0,035 ms, ngang với cột trần. Index biểu thức là công cụ chính cho tra không phân biệt hoa thường, tra theo date() cố định, hay bất kỳ hàm tất định nào.

Cách sửa 2: viết lại thành khoảng (không cần index mới)

Với hàm trên ngày tháng, thường có cách gỡ tốt hơn cả index biểu thức: viết lại thành nửa-khoảng. Thay vì date(tao_luc) = '2021-06-24', dùng:

WHERE tao_luc >= timestamp '2021-06-24' AND tao_luc < timestamp '2021-06-25';

Đo thật: date(tao_luc)=... seq scan 47,6 ms; bản viết lại chạy Index Only Scan using idx_tk_tao chỉ đọc 2.880 dòng đúng khoảng — 0,433 ms, nhanh hơn ~110 lần. Ưu điểm lớn: nó dùng index tao_luc đã có sẵn, không cần đánh thêm index thứ hai (tiết kiệm chi phí ghi và dung lượng). Với dữ liệu thời gian, đây thường là lựa chọn đầu tiên nên thử.

Đánh đổi cần cân nhắc

Index biểu thức có chi phí ghi và phải khớp chính xác. Nó phải được cập nhật mỗi lần INSERT/UPDATE như mọi index. Quan trọng hơn, truy vấn phải dùng đúng biểu thức đã index: index trên lower(email) không giúp upper(email) hay lower(trim(email)). Biểu thức trong truy vấn và trong index phải trùng khớp.

Viết lại thành khoảng chỉ áp dụng cho hàm "bảo toàn thứ tự". date(), cắt theo giờ, phép cộng/trừ hằng số — những hàm giữ nguyên thứ tự — viết lại được thành khoảng. lower() hay md5() thì không (chúng xáo trộn thứ tự), nên với chúng chỉ còn cách index biểu thức. Chọn công cụ theo bản chất hàm.

Cẩn thận ép kiểu ngầm — nó cũng là một dạng "hàm ẩn". Một điều kiện như cot_varchar = 123 (so cột text với số) khiến PostgreSQL chèn ép kiểu ngầm, và tùy phía nào bị ép mà index có thể mất tác dụng y như bọc hàm. Đó là chủ đề của bài sau.

Ba ý mang về

  1. Index lưu giá trị cột, không lưu giá trị hàm — bọc lower(), date(), hay phép cộng quanh cột trong WHERE buộc PostgreSQL tính lại cho từng dòng và rơi về Seq Scan: đo thật lower(email)=... chạy 160 ms so với 0,036 ms của cột trần.
  2. Giữ điều kiện sargable: cột đứng trần một vế, phép tính dồn sang vế kia — tao_luc > now() - interval '1 day' dùng index, còn tao_luc + interval '1 day' > now() thì không, dù cho cùng kết quả.
  3. Hai cách gỡ: index biểu thức khi buộc phải dùng hàm (CREATE INDEX ON tk(lower(email)) đưa về 0,035 ms), hoặc viết lại thành khoảng cho hàm ngày tháng (date()=... 47,6 ms thành khoảng Index Only Scan 0,433 ms, ~110 lần nhanh hơn, không cần index mới).

Phần sau ta xét một dạng "hàm ẩn" tinh vi hơn, không thấy bằng mắt: Phần sau đo vì sao so sánh cột với sai kiểu dữ liệu khiến PostgreSQL chèn ép kiểu ngầm và âm thầm vô hiệu hóa index.