Index B-tree đánh chỉ mục cả giá trị của một cột — tuyệt vời cho email = '...' hay tuoi > 18, nhưng vô dụng khi bạn hỏi "sản phẩm nào có tag hot?" hoặc "jsonb nào chứa {"mau":"do"}?". Với những cột chứa nhiều giá trị bên trong một ô — jsonb, mảng, văn bản — PostgreSQL có một loại index khác: GIN (Generalized Inverted Index). Nó đánh chỉ mục từng thành phần bên trong ô: mỗi khoá/giá trị jsonb, mỗi phần tử mảng, mỗi từ trong văn bản. Bài này đo GIN trên bảng 1 triệu dòng thật.

Ý tưởng: chỉ mục ngược

GIN là một inverted index — giống mục lục cuối sách. Thay vì "dòng 5 chứa các từ [a, b, c]", nó lưu chiều ngược: "từ a xuất hiện ở các dòng [5, 8, 12]". Nhờ vậy, hỏi "dòng nào chứa a?" chỉ là một lần tra khoá, không cần quét bảng.

-- 1) jsonb: toán tử chứa @>
CREATE INDEX idx_sp_tt ON sp USING gin (thuoc_tinh jsonb_path_ops);

-- 2) mảng text[]: @> nghĩa là "chứa tất cả phần tử này"
CREATE INDEX idx_sp_tags ON sp USING gin (tags);

-- 3) full-text: GIN trên biểu thức to_tsvector
CREATE INDEX idx_sp_fts ON sp USING gin (to_tsvector('simple', mo_ta));

Ảnh chụp đoạn mã SQL nền tối minh hoạ GIN index lập chỉ mục bên trong jsonb mảng và văn bản, giải thích B-tree đánh chỉ mục cả giá trị còn GIN đánh chỉ mục từng thành phần là mỗi khoá giá trị jsonb mỗi phần tử mảng mỗi từ lexeme trong văn bản, ba lệnh CREATE INDEX USING gin cho jsonb với jsonb_path_ops truy vấn bằng toán tử chứa, cho mảng text truy vấn tags chứa nhiều phần tử, và cho full-text trên biểu thức to_tsvector truy vấn bằng toán tử khớp, kèm ghi chú jsonb_path_ops nhỏ và nhanh cho toán tử chứa nhưng không hỗ trợ các toán tử kiểm tra tồn tại khoá

Hình 1: Ba cách dùng GIN. Điểm chung: cột chứa nhiều giá trị con, và truy vấn hỏi "có chứa X không" (@> cho jsonb/mảng, @@ cho full-text). GIN tách từng thành phần con ra làm khoá index.

Đo thật: ba truy vấn, ba mức thắng

Bảng sp có 1 triệu sản phẩm: cột thuoc_tinh kiểu jsonb, tags kiểu text[], mo_ta kiểu text. Chạy EXPLAIN (ANALYZE, BUFFERS) trước và sau khi tạo GIN:

Ảnh chụp bảng kết quả EXPLAIN ANALYZE nền tối so sánh trước và sau GIN trên bảng một triệu dòng PostgreSQL 16, truy vấn jsonb chứa hang và mau chọn lọc 570 dòng từ 36,81 mili giây seq scan xuống 1,34 mili giây với GIN nhanh khoảng 27 lần, truy vấn mảng chứa ba tag 0 dòng từ 43,54 xuống 9,50 mili giây, truy vấn full-text tìm cotton 200000 dòng từ 719,99 xuống 39,50 mili giây nhanh khoảng 18 lần, bên dưới là kế hoạch Bitmap Heap Scan dùng idx_sp_tt cho jsonb chọn lọc, giải thích vì sao seq scan full-text chậm là phải tính lại to_tsvector cho cả triệu dòng còn GIN lưu sẵn lexeme, và kích thước bảng sp 299 MB so với GIN jsonb 7 MB GIN mảng 3,4 MB GIN full-text 59 MB

Hình 2: Trước/sau GIN. jsonb chọn lọc: 36,81 ms → 1,34 ms (~27×). Full-text cotton (200.000 dòng): 719,99 ms → 39,50 ms (~18×). GIN jsonb và mảng rất gọn (7 MB, 3,4 MB) so với bảng 299 MB.

Đọc kỹ ba dòng:

jsonb chọn lọc (570 dòng) — truy vấn thuoc_tinh @> '{"hang":"H50","mau":"do"}'. Không index, PostgreSQL quét tuần tự cả triệu dòng, loại bỏ 333k dòng mỗi worker: 36,81 ms. Có GIN, nó tra hai khoá hang=H50 và mau=do, giao lại, đọc đúng 554 trang heap: 1,34 ms. Nhanh gấp 27 lần.

