Suốt sê-ri, "index" luôn nghĩa là B-tree — cây cân bằng tuyệt vời cho =, <, > và sắp thứ tự. Nhưng B-tree không phải loại index duy nhất, và có những truy vấn nó hoàn toàn không giúp được dù cột có index. Bài này đo một trong số đó — tìm kiếm toàn văn — và vấp đúng cái bẫy "cứ đánh index lên cột là xong", lần này ở một dạng mới: sai loại index.
B-tree không phải mọi thứ
B-tree sắp xếp giá trị theo thứ tự, nên nó trả lời nhanh các câu hỏi về thứ tự và bằng nhau: "bằng giá trị này", "nhỏ hơn", "trong khoảng", "sắp theo cột này". Nhưng nó chỉ hiểu cả giá trị như một khối — nó không có khái niệm "bên trong giá trị này có gì". Với một chuỗi văn bản, B-tree biết "con mèo" đứng trước "con chó" theo abc, nhưng không biết cả hai chứa từ "con". PostgreSQL vì thế có thêm vài loại index cho các kiểu truy vấn khác:
- HASH: chỉ phục vụ
=(băm giá trị). Không hỗ trợ<,>,ORDER BY, nhưng gọn hơn B-tree cho khóa lớn khi bạn chỉ cần so bằng. - GIN (Generalized Inverted Index): cho dữ liệu chứa nhiều phần tử bên trong — văn bản (mỗi tài liệu chứa nhiều từ), mảng (mỗi hàng chứa nhiều phần tử),
jsonb(chứa nhiều khóa). Nó lập chỉ mục từng phần tử riêng, nên tra "chứa từ X" hay "chứa phần tử Y" rất nhanh. - GiST: cho dữ liệu hình học, khoảng (range), và tìm lân cận — điểm trên bản đồ, khoảng thời gian chồng lấn, "5 điểm gần nhất".
Đo: B-tree bó tay, GIN bay
Tôi tạo bảng 200.000 tài liệu, mỗi tài liệu là một chuỗi vài từ ngẫu nhiên từ một từ vựng 5000 từ. Rồi tìm "các tài liệu chứa từ tu777" bằng tìm kiếm toàn văn (to_tsvector(...) @@ to_tsquery('tu777')), đo EXPLAIN ANALYZE qua ba tình huống:
| Tình huống | Kế hoạch | Thời gian |
|---|---|---|
| Không index | Seq Scan | 141 ms |
| Có B-tree trên cột text | Seq Scan (vẫn!) | 141 ms |
Có GIN trên to_tsvector(...) |
Bitmap Index Scan | 0,28 ms |
Không index, truy vấn quét tuần tự cả 200.000 dòng, dựng tsvector cho từng dòng, mất 141 ms. Thêm một B-tree trên cột văn bản — và truy vấn vẫn Seq Scan 141 ms, y hệt: B-tree bị bỏ qua hoàn toàn. Chỉ khi tạo GIN trên to_tsvector(...), kế hoạch chuyển sang Bitmap Index Scan và thời gian rơi xuống 0,28 ms — nhanh khoảng 500 lần. Chỉ mục GIN tốn ~4,5 MB (lớn hơn B-tree tương ứng vì nó lập chỉ mục từng từ trong mỗi tài liệu), nhưng đó là cái giá xứng đáng để biến 141 ms thành 0,28 ms — nửa mili giây cho một tìm kiếm trên hai trăm nghìn tài liệu.
Một lần tôi đo hớ: đúng cột, sai loại index
Cái bẫy tôi bước vào rất tự nhiên. Tôi có một cột văn bản và muốn tìm tài liệu chứa một từ. Phản xạ, như mọi khi trong sê-ri này, là CREATE INDEX ON docs(noi_dung) — và mặc định của lệnh đó là một B-tree. Tôi yên tâm rằng giờ đã có index, truy vấn sẽ nhanh.
Nhưng đo ra vẫn Seq Scan 141 ms — B-tree không được dùng chút nào. Phản ứng đầu: "ủa, có index trên đúng cột rồi mà sao không nhanh?". Đây là biến thể tinh vi của bài học ở phần 5: ở đó index bị bỏ vì truy vấn không chọn lọc; ở đây index bị bỏ vì sai loại cho toán tử truy vấn.
Lý do rõ khi nhìn vào cách B-tree lưu dữ liệu: nó sắp cả chuỗi "tu12 tu777 tu34 ..." như một đơn vị, theo thứ tự abc. Nó có thể tìm nhanh "chuỗi bằng đúng "tu12 tu777 ..."" hoặc "chuỗi bắt đầu bằng...", nhưng câu hỏi của tôi là "chứa từ tu777" — mà tu777 nằm ở giữa chuỗi, không phải đầu. B-tree không có cách nào nhảy tới "mọi chuỗi có tu777 ở đâu đó bên trong"; nó phải quét hết. Toán tử @@ (khớp toàn văn) đơn giản không nằm trong khả năng của B-tree, nên planner bỏ qua index đó. GIN thì ngược lại: nó lập chỉ mục từng từ như một mục riêng trỏ tới các tài liệu chứa từ đó (giống mục lục ngược ở cuối sách), nên "chứa tu777" là một tra cứu trực tiếp.
Cái tôi đo hớ là cho rằng "có index trên cột là đủ", quên rằng loại index phải khớp toán tử. CREATE INDEX mặc định ra B-tree, và B-tree câm lặng trước @@, @> (chứa mảng/jsonb), hay LIKE '%x%'. Bài học đo lường: khi một index "đúng cột" mà không được dùng, đừng chỉ nghĩ "thiếu index" — hỏi loại index có khớp phép toán truy vấn không. EXPLAIN cho bạn câu trả lời ngay: nếu vẫn Seq Scan sau khi thêm index, rất có thể bạn tạo nhầm loại.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: tìm kiếm toàn văn, mảng, và jsonb cần GIN, không phải B-tree. Nếu ứng dụng của bạn tìm "bài viết chứa từ khóa", lọc "hàng có tag X trong mảng tags", hay truy vấn "jsonb có khóa/giá trị Y", một B-tree thường vô dụng — bạn cần CREATE INDEX ... USING gin(...). Đây là lỗi rất phổ biến khiến tìm kiếm toàn văn hay lọc jsonb chậm chạp: có index nhưng sai loại. Riêng LIKE '%x%' (chứa chuỗi con bất kỳ) cũng không dùng được B-tree thường — cần GIN với pg_trgm (chỉ mục theo bộ ba ký tự).
Hệ quả thứ hai: mỗi loại index có đánh đổi riêng, chọn theo dạng dữ liệu và truy vấn. GIN tra cứu "chứa" cực nhanh nhưng xây chậm hơn và lớn hơn B-tree, và cập nhật (ghi) tốn hơn — hợp với dữ liệu đọc nhiều hơn ghi. HASH gọn cho = trên khóa rất lớn nhưng không làm được gì khác. GiST cho bài toán không gian/khoảng mà B-tree không mô tả nổi (hai khoảng thời gian có chồng nhau không, điểm nào gần nhất). Biết bảng "loại nào cho toán tử nào" giúp bạn chọn đúng ngay từ đầu thay vì tạo một B-tree vô dụng rồi ngồi hỏi vì sao chậm.
Hệ quả thứ ba là bài học đo lường. Con số mang theo: loại index phải khớp toán tử truy vấn — tìm "chứa từ" trên 200k dòng không index mất 141ms, một B-tree cũng 141ms (bị bỏ qua vì B-tree không hiểu @@), nhưng GIN chỉ 0,28ms, nhanh ~500 lần. "Có index trên cột chưa" là câu hỏi sai; câu đúng là "loại index có phục vụ phép toán mình dùng không". EXPLAIN là trọng tài — vẫn Seq Scan sau khi thêm index nghĩa là sai loại.
Thử ba mươi giây
Nếu bạn có một cột văn bản mà ứng dụng tìm kiếm theo từ khóa, hay một cột mảng/jsonb bạn lọc theo "chứa", chạy EXPLAIN (ANALYZE) truy vấn tìm kiếm đó — nhiều khả năng bạn thấy Seq Scan dù đã có index B-tree trên cột. Thử tạo index đúng loại: CREATE INDEX ... USING gin(to_tsvector('simple', cot)) cho toàn văn, hoặc USING gin(cot) cho mảng/jsonb, rồi chạy lại. Nếu kế hoạch chuyển sang Bitmap Index Scan và thời gian tụt hàng trăm lần, bạn vừa sửa đúng loại index. Và mẹo nhận biết: sau khi thêm bất kỳ index nào, luôn EXPLAIN lại — nếu vẫn Seq Scan, index của bạn không khớp truy vấn, và loại index thường là thủ phạm.