B-tree index trong PostgreSQL hoạt động thế nào Cơ bản
Thời gian đọc ước tính: 9 phút
Vấn đề
Phần tiêu đề “Vấn đề”Một bảng btree_demo có 100.000 dòng: sequence_no integer PRIMARY KEY, passenger_name text. Ba câu query trông na ná nhau:
SELECT sequence_no, passenger_name FROM btree_demo WHERE sequence_no = 42000;SELECT sequence_no FROM btree_demo WHERE passenger_name LIKE 'passenger-4200%';SELECT sequence_no FROM btree_demo WHERE passenger_name LIKE '%9999';| Query | Kết quả |
|---|---|
sequence_no = 42000 |
Dùng index, nhanh |
LIKE 'passenger-4200%' |
Dù passenger_name đã có index, vẫn quét toàn bảng, chậm hơn khoảng 350 lần |
LIKE '%9999' |
B-tree thông thường không giúp được, kể cả sau khi sửa; phải dùng GIN với pg_trgm, hoặc index trên reverse() |
Con số 350 lần so sánh hai query khác nhau, nên chỉ để thấy chênh lệch về plan, không phải benchmark công bằng.
Bài này chạy cả ba trên PostgreSQL 17 thật, xem plan thật, để trả lời chính xác “index giúp được gì và không giúp được gì”.
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”CREATE INDEX không kèm USING sẽ tạo B-tree — loại index mặc định và phổ biến nhất. Nó là cây cân bằng nhiều nhánh: mỗi node (một page 8 kB) chứa hàng trăm key và trỏ tới hàng trăm node con, nên cây rất nông. Index trên 100.000 dòng trong demo dưới đây chỉ chiếm:
relname | index_pages | index_size-----------------+-------------+------------ btree_demo_pkey | 276 | 2208 kB276 page (mỗi page 8kB) cho 100.000 key — tức trung bình khoảng 362 key mỗi page. Index demo này chỉ có 2 tầng: bt_metap() (extension pageinspect) báo root ở level 1, nghĩa là root trỏ thẳng xuống leaf.
Khi số leaf vượt quá số con trỏ một root chứa được (vài trăm), PostgreSQL thêm tầng internal. Với hệ số phân nhánh này, cây vẫn chỉ 3 tầng dù bảng có hàng triệu dòng. Đây là lý do tìm kiếm trên B-tree gần như không chậm đi khi bảng lớn thêm gấp 10 lần.
Cấu trúc gồm ba loại page:
| Loại page | Chứa gì | Vai trò |
|---|---|---|
| Root | Key phân cách + con trỏ xuống tầng dưới | Điểm bắt đầu mọi lần tìm kiếm |
| Internal | Key phân cách + con trỏ | Thu hẹp phạm vi tìm |
| Leaf | Key thật + TID (block, item) trỏ vào heap | Nơi tìm thấy dòng; các leaf nối với nhau theo thứ tự |
42000 ở leaf nào?Buffer 1: so sánh 42000 với các key phân cách ở root, chọn con trỏ xuống đúng leaf. Cây demo chỉ có 2 tầng nên root trỏ thẳng xuống leaf.
Vì leaf nối thành chuỗi, sau khi định vị điểm bắt đầu PostgreSQL đi ngang qua một khoảng giá trị mà không quay lại root. Đó là lý do range scan và ORDER BY rẻ.
Điểm quan trọng nhất, hay bị hiểu sai: B-tree so sánh giá trị bằng toán tử <, <=, =, >=, >. Nó không “tìm chuỗi con” — đó là việc của toán tử LIKE, và không phải mọi cách dùng LIKE đều dịch được thành phép so sánh mà B-tree hiểu.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”1. Equality — tìm đúng một dòng
Phần tiêu đề “1. Equality — tìm đúng một dòng”Index Scan using btree_demo_pkey on btree_demo (cost=0.29..8.31 rows=1 width=19) (actual time=0.004..0.004 rows=1 loops=1) Index Cond: (sequence_no = 42000) Buffers: local hit=3Execution Time: 0.008 mssequence_no = 42000 dịch thẳng thành một phép so sánh =. PostgreSQL đi từ root xuống leaf theo các key phân cách, chỉ chạm 3 page, mất 0.008 ms.
2. Range + ORDER BY — không cần Sort riêng
Phần tiêu đề “2. Range + ORDER BY — không cần Sort riêng”SELECT sequence_no FROM btree_demoWHERE sequence_no BETWEEN 42000 AND 42009ORDER BY sequence_no;Index Only Scan using btree_demo_pkey on btree_demo (cost=0.29..4.47 rows=9 width=4) (actual time=0.007..0.007 rows=10 loops=1) Index Cond: ((sequence_no >= 42000) AND (sequence_no <= 42009)) Heap Fetches: 0 Buffers: local hit=3BETWEEN dịch thành >= và <= — vẫn là toán tử B-tree hiểu được. Vì leaf page đã có thứ tự sẵn, PostgreSQL đọc liên tiếp 10 entry mà không cần một bước Sort riêng cho ORDER BY sequence_no.
Heap Fetches: 0 xác nhận query không phải quay lại bảng — vì SELECT sequence_no chỉ cần đúng cột đã nằm trong index. Điều kiện kèm theo: visibility map phải đánh dấu page là all-visible, nghĩa là bảng đã được VACUUM (vacuum và bloat). Bảng vừa ghi nhiều thì Heap Fetches sẽ lớn.
Đổi SELECT sequence_no thành SELECT * với cùng điều kiện đó:
Index Scan using btree_demo_pkey on btree_demo (cost=0.29..8.47 rows=9 width=19) (actual time=0.003..0.003 rows=10 loops=1) Index Cond: ((sequence_no >= 42000) AND (sequence_no <= 42009)) Buffers: local hit=3Tên node đổi từ Index Only Scan thành Index Scan — vì passenger_name không có trong index, PostgreSQL phải quay lại heap lấy nó. Buffers vẫn là 3 vì dữ liệu đang nằm trong cache; trên bảng lớn hơn nhiều, bước quay lại heap này là chỗ tốn I/O thật.
3. LIKE dạng prefix — có index vẫn Seq Scan, vì sao?
Phần tiêu đề “3. LIKE dạng prefix — có index vẫn Seq Scan, vì sao?”Thêm index trên passenger_name rồi chạy:
SELECT sequence_no FROM btree_demo WHERE passenger_name LIKE 'passenger-4200%';Seq Scan on btree_demo (cost=0.00..1887.00 rows=10 width=4) (actual time=0.127..2.797 rows=11 loops=1) Filter: (passenger_name ~~ 'passenger-4200%'::text) Rows Removed by Filter: 99989 Buffers: local hit=637Execution Time: 2.799 msCó index mà vẫn Seq Scan, đọc 637 page, loại 99.989 dòng để lấy 11 dòng. Nguyên nhân không phải PostgreSQL “quên” dùng index — mà là collation của database:
datcollate------------ en_US.utf8Với locale khác C, PostgreSQL không thể chắc chắn thứ tự sắp xếp theo locale đó trùng với thứ tự byte cần thiết để dùng B-tree cho so sánh prefix. Đây không phải chuyện hiếm — hầu hết database production không dùng locale C.
Cách sửa: tạo index với operator class text_pattern_ops, so sánh theo byte thay vì theo locale:
CREATE INDEX btree_demo_name_pattern_idx ON btree_demo (passenger_name text_pattern_ops);Chạy lại đúng câu query trên:
Index Scan using btree_demo_name_pattern_idx on btree_demo (cost=0.42..8.44 rows=10 width=4) (actual time=0.030..0.034 rows=11 loops=1) Index Cond: ((passenger_name ~>=~ 'passenger-4200'::text) AND (passenger_name ~<~ 'passenger-4201'::text)) Filter: (passenger_name ~~ 'passenger-4200%'::text) Buffers: local hit=2 read=3Execution Time: 0.048 ms2.799 ms → 0.048 ms: nhanh hơn khoảng 58 lần, 637 buffers → 5 buffers. PostgreSQL dịch LIKE 'passenger-4200%' thành khoảng so sánh byte >= 'passenger-4200' AND < 'passenger-4201' — một dải mà B-tree với text_pattern_ops hiểu và định vị trực tiếp được.
Cùng câu LIKE 'passenger-4200%' |
Plan | Thời gian | Buffers |
|---|---|---|---|
Index mặc định (en_US.utf8) |
Seq Scan |
2.799 ms | 637 |
text_pattern_ops |
Index Scan |
0.048 ms | 5 |
Index text_pattern_ops có một cái giá: nó so sánh theo byte nên không phục vụ <, > hay ORDER BY theo thứ tự locale. Cần cả hai kiểu truy vấn thì giữ hai index, hoặc tạo một index với COLLATE "C". Với cột varchar, dùng varchar_pattern_ops.
4. LIKE bắt đầu bằng % — B-tree thường không cứu được
Phần tiêu đề “4. LIKE bắt đầu bằng % — B-tree thường không cứu được”SELECT sequence_no FROM btree_demo WHERE passenger_name LIKE '%9999';Seq Scan on btree_demo (cost=0.00..1887.00 rows=10 width=4) (actual time=0.322..3.313 rows=10 loops=1) Filter: (passenger_name ~~ '%9999'::text) Rows Removed by Filter: 99990 Buffers: local hit=637Kể cả với text_pattern_ops đã tạo ở bước trước, câu này vẫn Seq Scan. Lý do khác hẳn: %9999 không có phần đầu cố định để dịch thành một khoảng so sánh — giá trị khớp có thể bắt đầu bằng bất kỳ ký tự nào.
B-tree định vị bằng cách so sánh có thứ tự; một điều kiện “kết thúc bằng X” không thu hẹp được vị trí bắt đầu trong cây. Một B-tree trực tiếp trên cột không giải quyết được việc này. Có hai hướng:
- GIN với
pg_trgm: hỗ trợ cảLIKE '%x%'. - Expression index trên
reverse()(riêng cho điều kiện “kết thúc bằng”): tạo index(reverse(passenger_name) text_pattern_ops)rồi viết lại query thànhWHERE reverse(passenger_name) LIKE reverse('%9999')(tức'9999%'trên chuỗi đảo ngược). Khi đó B-tree lại dùng được.
Bảng tra: toán tử nào dùng được B-tree?
Phần tiêu đề “Bảng tra: toán tử nào dùng được B-tree?”| Điều kiện | Dùng B-tree thường? | Ghi chú |
|---|---|---|
col = x, col IN (...) |
Có | |
col < x, >, BETWEEN, ORDER BY col |
Có | Giữ thứ tự, không cần Sort |
col IS NULL |
Có | B-tree có lưu NULL |
col LIKE 'abc%', locale C |
Có | Tự dịch thành khoảng so sánh |
col LIKE 'abc%', locale en_US |
Không | Cần text_pattern_ops hoặc COLLATE "C" |
col LIKE '%abc' |
Không | Dùng reverse(col) expression index hoặc GIN pg_trgm |
col LIKE '%abc%', ILIKE |
Không | GIN/GiST với pg_trgm |
lower(col) = x |
Chỉ khi có index trên lower(col) |
Expression index |
col <> x |
Gần như không | Hầu hết dòng đều khớp |
Index nhiều cột: PostgreSQL 18 thêm skip scan cho B-tree nhiều cột, nên mất cột đầu không còn luôn là thảm hoạ; chi tiết ở thứ tự cột composite index.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Trade-offs
Phần tiêu đề “Trade-offs”Ưu điểm
- Cây nông (2-3 tầng cho hàng triệu dòng nhờ hệ số phân nhánh cao) nên tốc độ tìm kiếm gần như không đổi khi bảng lớn thêm
- Hỗ trợ cả equality, range, và giữ được thứ tự cho
ORDER BY/LIMITmà không cầnSortriêng text_pattern_opsmở rộng B-tree sang tìm kiếm prefix hiệu quả, không cần đổi sang loại index khác
Nhược điểm
- Chỉ hỗ trợ toán tử so sánh có thứ tự; không giúp được
LIKE '%x', full-text search, hay tìm theo tập hợp - Locale khác
C(phổ biến trong production) âm thầm vô hiệu hoá index thường cho tìm kiếm prefix — dễ bị bỏ sót vìEXPLAINkhông cảnh báo, chỉ đơn giản chọnSeq Scan - Mỗi index thêm (kể cả
text_pattern_opssong song với index thường) tốn thêm dung lượng và làm ghi chậm hơn
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Checklist khi thiết kế B-tree index
Phần tiêu đề “Checklist khi thiết kế B-tree index”- Đối chiếu điều kiện
WHEREvới bảng tra ở trên: B-tree chỉ dịch được<, <=, =, >=, >(gồmBETWEEN,IN,IS NULL). - Có tìm kiếm
LIKE 'prefix%': kiểm tradatcollate; nếu khácC, tạo index vớitext_pattern_ops(varchar_pattern_opschovarchar) hoặcCOLLATE "C". - Cần cả prefix search lẫn
ORDER BY/so sánh theo locale: giữ hai index, vìtext_pattern_opskhông phục vụ thứ tự locale. - Có
LIKE '%suffix'hoặc'%x%': đừng thêm B-tree thường; dùng GINpg_trgmhoặc expression index trênreverse(). - Muốn
Index Only Scan: chỉSELECTcột có trong index và đểVACUUMgiữ visibility map, nếu khôngHeap Fetchessẽ lớn. - Mỗi index thêm tốn dung lượng và làm ghi chậm hơn; chỉ thêm cho truy vấn thường xuyên.