Hash index và bức tranh các loại index
Hash index tra equality O(1) nhưng mù range. B-tree cho range, hash cho equality, cùng inverted/spatial/bitmap — chọn loại index theo hình dạng truy vấn.
TL;DR: Không có một loại index vạn năng — mỗi loại tối ưu cho một kiểu truy vấn. Hash index băm khoá thành vị trí, cho tra cứu equality (= giá trị chính xác) gần như O(1), nhưng mù hoàn toàn với range/prefix vì hàm hash cố tình phá vỡ thứ tự. Ordered index (B-tree) ngược lại: chậm hơn equality một chút nhưng phục vụ được range, prefix, ORDER BY. Ngoài hai họ chính còn các loại chuyên dụng — inverted index (full-text), spatial (không gian), bitmap (cột ít giá trị phân biệt) — mỗi loại sinh ra cho một query pattern mà hai họ chính làm kém. Chọn loại index là chọn theo hình dạng câu hỏi bạn hỏi dữ liệu, không phải theo thói quen.
Bài 02 so sánh hai cách lưu một index sorted xuống đĩa (B-tree vs LSM-tree). Nhưng cả hai đều thuộc họ "ordered" — giữ khoá theo thứ tự. Câu hỏi tiếp theo: nếu bạn không cần thứ tự, chỉ cần tra "có đúng khoá này không", liệu có cấu trúc nào nhanh hơn? Có — hash index. Và một khi đã thấy hash là một họ riêng, bức tranh mở ra: có cả một họ các loại index, mỗi loại sinh ra cho một hình dạng truy vấn khác nhau.
TaskFlow gặp đúng tình huống này. Query thứ nhất: "lấy session theo session token chính xác" — chỉ tra equality một khoá, không bao giờ cần range. Query thứ hai: "task có due_at trong tuần này" — range. Query thứ ba: "task có chữ 'login' trong mô tả" — tìm chữ trong văn bản. Ba câu hỏi, ba hình dạng khác nhau, và không một loại index nào giỏi cả ba. Bài này vẽ bức tranh các loại index ở mức nguyên lý và dạy chọn loại nào theo từng hình dạng câu hỏi.
1. Analogy — Tủ hồ sơ vs bảng tra số nhà
Hình dung hai cách tổ chức để tìm thông tin:
- Tủ hồ sơ xếp theo bảng chữ cái (ordered / B-tree): kẹp hồ sơ theo tên, A đến Z. Tìm "Nam" thì lật tới chữ N — nhanh. Và vì đã sắp xếp, muốn lấy "tất cả tên từ M đến P" thì chỉ cần kéo một dải liên tục — range query dễ dàng.
- Bảng tra số nhà bằng công thức (hash): thay vì xếp theo thứ tự, bạn dùng một công thức biến tên thành số ô tủ: "Nam" → ô 7, "An" → ô 3. Tra một tên: tính công thức, mở thẳng đúng ô — gần như tức thì, không cần lật. Nhưng "tất cả tên từ M đến P" thì chịu: công thức rải các tên đi khắp nơi, "Nam" ở ô 7 còn "Nga" có thể ở ô 200 — không có khái niệm "ô kế tiếp theo thứ tự tên".
| Đời thường | Loại index |
|---|---|
| Tủ xếp theo bảng chữ cái | Ordered index (B-tree) — sorted |
| Kéo một dải tên M đến P | Range / prefix query |
| Bảng tra số nhà bằng công thức | Hash index |
| Tính công thức, mở thẳng đúng ô | Equality lookup O(1) |
| Công thức rải tên khắp nơi | Hash phá vỡ thứ tự → không range được |
Hash = công thức mở thẳng đúng ô (equality cực nhanh, nhưng không range). B-tree = tủ xếp theo bảng chữ cái (range/prefix dễ, equality vẫn nhanh). Chọn theo việc bạn hỏi "đúng khoá này?" hay "một dải khoá?".
2. Hash index — equality O(1), mù với range
Hash index dựa trên một hàm hash: một hàm biến khoá thành một con số (vị trí) trong một mảng các "bucket". Tra cứu khoá K: tính hash(K) ra vị trí, nhảy thẳng tới bucket đó, lấy pointer trỏ về dữ liệu. Không phải duyệt cây, không phải so sánh nhiều lần — về trung bình là O(1), một bước.
Hash index ON sessions(token):
token "a1b2..." --hash()--> bucket 7 --> pointer toi row
token "9f3c..." --hash()--> bucket 2 --> pointer toi row
token "00de..." --hash()--> bucket 7 --> collision: chuoi trong bucket 7
(vi tri = ham hash; KHONG sap theo gia tri token)
Điểm cốt lõi để hiểu cả ưu lẫn nhược: hàm hash cố tình phân tán khoá đều khắp các bucket để tránh dồn cục. Hệ quả: hai khoá liền kề về giá trị (token = 100 và token = 101) rơi vào hai bucket cách xa nhau. Hash không giữ bất kỳ thứ tự nào.
-- Hash index toa sang: tra dung mot khoa chinh xac
SELECT user_id, expires_at FROM sessions
WHERE token = 'a1b2c3d4e5f6'; -- equality -> hash O(1)
-- Hash index BO TAY: range / so sanh thu tu
SELECT * FROM events WHERE id BETWEEN 1000 AND 2000; -- hash khong giup
SELECT * FROM tasks ORDER BY due_at; -- hash khong giup
SELECT * FROM users WHERE email LIKE 'admin%'; -- prefix -> hash khong giup
Đây chính xác là tấm gương ngược của B-tree: B-tree giữ thứ tự nên làm range tốt nhưng equality chậm hơn hash một chút; hash phá thứ tự để equality cực nhanh nhưng mất sạch khả năng range/prefix/ORDER BY.
2.1 Hai giới hạn thực tế của hash
Ngoài chuyện "không range", hash index có hai ràng buộc thực tế:
- Hash collision — hàm hash có thể ánh hai khoá khác nhau vào cùng một bucket. Engine phải xử lý (thường nối thành chuỗi trong bucket, hoặc dò sang ô kế). Nếu phân bố lệch (nhiều khoá dồn vài bucket), tra cứu suy biến từ O(1) về duyệt chuỗi dài — chậm đi.
- Hợp nhất với bộ nhớ — hash index hiệu quả nhất khi bảng băm vừa trong bộ nhớ (RAM). Khi nó lớn vượt RAM và phải tràn xuống đĩa, mỗi lookup thành một random I/O (vì vị trí rải khắp nơi, không cục bộ như B-tree đi tuần tự xuống leaf) — chi phí tăng nhanh.
Dù hash nhanh hơn cho equality thuần, nhiều relational database mặc định tạo B-tree ngay cả cho khoá chính. Lý do: B-tree phục vụ được nhiều hình dạng truy vấn (equality, range, prefix, ORDER BY) bằng một cấu trúc, trong khi hash chỉ giỏi đúng equality. Một index đa năng "đủ nhanh cho mọi thứ" thường thắng một index "chỉ nhanh một thứ" về tổng lợi ích thực tế. Hash chỉ thật sự đáng dùng khi workload gần như chỉ tra equality.
3. Bức tranh các loại index — mỗi loại cho một hình dạng câu hỏi
Lùi lại một bước: index không phải một thứ, mà là một họ cấu trúc, mỗi loại sinh ra để trả lời một hình dạng câu hỏi mà các loại khác làm kém. Hai họ chính bạn đã gặp, cộng vài loại chuyên dụng nên biết là tồn tại + dùng cho gì (không cần đi sâu engine):
flowchart TB Q["Hinh dang cau hoi tren du lieu?"] Q --> EQ["Equality: WHERE k = gia tri"] Q --> RG["Range / prefix / ORDER BY"] Q --> TX["Tim chu trong van ban"] Q --> SP["Truy van khong gian (gan toa do X)"] Q --> LC["Cot it gia tri phan biet (low cardinality)"] EQ --> H["Hash index (O(1))"] RG --> B["Ordered index / B-tree"] TX --> IV["Inverted index (full-text)"] SP --> ST["Spatial index"] LC --> BM["Bitmap index"]
| Loại index | Hình dạng câu hỏi nó giỏi | Nguyên lý một câu | Điểm yếu |
|---|---|---|---|
| Ordered (B-tree) | Range, prefix, ORDER BY, cả equality | Giữ khoá sorted → quét dải tuần tự | Equality chậm hơn hash chút |
| Hash | Equality chính xác | Băm khoá ra vị trí → mở thẳng | Không range/prefix; collision; cần vừa RAM |
| Inverted (full-text) | Tìm chữ/từ trong văn bản | Map mỗi từ → danh sách doc chứa nó | Không hợp tra khoá có cấu trúc |
| Spatial | "Gần toạ độ X", trong vùng | Chia không gian nhiều chiều thành ô | Phức tạp; cho dữ liệu địa lý/hình học |
| Bitmap | Lọc/kết hợp cột ít giá trị phân biệt | Mỗi giá trị → một dãy bit | Tệ khi cardinality cao; tốn cập nhật |
Ba loại cuối chỉ cần nắm tồn tại + dùng cho gì ở mức khái niệm:
- Inverted index (full-text) — để trả lời "document nào chứa từ này", nó lật ngược quan hệ: thay vì "doc → các từ", nó lưu "từ → danh sách doc chứa từ đó". Đây là nền tảng của tìm kiếm văn bản. Nó là lời giải cho
LIKE '%từ%'mà B-tree bó tay (xem bài pattern matching). - Spatial index — để trả lời "điểm nào gần toạ độ này / nằm trong vùng này", nó chia không gian nhiều chiều thành các ô lồng nhau để khoanh vùng nhanh. Dùng cho dữ liệu địa lý, hình học.
- Bitmap index — cho cột ít giá trị phân biệt (low cardinality) như
status(chỉ vài giá trị) haygender: mỗi giá trị ứng một dãy bit "dòng nào mang giá trị này". Kết hợp nhiều điều kiện thành phépAND/ORtrên các dãy bit — rất nhanh cho phân tích, nhưng tốn cập nhật nên hợp kho đọc-nhiều hơn là OLTP ghi-nhiều.
4. Chọn loại index theo query pattern
Quy trình thực tế: nhìn vào hình dạng các truy vấn nóng (chạy nhiều, chậm), rồi chọn loại index khớp hình dạng đó.
| Query pattern thực tế | Nên dùng | Vì sao |
|---|---|---|
WHERE token = '...' (chỉ equality, không bao giờ range) | Hash (hoặc B-tree đa năng) | Equality thuần — hash O(1); B-tree vẫn ổn nếu muốn một index lo nhiều thứ |
WHERE due_at BETWEEN ..., ORDER BY created_at | Ordered (B-tree) | Cần thứ tự để quét dải / sắp xếp |
WHERE name LIKE 'admin%' (prefix) | Ordered (B-tree) | Prefix tận dụng thứ tự sorted |
WHERE description LIKE '%login%' (chữ giữa văn bản) | Inverted (full-text) | B-tree mù với chữ giữa chuỗi |
| "Cửa hàng gần vị trí tôi" | Spatial | Truy vấn không gian nhiều chiều |
Dashboard lọc theo status, priority (ít giá trị) | Bitmap (trong kho phân tích) | Kết hợp điều kiện trên cột low-cardinality |
Ví dụ trung lập để định vị, KHÔNG phải trọng tâm: hầu hết RDBMS có B-tree mặc định; một số hỗ trợ hash index tường minh; full-text và spatial thường là loại index/extension riêng tuỳ engine; bitmap phổ biến trong các kho phân tích. Tên gọi và cú pháp tạo khác nhau giữa engine — internals cụ thể thuộc track engine riêng (ví dụ track PostgreSQL), bài agnostic này chỉ dạy nguyên lý + cách chọn.
5. Pitfall — chọn loại index không khớp hình dạng truy vấn
Sai lầm hay gặp là tạo index theo thói quen, không theo hình dạng câu hỏi — index tạo ra nhưng query planner không dùng được.
-- SAI 1: ky vong hash index ho tro range
-- Hash phan tan token khap noi -> khong co "khoa ke tiep" -> range bo tay
SELECT * FROM sessions WHERE created_at BETWEEN '2026-06-01' AND '2026-06-07';
-- Hash index tren created_at KHONG giup -> full scan
-- Dung: ordered index (B-tree) tren created_at
-- SAI 2: ky vong B-tree ho tro chu GIUA van ban
SELECT * FROM tasks WHERE description LIKE '%login%';
-- B-tree chi tan dung prefix (LIKE 'login%'), khong tim chu giua chuoi
-- Dung: inverted index (full-text) cho noi dung van ban
Lý do gốc: mỗi loại index tối ưu cho một hình dạng truy vấn. Hash phá thứ tự nên không range; B-tree giữ thứ tự khoá nhưng không hiểu "chứa từ này ở giữa". Tạo nhầm loại = index nằm im, planner vẫn full scan, mà bạn lại tưởng "đã có index rồi sao vẫn chậm".
Hướng đúng: xác định hình dạng truy vấn trước (equality? range? prefix? chữ giữa văn bản? không gian?), rồi chọn loại index khớp. Kiểm tra query plan để xác nhận index thực sự được dùng — như đã học ở bài 01, tạo index không bảo đảm nó được dùng.
6. 📚 Deep Dive
- Designing Data-Intensive Applications (Kleppmann) — Chương 3 "Storage and Retrieval" — nguồn nền tảng: mục "Hash Indexes" (hash map giữ offset, giới hạn range), "Other Indexing Structures" (full-text, multi-dimensional). Đọc để thấy bức tranh các loại index ở mức nguyên lý, agnostic.
- Wikipedia — Hash table — cơ chế hàm hash, bucket, collision và cách xử lý (chaining, open addressing) — nền tảng của hash index.
- Use The Index, Luke — "Anatomy of an Index" — góc developer agnostic: vì sao ordered index (B-tree) phục vụ được nhiều hình dạng truy vấn, khi nào không dùng được.
- Wikipedia — Inverted index — nguyên lý "từ → danh sách doc" của full-text search.
Ghi chú: DDIA Chương 3 đặt hash index cạnh B-tree để làm rõ đánh đổi equality-only vs ordered. Wikipedia "Hash table" cho cơ chế collision chính xác. Use The Index Luke giải thích vì sao trong thực tế index đa năng (B-tree) thường thắng index chuyên một việc.
7. Liên hệ các bài khác
- Bài 01 — Tại sao cần index: nền tảng heap vs index sorted, 3 loại scan, và "tạo index không bảo đảm được dùng" — áp thẳng vào việc chọn đúng loại index ở bài này.
- Bài 02 — B-tree vs LSM-tree: cả hai là họ ordered index; bài này thêm hash (equality-only) và các loại chuyên dụng để hoàn thiện bức tranh.
- Module 2 — Pattern matching: vì sao
LIKE 'foo%'(prefix) dùng được B-tree cònLIKE '%foo%'(chữ giữa) cần inverted/full-text — minh hoạ trực tiếp việc khớp loại index với hình dạng truy vấn. - Module 10 — OLTP vs OLAP: bitmap index hợp kho phân tích (đọc-nhiều, cột low-cardinality) hơn DB giao dịch — nối với phân đôi workload.
8. Tóm tắt
- Không có index vạn năng: mỗi loại tối ưu cho một hình dạng câu hỏi.
- Hash index: băm khoá ra vị trí → equality O(1); nhưng mù với range/prefix/
ORDER BYvì hàm hash phá vỡ thứ tự. Thêm hai giới hạn: hash collision và hiệu quả nhất khi vừa RAM. - Ordered index (B-tree): giữ khoá sorted → phục vụ range, prefix, sắp xếp, cả equality — đa năng nên thường là mặc định.
- Các loại chuyên dụng nên biết tồn tại + dùng cho gì: inverted (tìm chữ trong văn bản), spatial (truy vấn không gian), bitmap (cột low-cardinality trong kho phân tích).
- Chọn loại index = nhìn hình dạng truy vấn nóng rồi khớp loại; kiểm tra query plan để xác nhận index được dùng.
- Pitfall: hash cho range, hoặc B-tree cho chữ giữa văn bản — index nằm im, planner vẫn full scan.
9. Tự kiểm tra
Q1Vì sao hash index tra equality cực nhanh nhưng hoàn toàn không phục vụ được range query hay ORDER BY?▸
Hash index dùng một hàm hash biến khoá thành vị trí bucket: tra một khoá chỉ cần tính hàm rồi nhảy thẳng tới bucket đó — về trung bình O(1), một bước, không duyệt cây.
Nhưng hàm hash cố tình phân tán khoá đều khắp các bucket để tránh dồn cục. Hệ quả: hai khoá liền kề về giá trị (ví dụ 100 và 101) rơi vào hai bucket cách xa nhau — index không giữ bất kỳ thứ tự nào. Không có khái niệm "khoá kế tiếp theo thứ tự", nên BETWEEN, ORDER BY, prefix LIKE 'a%' đều không tận dụng được. Đó là cái giá đánh đổi để có equality O(1).
Q2Hai giới hạn thực tế của hash index ngoài chuyện không range là gì, và mỗi cái ảnh hưởng thế nào tới hiệu năng?▸
- Hash collision: hàm hash có thể ánh hai khoá khác nhau vào cùng bucket. Engine phải xử lý (nối chuỗi trong bucket hoặc dò ô kế). Nếu phân bố lệch — nhiều khoá dồn vài bucket — tra cứu suy biến từ O(1) thành duyệt một chuỗi dài, chậm đi đáng kể.
- Phải vừa bộ nhớ: hash hiệu quả nhất khi bảng băm nằm trong RAM. Khi nó lớn vượt RAM và tràn xuống đĩa, mỗi lookup thành một random I/O — vì vị trí rải khắp nơi, không cục bộ như B-tree đi tuần tự xuống leaf. Chi phí đọc đĩa ngẫu nhiên tăng nhanh.
Hai giới hạn này (cộng việc không range) là lý do nhiều RDBMS vẫn mặc định B-tree — một index đa năng đủ nhanh cho nhiều hình dạng truy vấn thường thắng một index chỉ nhanh đúng equality.
Q3Một bảng 'sessions' chỉ tra theo token chính xác (WHERE token = '...'), không bao giờ range. Nên dùng hash hay B-tree, và đánh đổi là gì?▸
Hash index là lựa chọn lý thuyết tối ưu ở đây: workload thuần equality, hash cho O(1), nhanh hơn B-tree một chút cho mỗi lookup. Nếu bảng vừa RAM và phân bố hash đều, hash thắng.
Đánh đổi: nếu sau này phát sinh nhu cầu range (ví dụ "session tạo trong khoảng thời gian X") hay sắp xếp, hash bó tay và bạn phải thêm index khác. B-tree tuy chậm hơn equality chút nhưng lo được cả tương lai đó bằng một cấu trúc — nên nhiều khi B-tree là lựa chọn an toàn hơn dù không tối ưu tuyệt đối cho equality. Quy tắc: chọn hash khi chắc chắn workload gần như chỉ equality; còn nghi ngờ sẽ cần range → B-tree.
Q4Query 'WHERE description LIKE \'%login%\'' chậm dù đã có B-tree trên description. Vì sao B-tree không giúp, và loại index nào mới đúng?▸
B-tree giữ khoá sorted theo toàn bộ giá trị từ đầu chuỗi. Nó tận dụng được prefix (LIKE 'login%') vì có thể định vị dải bắt đầu bằng "login". Nhưng LIKE '%login%' tìm chữ ở giữa chuỗi — B-tree không biết bắt đầu quét từ đâu vì thứ tự sorted dựa trên ký tự đầu, không phải nội dung giữa. Nên planner buộc full scan.
Loại đúng là inverted index (full-text): nó lật quan hệ thành "từ → danh sách document chứa từ đó", nên tra "doc nào chứa từ login" trở thành một lookup theo từ. Đây là nền tảng của tìm kiếm văn bản, sinh ra đúng cho hình dạng câu hỏi mà B-tree làm kém.
Q5Giải thích nguyên lý cốt lõi: vì sao 'chọn loại index' thực chất là 'khớp với hình dạng câu hỏi' chứ không phải chọn cái nhanh nhất?▸
Vì mỗi loại index tối ưu cho một hình dạng truy vấn bằng cách đánh đổi mất khả năng phục vụ hình dạng khác. Hash phá thứ tự để đổi lấy equality O(1) — mất range. B-tree giữ thứ tự để làm range/prefix/sort — equality chậm hơn chút. Inverted lật quan hệ để tìm chữ trong văn bản — không hợp tra khoá có cấu trúc. Không loại nào "nhanh nhất cho mọi câu hỏi".
Nên câu hỏi đúng không phải "loại nào nhanh nhất" mà "truy vấn nóng của tôi có hình dạng gì": equality, range, prefix, chữ giữa văn bản, không gian, hay lọc cột low-cardinality? Khớp loại index với hình dạng đó. Sau khi tạo, kiểm tra query plan xác nhận index thực sự được dùng — tạo index không bảo đảm planner dùng nó, đúng như nguyên tắc ở bài 01.
Q6Bitmap index hợp với hoàn cảnh nào và vì sao nó tệ cho bảng OLTP ghi-nhiều, cardinality cao?▸
Bitmap index hợp với cột ít giá trị phân biệt (low cardinality) như status, gender, priority: mỗi giá trị ứng một dãy bit đánh dấu "dòng nào mang giá trị này". Lọc kết hợp nhiều điều kiện trở thành phép AND/OR trên các dãy bit — rất nhanh, lý tưởng cho dashboard/phân tích đọc-nhiều lọc theo vài cột phân loại.
Nó tệ cho OLTP ghi-nhiều, cardinality cao vì: (1) cardinality cao (nhiều giá trị phân biệt như id, email) nghĩa là rất nhiều dãy bit thưa — tốn chỗ và mất lợi thế; (2) mỗi cập nhật một dòng đụng tới nhiều dãy bit, nên ghi liên tục làm chi phí bảo trì bitmap tăng nhanh. Vì vậy nó thuộc về kho phân tích (xem OLTP vs OLAP), không phải DB giao dịch.
Đây là bài cuối trong chuỗi nền tảng storage & indexing của module.
Bài tiếp theo: Tại sao cần index — ôn lại nền tảng module
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên