IndexingIndexing
Mục lục
- Tổng quan
- Kiến thức nền tảng
- Tại sao index tồn tại: chi phí của một lần full scan
- Index như một cấu trúc thứ hai, nhỏ hơn
- Linear / sequential indexing
- Khái niệm chính
- Tree-based indexing: B-tree và B+ tree
- Hash index
- ISAM và vai trò lịch sử của nó
- Chi phí ghi của một index
- Composite index và quy tắc leftmost-prefix
- Covering index và index-only scan
- Chọn cấu trúc index
- Best Practices
- Tài liệu tham khảo
Table of contents
- Overview
- Fundamentals
- Why indexes exist: the cost of a full scan
- The index as a second, smaller structure
- Linear / sequential indexing
- Key Concepts
- Tree-based indexing: B-trees and B+ trees
- Hash indexes
- ISAM and its historical role
- The write cost of an index
- Composite indexes and the leftmost-prefix rule
- Covering indexes and index-only scans
- Choosing an index structure
- Best Practices
- References
Thuộc bộ kiến thức Data Structures & Algorithms Roadmap.
Tổng quan
Mọi cấu trúc trong roadmap này từ trước tới giờ đều được nghiên cứu trong memory: array là một khối liên tục, B-tree là một tập các node, hash table là một mảng bucket. Indexing là chuyện xảy ra khi bạn lấy đúng những cấu trúc đó và trỏ chúng vào dữ liệu không nằm vừa trong memory — một table trên disk, một file, một column store. Thuật toán không thay đổi. Cái thay đổi là mô hình chi phí: một lần dereference pointer thôi không còn miễn phí mà trở thành một lần đọc page, và chỉ riêng sự thật đó đã định hình lại mọi quyết định thiết kế.
Index là một cấu trúc dữ liệu riêng biệt ánh xạ một giá trị tới vị trí của các record chứa giá trị đó. Không có nó, tìm một row nghĩa là đọc mọi row — một full scan, O(n) theo kích thước table. Có nó, tìm một row nghĩa là vài lần tra cứu trong một cấu trúc nhỏ hơn nhiều — O(log n) với tree index, O(1) với hash index — rồi nhảy thẳng tới record. Trên một table 100 triệu row, đó là khác biệt giữa đọc 10 GB và đọc bốn page 8 KB.
Không có lý thuyết nào mới ở đây. Một B+ tree index chính là multiway tree trong ./11-balanced-and-multiway-trees.md, với kích thước node được chọn khớp với disk page. Một hash index chính là hash table trong ./07-hash-tables.md, với các bucket nằm trên disk. Một index file đã sắp xếp được tìm bằng binary search chính là ./09-search-algorithms.md. Bài này nói về dạng ứng dụng: chọn cấu trúc nào cho access pattern nào, index tốn gì trên đường ghi, và tại sao câu trả lời không bao giờ là “đánh index mọi thứ”.
Đây có chủ ý là góc nhìn cấu trúc dữ liệu về indexing, không phải sổ tay DBA. Về phần vận hành — sáu access method của PostgreSQL, partial index và expression index, output của EXPLAIN, bloat, REINDEX CONCURRENTLY — hãy xem Chiến lược Indexing và Query Planning & Performance Tuning. Về chỗ đứng của index trong thiết kế database ở tầng ứng dụng, xem Relational Databases.
Kiến thức nền tảng
Tại sao index tồn tại: chi phí của một lần full scan
Hãy xét một table được lưu dưới dạng một dãy các page kích thước cố định (8 KB trong PostgreSQL, 16 KB trong InnoDB), mỗi page chứa một số record. Trả lời WHERE id = 30 mà không có index nghĩa là đọc hết page này tới page khác và kiểm tra từng record.
import bisect
PAGE_SIZE = 4
records = [(i, f"row-{i}") for i in range(0, 40, 2)] # key 0, 2, 4, ..., 38
pages = [records[i:i + PAGE_SIZE] for i in range(0, len(records), PAGE_SIZE)]
def full_scan(pages, key):
"""Không index: đọc từng page cho tới khi thấy key. O(P) lần đọc page."""
reads = 0
for page in pages:
reads += 1 # một lần đọc page = một đơn vị I/O
for k, v in page:
if k == key:
return v, reads
return None, reads
print(full_scan(pages, 30)) # ('row-30', 4)
print(full_scan(pages, 99)) # (None, 5) — miss thì đọc toàn bộ
Hai điều cần chú ý. Một lần trúng tốn trung bình một nửa số page; một lần trượt luôn tốn toàn bộ. Và hằng số nhân cực kỳ quan trọng: đọc một page từ SSD mất ~100 μs và từ ổ đĩa quay ~10 ms, so với ~100 ns cho một lần truy cập memory đã được cache. Index không chỉ giảm số phép toán, nó chuyển các phép toán đắt thành các phép toán rẻ.
Index như một cấu trúc thứ hai, nhỏ hơn
Index lưu các cặp (giá trị được index → vị trí), được sắp xếp hoặc hash theo giá trị. Nó nhỏ hơn table vì chỉ chứa cột được index cộng một pointer, nên mỗi page chứa được nhiều entry hơn, nên cấu trúc nông hơn và phần lớn nó nằm trong cache.
TABLE (heap) — thứ tự vật lý là tùy ý INDEX trên `email` — sắp theo giá trị
page 0: (7, "carol@x", ...) "alice@x" → (page 1, slot 0)
(2, "erin@x", ...) "bob@x" → (page 2, slot 1)
page 1: (5, "alice@x", ...) "carol@x" → (page 0, slot 0)
(9, "dan@x", ...) "dan@x" → (page 1, slot 1)
page 2: (1, "frank@x", ...) "erin@x" → (page 0, slot 1)
(4, "bob@x", ...) "frank@x" → (page 2, slot 0)
Hai lựa chọn cấu trúc định nghĩa cả họ này:
- Dense index — một entry cho mỗi record. Bắt buộc với mọi cột mà giá trị của nó không phải thứ tự vật lý của table, vì bạn không thể nhảy tới một record mà bạn không có pointer trỏ tới. Mọi secondary index đều là dense.
- Sparse index — một entry cho mỗi page, chứa key nhỏ nhất của page đó. Chỉ khả thi khi data file được sắp xếp vật lý theo key được index, nhưng nhỏ hơn hẳn: với 100 record mỗi page, sparse index chỉ bằng 1% kích thước của dense index.
# SPARSE index: một entry cho mỗi page, chứa key nhỏ nhất của page đó.
sparse_index = [(page[0][0], pno) for pno, page in enumerate(pages)]
def sparse_lookup(pages, index, key):
"""Binary search trên index để chọn đúng một page, rồi quét page đó."""
keys = [k for k, _ in index]
pos = bisect.bisect_right(keys, key) - 1 # page cuối cùng có key đầu <= key
if pos < 0:
return None, 0
_, pno = index[pos]
reads = 1 # chạm đúng một data page
for k, v in pages[pno]:
if k == key:
return v, reads
return None, reads
print(sparse_lookup(pages, sparse_index, 30)) # ('row-30', 1)
print(sparse_lookup(pages, sparse_index, 99)) # (None, 1) — miss cũng rẻ
Bốn lần đọc page giảm còn một, và trường hợp miss cải thiện từ năm lần đọc xuống một. Tỷ lệ đó chỉ càng lớn khi table càng lớn, đó chính là toàn bộ lý lẽ cho việc đánh index.
Một trục thứ hai:
- Clustered / primary index — các record của table được lưu vật lý theo thứ tự của index này (primary key của InnoDB, PostgreSQL sau
CLUSTER). Chỉ có thể có một, và range scan trên nó là I/O tuần tự — thứ nhanh nhất mà một ổ đĩa làm được. - Non-clustered / secondary index — một cấu trúc riêng trỏ ngược về heap. Có bao nhiêu cũng được, nhưng range scan sinh ra các lần đọc ngẫu nhiên vào heap, một lần cho mỗi row khớp.
Sự phân biệt đó giải thích sự thật phản trực giác nhất về index: vượt qua một ngưỡng selectivity nào đó, index chậm hơn full scan. Nếu một truy vấn khớp 30% của một table lớn, đi theo secondary index nghĩa là 30% số row được lấy về bằng các lần đọc page đơn lẻ ngẫu nhiên, đắt hơn nhiều so với việc đọc tuần tự toàn bộ table. Query planner thật sẽ ước lượng điều này và chuyển sang sequential scan — xem Query Planning & Performance Tuning để biết quyết định đó thực sự được đưa ra thế nào và đọc nó trong EXPLAIN ra sao.
Linear / sequential indexing
“Linear indexing” bao hàm hai ý tưởng đơn giản có liên quan, cả hai đều đáng nêu tên vì chúng là mốc cơ sở mà mọi thứ khác cải tiến lên từ đó.
1. Linear indexing cho mảng nhiều chiều. Một mảng 2 chiều trong memory thực chất là một khối 1 chiều; “index” ở đây là phép số học làm phẳng (row, col) thành một offset duy nhất. Theo thứ tự row-major (C, Python, hầu hết các ngôn ngữ):
Góc nhìn 2 chiều (3 hàng x 4 cột) layout tuyến tính (row-major) trong memory
c0 c1 c2 c3 offset: 0 1 2 3 4 5 6 7 8 9 10 11
r0 a b c d giá trị: a b c d e f g h i j k l
r1 e f g h |__ row 0 __|__ row 1 __|__ row 2 __|
r2 i j k l
offset(r, c) = r * cols + c ví dụ offset(1, 2) = 1*4 + 2 = 6 -> 'g'
def linear_index(r, c, cols):
"""Làm phẳng row-major: O(1), và là lý do duyệt theo hàng nhanh hơn theo cột."""
return r * cols + c
Đây là phép số học địa chỉ O(1) không cần tra cứu gì cả, và nó chính là mẹo dùng để đưa một grid vào union-find trong ./16-disjoint-set-union-find.md. Ý nghĩa thực tiễn của nó là cache locality: duyệt for r: for c: đi tuần tự trong memory và thường nhanh gấp vài lần for c: for r:, vốn nhảy qua cả mảng ở mỗi bước. Các ngôn ngữ column-major (Fortran, MATLAB, và NumPy khi bạn yêu cầu) đảo ngược công thức, và làm ngược là một bug hiệu năng kinh điển. Xem ./04-arrays.md.
2. Một linear (sequential) index file. Nghĩa ở phía database: giữ index dưới dạng một mảng phẳng đã sắp xếp gồm các cặp (key, vị trí). Tra cứu là binary search — O(log n) lần so sánh, và nếu mảng nằm trên disk thì O(log n) lần đọc page.
def linear_index_lookup(index, key):
"""Binary search trên index phẳng đã sắp xếp. O(log n) — xem ./09-search-algorithms.md."""
keys = [k for k, _ in index]
pos = bisect.bisect_left(keys, key)
if pos < len(index) and index[pos][0] == key:
return index[pos][1]
return None
Điểm yếu chí mạng của nó là việc chèn: đưa một key mới vào giữa một mảng đã sắp xếp tốn O(n) vì mọi thứ phía sau phải dịch đi. Điều đó chấp nhận được với một index chỉ đọc được dựng hàng loạt (data warehouse, search index dựng offline) và vô dụng với bất cứ thứ gì mang tính transactional. Toàn bộ phần còn lại của bài này tồn tại để giữ được việc tìm kiếm O(log n) trong khi làm cho việc chèn cũng rẻ.
Khái niệm chính
Tree-based indexing: B-tree và B+ tree
Cấu trúc index thống trị, với khoảng cách rất xa, là B+ tree. Lý do nằm ở một quyết định thiết kế duy nhất: kích thước node được chọn khớp với disk page, nên một lần lấy node là một lần I/O.
Một balanced binary tree chứa 100 triệu key sâu khoảng 27 tầng, tức 27 lần đọc page. Một B+ tree có node chứa ~400 key mỗi node (một page 8 KB với key và pointer 8 byte) có branching factor 400, nên độ sâu của nó là log₄₀₀(10⁸) ≈ 3. Ba lần đọc page, và root cùng tầng nội bộ đầu tiên gần như chắc chắn đã nằm trong cache, nên trên thực tế một lần tra cứu là một lần đọc vật lý. Fan-out cao chính là toàn bộ ý tưởng — xem ./11-balanced-and-multiway-trees.md để biết B-tree split, merge và giữ cân bằng như thế nào.
Sự phân biệt B-tree so với B+ tree chính là điều khiến B+ tree có hình dạng đúng cho một database index:
| B-tree | B+ tree | |
|---|---|---|
| Dữ liệu/pointer nằm ở đâu | Ở mọi node, cả nội bộ lẫn leaf | Chỉ ở leaf; node nội bộ chỉ giữ key phân tách |
| Fan-out của node nội bộ | Thấp hơn (mỗi key kèm payload) | Cao hơn (chỉ key ⇒ nhiều key mỗi page ⇒ tree nông hơn) |
| Point lookup | Có thể dừng sớm ở node nội bộ | Luôn đi xuống tới leaf (chi phí đồng đều, dự đoán được) |
| Range scan | Cần in-order traversal đi lên đi xuống | Các leaf được liên kết ⇒ một lần đi xuống, rồi đi ngang |
| Được dùng bởi | Một số filesystem, map trong memory | Về cơ bản là mọi index của relational database |
Dòng cuối cùng là dòng quyết định:
B+ tree, fan-out 4 — node nội bộ chỉ mang key phân tách
[ 10 | 20 ] <- root
/ | \
[4 | 7] [13 | 16] [24 | 27] <- nội bộ
/ | \ / | \ / | \
v v v v v v v v v
tầng leaf, mọi record đều có mặt, theo thứ tự key, liên kết trái sang phải:
[1 2 3] <-> [4 5 6] <-> [7 8 9] <-> [10 11 12] <-> ... <-> [28 29 30] -> None
WHERE k BETWEEN 5 AND 27:
một lần đi từ root xuống leaf để tìm 5 (3 lần đọc page)
rồi đi theo liên kết leaf tới 27 (đọc tuần tự, không đi xuống lại)
Một range scan trên B+ tree tốn O(log n + k) cho k kết quả, và — vì các leaf được liên kết và thường nằm gần nhau — k lần đọc đó gần như tuần tự. Trên B-tree thuần thì cùng phép quét đó phải nhảy lên nhảy xuống trong cây. Đây là lý do ORDER BY, BETWEEN, >, <, LIMIT kèm sort, và MIN/MAX đều rẻ trên B+ tree index, và là lý do “B-tree index” trong database gần như luôn nghĩa là B+ tree.
Độ phức tạp, tính theo số lần đọc page thay vì số phép so sánh:
| Phép toán | B+ tree index | Ghi chú |
|---|---|---|
| Point lookup | O(log_f n) ≈ 3–4 lần đọc | f = fan-out, thường 100–500 |
Range scan k row | O(log_f n + k/số_record_mỗi_page) | Tuần tự ở tầng leaf |
| Insert | O(log_f n) | Cộng thêm việc split node thỉnh thoảng lan lên trên |
| Delete | O(log_f n) | Cộng thêm merge/rebalance, hoặc tombstone + vacuum sau |
| Space | O(n) | Thường 1–3% kích thước table mỗi cột được index, với page đầy ~70–90% |
Hash index
Hash index áp hàm hash lên key và nhảy thẳng tới bucket chứa vị trí của record — O(1) kỳ vọng, không phụ thuộc kích thước table.
def build_hash_index(pages, buckets=8):
"""Hash index: key -> (page, slot). Tra cứu equality O(1), hoàn toàn không có thứ tự."""
index = [[] for _ in range(buckets)]
for pno, page in enumerate(pages):
for slot, (k, _) in enumerate(page):
index[hash(k) % buckets].append((k, pno, slot))
return index
def hash_lookup(pages, index, key):
"""Một lần đọc bucket, rồi quét ngắn bên trong nó. O(1) kỳ vọng."""
for k, pno, slot in index[hash(key) % len(index)]:
if k == key:
return pages[pno][slot][1]
return None
hidx = build_hash_index(pages)
print(hash_lookup(pages, hidx, 30)) # 'row-30'
print(hash_lookup(pages, hidx, 31)) # None
Cơ chế ở đây là hash table trong ./07-hash-tables.md, được chuyển lên disk — các biến thể on-disk (extendible hashing, linear hashing) tồn tại chính là để tránh cú khựng “rehash toàn bộ table cùng lúc” mà việc resize trong memory chấp nhận được.
Điều mà hash index không làm được chính là lý do nó hiếm khi là lựa chọn đúng:
- Không hỗ trợ range query. Hashing phá hủy thứ tự theo thiết kế;
WHERE x > 5hoàn toàn không dùng được nó. - Không hỗ trợ sort. Nó không thỏa mãn được
ORDER BY,MIN, hayMAX. - Không so khớp prefix.
LIKE 'abc%'là vô hình với nó. - Không dùng được leftmost-prefix của composite index. Hash của
(a, b)không nói gì về riênga.
B+ tree làm equality lookup trong 3–4 lần đọc page và làm tất cả những thứ còn lại nữa. Nên quy tắc thực tế là: mặc định dùng tree index, và chỉ dùng hashing trong trường hợp hẹp, đã đo đạc cụ thể — thường là key rất dài mà việc hash làm index nhỏ đi đáng kể, và bạn chắc chắn sẽ không bao giờ cần truy vấn range hay ordering. Hash index của PostgreSQL chỉ trở nên crash-safe từ phiên bản 10; xem Chiến lược Indexing để biết lịch sử đó và khuyến nghị hiện tại.
ISAM và vai trò lịch sử của nó
ISAM (Indexed Sequential Access Method), do IBM giới thiệu vào thập niên 1960, là tổ tiên trực tiếp của mọi thứ ở trên và đáng hiểu vì kiểu thất bại của nó giải thích tại sao B+ tree thắng.
Thiết kế: lưu data file sắp xếp vật lý theo key, và dựng một index tĩnh, sparse, nhiều tầng phía trên nó — một entry cho mỗi data block, rồi một index trên index đó, cho tới một root nhỏ. Một lần tra cứu đi xuống qua các tầng index và đáp xuống đúng một data block. Xử lý tuần tự (bảng lương, báo cáo theo lô — các workload của thời đó) đọc thẳng data file theo thứ tự key, và trên băng từ hay ổ đĩa quay thì đó là tối ưu.
Layout của ISAM
master index : [ 100 | 400 | 700 ]
/ | \
cylinder index : [100|200|300] [400|...] [700|...]
|
data block : [101..150][151..200]... <- sắp xếp vật lý, không chừa chỗ trống
|
overflow chain : [ 155 ] -> [ 158 ] -> [ 156 ] <- insert rơi vào đây, không sắp xếp
Vấn đề nằm ở chữ tĩnh. Index được dựng một lần, lúc tạo file. Một lần insert không vừa vào block gốc của nó sẽ đi vào overflow area, được móc xích từ block đó. Các lần đọc giờ phải kiểm tra cả block gốc lẫn đi hết chuỗi overflow của nó. Khi insert tích tụ, chuỗi dài ra, tra cứu suy giảm từ O(log n) về phía O(n) trên các block nóng, và cách chữa duy nhất là đưa file offline và reorganize nó — dựng lại toàn bộ theo thứ tự, với chỗ trống mới.
Việc reorganize offline định kỳ đó là một phần bình thường, được lên lịch của vận hành thập niên 1960–70 và hoàn toàn không chấp nhận được ngày nay. B-tree (Bayer và McCreight, 1970) giải quyết nó bằng cách làm cho index trở nên động: node split và merge tại chỗ khi dữ liệu tới, tree tự giữ cân bằng, và không có overflow area cũng không có cửa sổ reorganize. Chính IBM đã thay ISAM bằng VSAM, và B+ tree tiếp quản mọi nơi khác.
Di sản của ISAM vẫn còn thấy được trong từ vựng — storage engine gốc của MySQL tên là MyISAM, và “index-sequential” vẫn là cách người ta mô tả một clustered index. Và ý tưởng cốt lõi sống tiếp dưới hình thức hiện đại: LSM-tree, được RocksDB, LevelDB, Cassandra và HBase dùng, chính là thỏa thuận của ISAM được viết lại cho SSD. Chúng giữ các run bất biến đã sắp xếp, hấp thụ ghi vào một buffer trong memory, và compact (reorganize) ở nền — đánh đổi read amplification lấy write throughput rất cao. Xem NoSQL Databases.
Chi phí ghi của một index
Toàn bộ câu chuyện index cho tới giờ là về việc đọc. Mọi lợi ích đó đều được trả giá trên đường ghi, và đây là nơi sự nhiệt tình với index đi đến hồi kết.
Mỗi index nhân lên khối lượng công việc ghi. Một table có năm index biến một lệnh INSERT thành sáu lần sửa đổi cấu trúc: một lần ghi heap cộng năm lần chèn index, mỗi lần là một lượt đi xuống O(log n) riêng, một lần làm bẩn page riêng, và một khả năng split node riêng. Một mô hình thô:
INSERT vào một table có k secondary index:
1 lần ghi heap page
+ k lượt đi xuống index (log_f n lần đọc page mỗi lượt, phần lớn đã cache)
+ k lần ghi leaf page của index
+ thỉnh thoảng có page split, mỗi lần ghi 2-3 page và làm bẩn parent
+ bản ghi write-ahead log cho tất cả những thứ trên
Các hệ quả cụ thể đáng biết ở tầng cấu trúc dữ liệu:
- Chèn theo thứ tự ngẫu nhiên tệ hơn nhiều so với tuần tự. Chèn các key tăng đơn điệu (auto-increment id, timestamp) luôn nối vào leaf ngoài cùng bên phải, vốn luôn nóng trong cache — rẻ. Chèn key ngẫu nhiên (UUIDv4) làm bẩn một leaf page khác nhau mỗi lần, nên working set trở thành toàn bộ index. Đây là lý do UUIDv7 / ULID (UUID có thứ tự thời gian) tồn tại, và đó là một lý lẽ thuần túy về cấu trúc dữ liệu, không có gì đặc thù database cả.
UPDATEcó thể tốn hơnINSERT. Cập nhật một cột không được index vẫn có thể buộc phải cập nhật mọi index nếu storage engine ghi một phiên bản row mới (MVCC của PostgreSQL làm đúng như vậy, được giảm nhẹ nhưng không loại bỏ nhờ HOT update — xem Storage Internals & Vacuum).- Delete để lại rác. Xóa một entry thường chỉ đánh dấu là chết chứ không nén page ngay lập tức; việc thu hồi không gian là một tác vụ nền. Do đó index bị bloat — phình to hơn mức dữ liệu biện minh — khi có nhiều biến động.
- Bulk load nhanh hơn khi đã drop index. Nạp
nrow vào một table có index tốnnlần chènO(log n)riêng lẻ với truy cập page ngẫu nhiên; drop index, nạp dữ liệu, rồi dựng lại index thì chỉ sort một lần và điền page tuần tự. Với các đợt nạp lớn, cách này thường nhanh hơn cả một bậc độ lớn.
Chi phí lưu trữ. Một entry của B+ tree index xấp xỉ bằng key cộng một row pointer cộng phần overhead mỗi entry — cứ tạm tính ~16–24 byte cho một key số nguyên 8 byte, với page đầy 70–90%. Một index trên cột bigint của table 100 triệu row dễ dàng đạt 2–3 GB. Đánh index một cột text rộng và index có thể tiệm cận hoặc vượt cả bản thân table. Index cũng cạnh tranh cùng buffer cache với dữ liệu, nên một index không dùng tới không chỉ là disk lãng phí — nó chủ động đẩy ra những page mà bạn thực sự cần.
Tổng hợp lại: một index là một canh bạc rằng lượt đọc nhiều hơn lượt ghi với access pattern này. Trên một table nặng ghi với một truy vấn hiếm khi chạy, canh bạc đó thua. Index không dùng tới là chi phí thuần túy, và việc tìm ra rồi drop chúng là một trong những tác vụ bảo trì database có giá trị cao nhất và rủi ro thấp nhất.
Composite index và quy tắc leftmost-prefix
Một composite index (nhiều cột) sắp xếp các entry theo cột thứ nhất, rồi cột thứ hai trong các trường hợp bằng nhau, rồi cột thứ ba — hệt như sort tuple. Chính thứ tự đó quyết định truy vấn nào dùng được nó.
composite index trên (tenant_id, created_at, status), sắp theo thứ tự từ điển:
(1, 2024-01-01, 'new')
(1, 2024-01-01, 'paid')
(1, 2024-01-02, 'new')
(1, 2024-01-05, 'paid')
(2, 2024-01-01, 'new')
(2, 2024-01-03, 'shipped')
DÙNG ĐƯỢC (một khoảng liền kề trong index):
WHERE tenant_id = 1
WHERE tenant_id = 1 AND created_at >= '2024-01-02'
WHERE tenant_id = 1 AND created_at = '2024-01-01' AND status = 'paid'
ORDER BY tenant_id, created_at (vốn đã theo thứ tự này)
KHÔNG DÙNG ĐƯỢC như một lần tra cứu có thứ tự:
WHERE created_at >= '2024-01-02' -- row của ngày đó rải rác khắp các tenant
WHERE status = 'paid' -- riêng cột thứ ba: không liền kề chút nào
DÙNG ĐƯỢC MỘT PHẦN:
WHERE tenant_id = 1 AND status = 'paid'
-> tenant_id thu hẹp index về một khối liền kề;
status khi đó chỉ là bộ lọc áp dụng trong lúc quét khối đó
Quy tắc suy ra trực tiếp từ thứ tự sắp xếp và không cần học thuộc: một index chỉ dùng được cho một truy vấn tới mức mà truy vấn đó ràng buộc một prefix dẫn đầu các cột của nó, và chỉ cột bị ràng buộc cuối cùng mới được dùng vị từ range — một khi bạn quét range trên cột i, các cột sau i không còn theo thứ tự sắp xếp trong kết quả nữa. Nên thứ tự cột trong composite index là một quyết định thiết kế thực sự: đặt các vị từ equality trước, vị từ range sau cùng, và cột equality có selectivity cao nhất lên đầu.
Covering index và index-only scan
Một index bình thường trả lời “row nằm ở đâu”, sau đó engine lấy row từ heap — một lần đọc thứ hai, ngẫu nhiên, cho mỗi kết quả. Một covering index loại bỏ bước đó bằng cách chứa mọi cột mà truy vấn cần, để riêng index đã trả lời được:
SELECT status FROM orders WHERE tenant_id = 1 AND created_at > '2024-01-01';
index trên (tenant_id, created_at) -> tìm entry khớp trong index,
rồi một lần đọc heap ngẫu nhiên mỗi row
index trên (tenant_id, created_at, status) -> `status` đã có sẵn trong index;
(hoặc ... INCLUDE (status)) không đọc heap lần nào: INDEX-ONLY SCAN
Với một truy vấn trả về hàng nghìn row, điều này loại bỏ hàng nghìn lần đọc ngẫu nhiên và thường là cải thiện 10×. Đánh đổi vẫn là cái muôn thuở: index rộng hơn là index lớn hơn, ghi chậm hơn và chiếm chỗ cache nhiều hơn. Mệnh đề INCLUDE của Postgres tồn tại để thêm cột payload vào tầng leaf mà không thêm chúng vào sort key của cây, giữ cho node nội bộ vẫn hẹp — một minh họa đẹp rằng việc B+ tree tách key nội bộ khỏi payload ở leaf không phải là ngẫu nhiên. Chi tiết và cú pháp nằm ở Chiến lược Indexing.
Chọn cấu trúc index
| Access pattern | Cấu trúc | Lý do |
|---|---|---|
Equality và range, sorting, MIN/MAX, LIKE prefix | B+ tree | Mặc định; xử lý gần như mọi thứ trong 3–4 lần đọc page |
| Chỉ equality, key rất dài, đã đo được nhu cầu | Hash index | O(1), nhưng không thứ tự, không range, không prefix |
| Dữ liệu chỉ đọc, dựng hàng loạt, không bao giờ update | File phẳng đã sắp xếp + binary search | Đơn giản và nhỏ nhất; chèn O(n) khiến nó chỉ đọc |
| Dữ liệu sắp xếp vật lý, xử lý tuần tự theo lô | Sparse index kiểu ISAM | Index rất nhỏ; suy giảm nặng khi có insert |
| Nặng ghi, đọc chủ yếu tuần tự | LSM-tree | Buffer việc ghi, compact ở nền |
| Kiểm tra thành viên trên keyspace khổng lồ, chấp nhận false positive | Bloom filter | Bit chứ không phải entry; dùng phía trước index LSM |
| ”Row nào chứa phần tử/từ này?” | Inverted index (GIN) | Một entry cho mỗi token, trỏ tới mọi row chứa nó |
| Dữ liệu rất lớn, có cụm tự nhiên (time-series) | Block-range index (BRIN) | Lưu min/max mỗi dải block; rất nhỏ, cần tương quan |
| Prefix/autocomplete trên chuỗi | Trie / radix trie | Xem ./17-advanced-tree-structures.md |
| Tìm substring trên một corpus cố định | Suffix array / FM-index | Xem ./17-advanced-tree-structures.md |
Dòng đầu tiên bao phủ đại đa số các trường hợp thực tế, và đó chính là điểm mấu chốt: tính đa năng của B+ tree đáng giá hơn lợi thế của bất kỳ cấu trúc chuyên biệt nào ở một chiều duy nhất. Các access method GIN, GiST, SP-GiST và BRIN của PostgreSQL chính là các dòng chuyên biệt được cụ thể hóa — xem Chiến lược Indexing để biết khi nào mỗi loại xứng đáng có chỗ.
Best Practices
- Đánh index cho truy vấn, không phải cho cột. Index tồn tại để phục vụ một access pattern cụ thể. Hãy bắt đầu từ những truy vấn bạn thực sự chạy — mệnh đề
WHERE,JOIN,ORDER BYcủa chúng — chứ không phải từ thói quen đánh index mọi foreign key và mọi cột “trông có vẻ tìm kiếm được”. - Mặc định dùng B+ tree. Nó xử lý equality, range, sorting,
MIN/MAX, và so khớp prefix trong 3–4 lần đọc page. Chỉ chọn cấu trúc chuyên biệt khi bạn gọi được tên truy vấn mà nó phục vụ và đã đo được rằng B+ tree không đủ. - Nhớ rằng index là một canh bạc về tỷ lệ đọc/ghi. Mỗi index làm mọi lần ghi vào table đó đắt hơn, vĩnh viễn. Trên một table nặng ghi phục vụ một truy vấn chạy hai lần mỗi ngày, canh bạc đó thua.
- Đặt cột equality lên trước trong composite index, cột range sau cùng. Quy tắc leftmost-prefix là hệ quả trực tiếp của thứ tự sắp xếp từ điển, và đặt sai thứ tự cột khiến một index vốn đúng trở nên vô dụng với một nửa số truy vấn của bạn.
- Ưu tiên một composite index được sắp xếp tốt hơn là nhiều index một cột. Ba index riêng lẻ nhân ba chi phí ghi và thường vẫn buộc phải lấy dữ liệu từ heap; một composite index trên cùng các cột đó có thể phủ trọn truy vấn.
- Cân nhắc covering index cho các đường đọc nóng. Thêm các cột được chiếu ra sẽ biến một range scan cộng
klần đọc heap ngẫu nhiên thành một index-only scan tuần tự duy nhất. Hãy cân nhắc với kích thước index lớn hơn và chi phí ghi thêm. - Chèn theo thứ tự key khi có thể. Key đơn điệu nối vào một leaf page nóng duy nhất; key ngẫu nhiên (UUIDv4) làm bẩn một page khác nhau mỗi lần và biến cả index thành working set. Các định danh có thứ tự thời gian (UUIDv7, ULID, Snowflake) tồn tại chính vì lý do này.
- Drop index trước một đợt bulk load rồi dựng lại sau. Một lần dựng hàng loạt có sắp xếp sẽ điền page tuần tự;
nlần chèn riêng lẻ thực hiệnnlượt đi xuốngO(log n)ngẫu nhiên. Khác biệt thường xuyên là cả một bậc độ lớn. - Tìm và drop các index không dùng tới. Chúng tốn disk, tốn write throughput, và đẩy các page hữu ích ra khỏi buffer cache. Gần như mọi database đều cung cấp thống kê sử dụng theo từng index; kiểm tra định kỳ vừa rẻ vừa hiệu quả cao.
- Đừng ngạc nhiên khi planner bỏ qua index của bạn. Vượt quá khoảng 5–20% selectivity, sequential scan thắng index scan cộng các lần lấy heap ngẫu nhiên, và planner thường đúng. Nếu nó sai, cách sửa là cải thiện statistics hoặc dùng covering index, chứ không phải ép dùng index — xem Query Planning & Performance Tuning.
- Hiểu cấu trúc trước khi tune database. “Tại sao
LIKE '%foo'không dùng được index?”, “tại sao composite index cần các cột theo đúng thứ tự đó?”, và “tại sao UUID primary key làm chậm insert?” đều có cùng một câu trả lời: đó là một B+ tree, và B+ tree thì được sắp xếp. Góc nhìn cấu trúc dữ liệu trả lời cho các câu hỏi của DBA.
Tài liệu tham khảo
- roadmap.sh — Data Structures & Algorithms
- Database index — Wikipedia
- B-tree — Wikipedia
- B+ tree — Wikipedia
- ISAM — Wikipedia
- Log-structured merge-tree — Wikipedia
- Row- and column-major order — Wikipedia
- Inverted index — Wikipedia
- Use The Index, Luke! — a guide to SQL indexing
- PostgreSQL Documentation — Indexes
- CLRS — Introduction to Algorithms, Chapter 18: B-Trees
- Python Documentation —
bisect
Part of the Data Structures & Algorithms Roadmap knowledge base.
Overview
Every structure in this roadmap so far has been studied in memory: an array is a contiguous block, a B-tree is a set of nodes, a hash table is a bucket array. Indexing is what happens when you take those same structures and point them at data that does not fit in memory — a table on disk, a file, a column store. The algorithms do not change. What changes is the cost model: a pointer dereference stops being free and becomes a page read, and that single fact reshapes every design decision.
An index is a separate data structure that maps a value to the location of the records containing it. Without one, finding a row means reading every row — a full scan, O(n) in the size of the table. With one, finding a row means a handful of lookups in a much smaller structure — O(log n) for a tree index, O(1) for a hash index — followed by a direct jump to the record. On a table of 100 million rows, that is the difference between reading 10 GB and reading four 8 KB pages.
Nothing here is new theory. A B+ tree index is the multiway tree from ./11-balanced-and-multiway-trees.md, with node size chosen to match the disk page. A hash index is the hash table from ./07-hash-tables.md, with buckets that live on disk. A sorted index file searched with binary search is ./09-search-algorithms.md. This note is about the applied form: which structure to choose for which access pattern, what the index costs you on the write path, and why the answer is never “index everything”.
This is deliberately the data-structures view of indexing, not a DBA manual. For the operational treatment — PostgreSQL’s six index access methods, partial and expression indexes, EXPLAIN output, bloat, REINDEX CONCURRENTLY — go to Indexing Strategies and Query Planning & Performance Tuning. For where indexes sit in application-level database design, see Relational Databases.
Fundamentals
Why indexes exist: the cost of a full scan
Consider a table stored as a sequence of fixed-size pages (8 KB in PostgreSQL, 16 KB in InnoDB), each holding some number of records. Answering WHERE id = 30 without an index means reading page after page and checking every record.
import bisect
PAGE_SIZE = 4
records = [(i, f"row-{i}") for i in range(0, 40, 2)] # keys 0, 2, 4, ..., 38
pages = [records[i:i + PAGE_SIZE] for i in range(0, len(records), PAGE_SIZE)]
def full_scan(pages, key):
"""No index: read pages until the key turns up. O(P) page reads."""
reads = 0
for page in pages:
reads += 1 # one page read = one unit of I/O
for k, v in page:
if k == key:
return v, reads
return None, reads
print(full_scan(pages, 30)) # ('row-30', 4)
print(full_scan(pages, 99)) # (None, 5) — a miss reads everything
Two things to notice. A hit costs on average half the pages; a miss always costs all of them. And the constant matters enormously: a page read from an SSD is ~100 μs and from a spinning disk ~10 ms, versus ~100 ns for a cached in-memory access. An index does not just reduce the number of operations, it converts expensive operations into cheap ones.
The index as a second, smaller structure
An index stores (indexed value → location) pairs, sorted or hashed by value. It is smaller than the table because it holds only the indexed column plus a pointer, so more entries fit per page, so the structure is shallower and more of it stays in cache.
TABLE (heap) — physical order is arbitrary INDEX on `email` — sorted by value
page 0: (7, "carol@x", ...) "alice@x" → (page 1, slot 0)
(2, "erin@x", ...) "bob@x" → (page 2, slot 1)
page 1: (5, "alice@x", ...) "carol@x" → (page 0, slot 0)
(9, "dan@x", ...) "dan@x" → (page 1, slot 1)
page 2: (1, "frank@x", ...) "erin@x" → (page 0, slot 1)
(4, "bob@x", ...) "frank@x" → (page 2, slot 0)
Two structural choices define the whole family:
- Dense index — one entry per record. Required for any column whose values are not the table’s physical sort order, because you cannot skip to a record you have no pointer to. Every secondary index is dense.
- Sparse index — one entry per page, holding that page’s smallest key. Only possible when the data file is physically sorted by the indexed key, but dramatically smaller: with 100 records per page, the sparse index is 1% the size of a dense one.
# SPARSE index: one entry per page, holding that page's smallest key.
sparse_index = [(page[0][0], pno) for pno, page in enumerate(pages)]
def sparse_lookup(pages, index, key):
"""Binary search the index to pick exactly one page, then scan that page."""
keys = [k for k, _ in index]
pos = bisect.bisect_right(keys, key) - 1 # last page whose first key <= key
if pos < 0:
return None, 0
_, pno = index[pos]
reads = 1 # exactly one data page is touched
for k, v in pages[pno]:
if k == key:
return v, reads
return None, reads
print(sparse_lookup(pages, sparse_index, 30)) # ('row-30', 1)
print(sparse_lookup(pages, sparse_index, 99)) # (None, 1) — a miss is cheap too
Four page reads become one, and the miss case improves from five reads to one. That ratio only grows with table size, which is the entire argument for indexing.
A second axis:
- Clustered / primary index — the table’s records are physically stored in this index’s order (InnoDB’s primary key, PostgreSQL after
CLUSTER). There can be only one, and range scans on it are sequential I/O, which is the fastest thing a disk does. - Non-clustered / secondary index — a separate structure pointing back at the heap. Any number of them, but a range scan produces random reads into the heap, one per matching row.
That distinction explains the most counter-intuitive fact about indexes: beyond a certain selectivity, an index is slower than a full scan. If a query matches 30% of a large table, following a secondary index means 30% of the rows fetched as random single-page reads, which is far more expensive than streaming the whole table sequentially. Real query planners estimate this and switch to a sequential scan — see Query Planning & Performance Tuning for how that decision is actually made and how to read it in EXPLAIN.
Linear / sequential indexing
“Linear indexing” covers two related simple ideas, both worth naming because they are the baseline everything else improves on.
1. Linear indexing of a multidimensional array. A 2-D array in memory is really a 1-D block; the “index” is the arithmetic that flattens (row, col) into a single offset. In row-major order (C, Python, most languages) that is:
2-D view (3 rows x 4 cols) linear (row-major) layout in memory
c0 c1 c2 c3 offset: 0 1 2 3 4 5 6 7 8 9 10 11
r0 a b c d value: a b c d e f g h i j k l
r1 e f g h |__ row 0 __|__ row 1 __|__ row 2 __|
r2 i j k l
offset(r, c) = r * cols + c e.g. offset(1, 2) = 1*4 + 2 = 6 -> 'g'
def linear_index(r, c, cols):
"""Row-major flattening: O(1), and the reason iterating rows beats columns."""
return r * cols + c
This is O(1) address arithmetic with no lookup at all, and it is exactly the trick used to put a grid into a union-find in ./16-disjoint-set-union-find.md. Its practical significance is cache locality: iterating for r: for c: walks memory sequentially and is often several times faster than for c: for r:, which strides across the whole array on every step. Column-major languages (Fortran, MATLAB, and NumPy when you ask for it) invert the formula, and getting it backwards is a classic performance bug. See ./04-arrays.md.
2. A linear (sequential) index file. The database-side meaning: keep the index as a flat, sorted array of (key, location) pairs. Lookup is a binary search — O(log n) comparisons, and if the array is on disk, O(log n) page reads.
def linear_index_lookup(index, key):
"""Binary search a flat sorted index. O(log n) — see ./09-search-algorithms.md."""
keys = [k for k, _ in index]
pos = bisect.bisect_left(keys, key)
if pos < len(index) and index[pos][0] == key:
return index[pos][1]
return None
Its fatal flaw is insertion: putting a new key in the middle of a sorted array costs O(n) because everything after it shifts. That is fine for a read-only index rebuilt in bulk (a data warehouse, a search index built offline) and useless for anything transactional. Everything in the rest of this note exists to keep the O(log n) search while making insertion cheap too.
Key Concepts
Tree-based indexing: B-trees and B+ trees
The dominant index structure, by an enormous margin, is the B+ tree. The reason is a single design decision: the node size is chosen to match the disk page, so one node fetch is one I/O.
A balanced binary tree of 100 million keys is ~27 levels deep, which would be 27 page reads. A B+ tree whose nodes hold ~400 keys each (an 8 KB page with 8-byte keys and pointers) has a branching factor of 400, so its depth is log₄₀₀(10⁸) ≈ 3. Three page reads, and the root and the first internal level are almost certainly cached, so a lookup is realistically one physical read. High fan-out is the whole idea — see ./11-balanced-and-multiway-trees.md for how B-trees split, merge, and stay balanced.
The B-tree versus B+ tree distinction is exactly what makes B+ trees the right shape for a database index:
| B-tree | B+ tree | |
|---|---|---|
| Where data/pointers live | In every node, internal and leaf | Leaves only; internal nodes hold separator keys |
| Internal node fan-out | Lower (each key carries a payload) | Higher (keys only ⇒ more keys per page ⇒ shallower tree) |
| Point lookup | Can stop early at an internal node | Always descends to a leaf (uniform, predictable cost) |
| Range scan | Requires in-order traversal back up and down | Leaves are linked ⇒ one descent, then walk sideways |
| Used by | Some filesystems, in-memory maps | Essentially every relational database index |
That last row is the decisive one:
B+ tree, fan-out 4 — internal nodes carry separator keys only
[ 10 | 20 ] <- root
/ | \
[4 | 7] [13 | 16] [24 | 27] <- internal
/ | \ / | \ / | \
v v v v v v v v v
leaf level, every record present, in key order, linked left to right:
[1 2 3] <-> [4 5 6] <-> [7 8 9] <-> [10 11 12] <-> ... <-> [28 29 30] -> None
WHERE k BETWEEN 5 AND 27:
one root-to-leaf descent to find 5 (3 page reads)
then follow leaf links until 27 (sequential reads, no re-descent)
A range scan on a B+ tree costs O(log n + k) for k results, and — because the leaves are linked and usually laid out near each other — those k reads are close to sequential. On a plain B-tree the same scan bounces up and down the tree. This is why ORDER BY, BETWEEN, >, <, LIMIT with a sort, and MIN/MAX are all cheap on a B+ tree index, and it is why “B-tree index” in a database almost always means B+ tree.
Complexity, in page reads rather than comparisons:
| Operation | B+ tree index | Note |
|---|---|---|
| Point lookup | O(log_f n) ≈ 3–4 reads | f = fan-out, typically 100–500 |
Range scan of k rows | O(log_f n + k/records_per_page) | Sequential at the leaf level |
| Insert | O(log_f n) | Plus occasional node splits propagating upward |
| Delete | O(log_f n) | Plus merges/rebalancing, or tombstones + later vacuum |
| Space | O(n) | Typically 1–3% of table size per indexed column, at ~70–90% page fill |
Hash indexes
A hash index applies the hash function to the key and jumps straight to the bucket holding the record’s location — O(1) expected, independent of table size.
def build_hash_index(pages, buckets=8):
"""Hash index: key -> (page, slot). O(1) equality lookup, no ordering at all."""
index = [[] for _ in range(buckets)]
for pno, page in enumerate(pages):
for slot, (k, _) in enumerate(page):
index[hash(k) % buckets].append((k, pno, slot))
return index
def hash_lookup(pages, index, key):
"""One bucket read, then a short scan within it. Expected O(1)."""
for k, pno, slot in index[hash(key) % len(index)]:
if k == key:
return pages[pno][slot][1]
return None
hidx = build_hash_index(pages)
print(hash_lookup(pages, hidx, 30)) # 'row-30'
print(hash_lookup(pages, hidx, 31)) # None
The mechanics are the hash table of ./07-hash-tables.md, moved onto disk — the on-disk variants (extendible hashing, linear hashing) exist specifically to avoid the “rehash the entire table at once” pause that in-memory resizing accepts.
What a hash index cannot do is the reason it is rarely the right choice:
- No range queries. Hashing destroys order by design;
WHERE x > 5cannot use it at all. - No sorting. It cannot satisfy
ORDER BY,MIN, orMAX. - No prefix matching.
LIKE 'abc%'is invisible to it. - No leftmost-prefix use of a composite index. A hash of
(a, b)says nothing aboutaalone.
A B+ tree does equality lookups in 3–4 page reads and does everything else too. So the practical rule is: default to a tree index, and reach for hashing only in a narrow, measured case — usually very long keys where hashing shrinks the index a lot, and where you are certain no range or ordering query will ever be needed. PostgreSQL’s hash indexes only became crash-safe in version 10; see Indexing Strategies for that history and the current guidance.
ISAM and its historical role
ISAM (Indexed Sequential Access Method), introduced by IBM in the 1960s, is the direct ancestor of everything above and is worth understanding because its failure mode explains why B+ trees won.
The design: store the data file physically sorted by key, and build a static, sparse, multi-level index over it — one entry per data block, then an index over the index, up to a small root. A lookup descends the index levels and lands on exactly one data block. Sequential processing (payroll, batch reports — the workloads of the era) reads the data file straight through in key order, which on a tape or a spinning disk is optimal.
ISAM layout
master index : [ 100 | 400 | 700 ]
/ | \
cylinder index : [100|200|300] [400|...] [700|...]
|
data blocks : [101..150][151..200]... <- physically sorted, no free space
|
overflow chain : [ 155 ] -> [ 158 ] -> [ 156 ] <- inserts land here, unsorted
The problem is the word static. The index is built once, at file creation. An insert that does not fit in its home block goes into an overflow area, chained off that block. Reads must now check the home block and walk its overflow chain. As inserts accumulate the chains grow, lookups degrade from O(log n) toward O(n) on hot blocks, and the only cure is taking the file offline and reorganizing it — rebuilding the whole thing sorted, with fresh free space.
That periodic offline reorganization was a normal, scheduled part of 1960s–70s operations and is completely unacceptable today. The B-tree (Bayer and McCreight, 1970) solved it by making the index dynamic: nodes split and merge in place as data arrives, the tree stays balanced automatically, and there is no overflow area and no reorganization window. IBM itself replaced ISAM with VSAM, and B+ trees took over everywhere else.
ISAM’s legacy is still visible in the vocabulary — MySQL’s original storage engine was called MyISAM, and “index-sequential” is still how people describe a clustered index. And the core idea survives in a modern form: LSM-trees, used by RocksDB, LevelDB, Cassandra, and HBase, are ISAM’s bargain rewritten for SSDs. They keep sorted immutable runs, absorb writes into an in-memory buffer, and compact (reorganize) in the background — trading read amplification for very high write throughput. See NoSQL Databases.
The write cost of an index
The entire index story so far has been about reads. Every one of those gains is paid for on the write path, and this is where index enthusiasm goes to die.
Every index multiplies write work. A table with five indexes turns one INSERT into six structure modifications: one heap write plus five index insertions, each of which is its own O(log n) descent, its own page dirtying, and its own possible node split. A rough model:
INSERT into a table with k secondary indexes:
1 heap page write
+ k index descents (log_f n page reads each, mostly cached)
+ k index leaf page writes
+ occasional page splits, each writing 2-3 pages and dirtying the parent
+ write-ahead log records for all of the above
Concrete consequences worth knowing at the data-structure level:
- Random insertion order is much worse than sequential. Inserting monotonically increasing keys (an auto-increment id, a timestamp) always appends to the rightmost leaf, which stays hot in cache — cheap. Inserting random keys (a UUIDv4) dirties a different leaf page every time, so the working set becomes the whole index. This is why UUIDv7 / ULID (time-ordered UUIDs) exist, and it is a pure data-structure argument with nothing database-specific about it.
UPDATEcan cost more thanINSERT. Updating a non-indexed column may still require updating every index if the storage engine writes a new row version (PostgreSQL’s MVCC does exactly this, mitigated but not eliminated by HOT updates — see Storage Internals & Vacuum).- Deletes leave debris. Removing an entry usually marks it dead rather than compacting the page immediately; reclaiming the space is a background job. Indexes therefore bloat — they grow larger than the data justifies — under churn.
- Bulk loading is faster with indexes dropped. Loading
nrows into an indexed table costsnseparateO(log n)insertions with random page access; dropping the index, loading, and rebuilding it sorts once and fills pages sequentially. For large loads this is often an order of magnitude faster.
Storage cost. A B+ tree index entry is roughly the key plus a row pointer plus per-entry overhead — call it ~16–24 bytes for an 8-byte integer key, at 70–90% page fill. An index on a bigint column of a 100-million-row table is comfortably 2–3 GB. Index a wide text column and the index can approach or exceed the table itself. Indexes also compete for the same buffer cache as the data, so an unused index is not merely wasted disk — it actively evicts pages you do need.
The synthesis: an index is a bet that reads outnumber writes for this access pattern. On a write-heavy table with a rarely used query, the bet loses. Unused indexes are pure cost, and finding and dropping them is one of the highest-value, lowest-risk database maintenance tasks there is.
Composite indexes and the leftmost-prefix rule
A composite (multi-column) index sorts entries by the first column, then the second within ties, then the third — exactly like sorting tuples. That ordering is what determines which queries can use it.
composite index on (tenant_id, created_at, status), sorted lexicographically:
(1, 2024-01-01, 'new')
(1, 2024-01-01, 'paid')
(1, 2024-01-02, 'new')
(1, 2024-01-05, 'paid')
(2, 2024-01-01, 'new')
(2, 2024-01-03, 'shipped')
USABLE (contiguous range in the index):
WHERE tenant_id = 1
WHERE tenant_id = 1 AND created_at >= '2024-01-02'
WHERE tenant_id = 1 AND created_at = '2024-01-01' AND status = 'paid'
ORDER BY tenant_id, created_at (already in this order)
NOT USABLE as an ordered lookup:
WHERE created_at >= '2024-01-02' -- rows for that date are scattered across tenants
WHERE status = 'paid' -- third column alone: no contiguity at all
PARTIALLY usable:
WHERE tenant_id = 1 AND status = 'paid'
-> tenant_id narrows the index to a contiguous block;
status is then just a filter applied while scanning that block
The rule follows directly from the sort order and needs no memorization: an index can be used for a query only as far as the query constrains a leading prefix of its columns, and only the last constrained column may use a range predicate — once you range-scan on column i, columns after i are no longer in sorted order within the results. So column order in a composite index is a real design decision: put equality predicates first, the range predicate last, and the most selective equality column at the front.
Covering indexes and index-only scans
An index normally answers “where is the row”, after which the engine fetches the row from the heap — a second, random read per result. A covering index eliminates that step by including every column the query needs, so the index alone can answer it:
SELECT status FROM orders WHERE tenant_id = 1 AND created_at > '2024-01-01';
index on (tenant_id, created_at) -> find matching entries in the index,
then one random heap read per row
index on (tenant_id, created_at, status) -> `status` is already in the index;
(or ... INCLUDE (status)) zero heap reads: an INDEX-ONLY SCAN
For a query returning thousands of rows this removes thousands of random reads and is frequently a 10× improvement. The trade-off is the general one: a wider index is a bigger index, slower to write and more cache it displaces. Postgres’s INCLUDE clause exists to add payload columns to the leaf level without adding them to the tree’s sort key, keeping the internal nodes narrow — a nice illustration that the B+ tree’s separation of internal keys from leaf payloads is not an accident. Details and syntax live in Indexing Strategies.
Choosing an index structure
| Access pattern | Structure | Why |
|---|---|---|
Equality and range, sorting, MIN/MAX, prefix LIKE | B+ tree | The default; handles nearly everything at 3–4 page reads |
| Equality only, very long keys, measured need | Hash index | O(1), but no ordering, no ranges, no prefixes |
| Read-only data, bulk-built, never updated | Sorted flat file + binary search | Simplest and smallest; O(n) insertion makes it read-only |
| Physically sorted data, batch sequential processing | Sparse/ISAM-style index | Tiny index; degrades badly under insertion |
| Write-heavy, mostly sequential reads | LSM-tree | Buffers writes, compacts in the background |
| Membership test over huge keyspace, false positives OK | Bloom filter | Bits, not entries; used in front of LSM indexes |
| ”Which rows contain this element/word?” | Inverted index (GIN) | One entry per token, pointing at all rows containing it |
| Very large, naturally clustered data (time-series) | Block-range index (BRIN) | Stores min/max per block range; tiny, needs correlation |
| Prefix/autocomplete over strings | Trie / radix trie | See ./17-advanced-tree-structures.md |
| Substring search over a fixed corpus | Suffix array / FM-index | See ./17-advanced-tree-structures.md |
The first row covers the overwhelming majority of real cases, and that is the point: a B+ tree’s versatility is worth more than any specialized structure’s edge in one dimension. PostgreSQL’s GIN, GiST, SP-GiST, and BRIN methods are the specialized rows made concrete — see Indexing Strategies for when each earns its place.
Best Practices
- Index the query, not the column. An index exists to serve a specific access pattern. Start from the queries you actually run — their
WHERE,JOIN, andORDER BYclauses — not from a habit of indexing every foreign key and every column that “looks searchable”. - Default to a B+ tree. It handles equality, ranges, sorting,
MIN/MAX, and prefix matching in 3–4 page reads. Choose a specialized structure only when you can name the query it serves and have measured that the B+ tree is inadequate. - Remember that an index is a bet on the read/write ratio. Every index makes every write to that table more expensive, permanently. On a write-heavy table serving a query that runs twice a day, the bet loses.
- Put equality columns first in a composite index, the range column last. The leftmost-prefix rule is a direct consequence of lexicographic sort order, and getting the column order wrong makes an otherwise correct index unusable for half your queries.
- Prefer one well-ordered composite index over several single-column indexes. Three separate indexes triple the write cost and often still force a heap fetch; one composite index over the same columns may cover the query outright.
- Consider a covering index for hot read paths. Adding the projected columns turns a range scan plus
krandom heap reads into a single sequential index-only scan. Weigh it against the larger index size and the extra write cost. - Insert in key order when you can. Monotonic keys append to one hot leaf page; random keys (UUIDv4) dirty a different page every time and turn the whole index into the working set. Time-ordered identifiers (UUIDv7, ULID, Snowflake) exist for exactly this reason.
- Drop indexes before a bulk load and rebuild afterwards. One sorted bulk build fills pages sequentially;
nindividual insertions donrandomO(log n)descents. The difference is routinely an order of magnitude. - Find and drop unused indexes. They cost disk, they cost write throughput, and they evict useful pages from the buffer cache. Nearly every database exposes per-index usage statistics; checking them periodically is cheap and high-yield.
- Do not be surprised when the planner ignores your index. Past roughly 5–20% selectivity, a sequential scan beats an index scan plus random heap fetches, and the planner is usually right. If it is wrong, the fix is better statistics or a covering index, not forcing the index — see Query Planning & Performance Tuning.
- Understand the structure before tuning the database. “Why is
LIKE '%foo'unindexable?” and “why does a composite index need its columns in that order?” and “why does a UUID primary key slow down inserts?” all have the same answer: it is a B+ tree, and B+ trees are sorted. The data-structure view answers the DBA questions.
References
- roadmap.sh — Data Structures & Algorithms
- Database index — Wikipedia
- B-tree — Wikipedia
- B+ tree — Wikipedia
- ISAM — Wikipedia
- Log-structured merge-tree — Wikipedia
- Row- and column-major order — Wikipedia
- Inverted index — Wikipedia
- Use The Index, Luke! — a guide to SQL indexing
- PostgreSQL Documentation — Indexes
- CLRS — Introduction to Algorithms, Chapter 18: B-Trees
- Python Documentation —
bisect