Full-text (200.000 dòng) — đây là ca thắng đậm nhất về ý nghĩa. Không index, mệnh đề to_tsvector('simple', mo_ta) @@ to_tsquery('cotton') buộc PostgreSQL tính lại to_tsvector cho cả triệu dòng ngay lúc truy vấn — tách từ, chuẩn hoá, dựng vector — mất 719,99 ms. GIN lưu sẵn các lexeme (từ đã chuẩn hoá), nên bỏ hẳn bước tính đó và chỉ tra khoá cotton: 39,50 ms. Điểm mấu chốt: GIN full-text không chỉ tránh quét bảng, nó còn tránh tính toán lại — đó là lý do khác biệt lớn tới vậy dù kết quả tận 200k dòng.

Mảng (0 dòng) — tags @> ARRAY['hot','sale','best']. Kể cả khi không dòng nào khớp, GIN biết điều đó sau khi giao ba danh sách khoá (9,50 ms) thay vì quét cả bảng để chắc chắn (43,54 ms).

jsonb_ops hay jsonb_path_ops?

Với jsonb, GIN có hai lớp toán tử:

  • jsonb_ops (mặc định): đánh chỉ mục cả khoá lẫn giá trị. Hỗ trợ nhiều toán tử: @> (chứa), ? (có khoá này không), ?|, ?&.
  • jsonb_path_ops: chỉ đánh chỉ mục đường dẫn tới giá trị. Index nhỏ hơn và nhanh hơn cho truy vấn @>, nhưng không hỗ trợ các toán tử kiểm tra tồn tại khoá (?, ?|, ?&).

Ở đây ta dùng jsonb_path_ops (index chỉ 7 MB) vì chỉ cần @>. Nếu ứng dụng còn hỏi "jsonb này có khoá khuyen_mai không" thì phải dùng jsonb_ops. Chọn sai lớp toán tử là index nằm đó mà truy vấn vẫn seq scan — không lỗi, chỉ chậm.

Đánh đổi: GIN không miễn phí

Ba cái giá cần biết trước khi rải GIN khắp nơi:

Ghi chậm hơn B-tree. Mỗi lần INSERT/UPDATE một ô jsonb hay mảng, GIN phải cập nhật nhiều khoá (một cho mỗi phần tử con), không phải một. PostgreSQL giảm nhẹ bằng fastupdate (gom cập nhật vào một danh sách chờ rồi trộn sau), nhưng bản chất GIN vẫn nặng đường ghi hơn B-tree. Cân nhắc với bảng ghi rất nhiều.

Kích thước không phải lúc nào cũng nhỏ. GIN jsonb và mảng ở đây rất gọn (7 MB, 3,4 MB) vì mỗi ô ít phần tử con. Nhưng GIN full-text lên tới 59 MB — vì mỗi mô tả sinh ra nhiều lexeme, và mỗi lexeme là một khoá. Văn bản càng dài, index full-text càng phình. Vẫn nhỏ hơn heap 299 MB, nhưng đừng cho rằng "GIN luôn tí hon".

Không thắng khi truy vấn kém chọn lọc. Đo thêm tags @> ARRAY['hot','moi'] khớp 125.000 dòng (12,5% bảng): GIN vẫn được dùng nhưng bước đọc heap thống trị, kết quả 55 ms — chậm hơn cả seq scan. Khi bạn lấy về một phần lớn bảng, không index nào cứu được; chi phí nằm ở việc đọc dữ liệu, không ở việc tìm nó.

Ba ý mang về

  1. GIN đánh chỉ mục các thành phần bên trong một ô — mỗi khoá jsonb, mỗi phần tử mảng, mỗi lexeme văn bản — nên trả lời "có chứa X không" (@>, @@) bằng một lần tra khoá thay vì quét bảng: jsonb chọn lọc nhanh gấp 27 lần, full-text gấp 18 lần trong phép đo này.
  2. Full-text được lợi kép: GIN vừa tránh quét bảng vừa tránh tính lại to_tsvector cho từng dòng — nguồn gốc của khác biệt 720 ms so với 39,5 ms; luôn tạo GIN trên biểu thức to_tsvector bạn thật sự dùng trong truy vấn.
  3. GIN nặng đường ghi và không luôn nhỏ (index full-text ở đây 59 MB), lại không thắng khi truy vấn lấy về phần lớn bảng — chọn jsonb_path_ops khi chỉ cần @>, và chỉ đặt GIN ở nơi truy vấn thật sự chọn lọc.

Phần sau ta sang một họ index khác hẳn, chuyên cho dữ liệu có thứ tự không gian: Phần sau nói về GiST — cách nó đánh chỉ mục hình học, kiểu range, và tìm kiếm láng giềng gần nhất (KNN).