← Cấu trúc dữ liệu & Giải thuật← Data Structures & Algorithms
Cấu trúc dữ liệu & Giải thuậtData Structures & Algorithms7 Th8, 2026Aug 7, 202628 phút đọc24 min read

IndexingIndexing

Mục lục
  1. Tổng quan
  2. Kiến thức nền tảng
  3. Tại sao index tồn tại: chi phí của một lần full scan
  4. Index như một cấu trúc thứ hai, nhỏ hơn
  5. Linear / sequential indexing
  6. Khái niệm chính
  7. Tree-based indexing: B-tree và B+ tree
  8. Hash index
  9. ISAM và vai trò lịch sử của nó
  10. Chi phí ghi của một index
  11. Composite index và quy tắc leftmost-prefix
  12. Covering index và index-only scan
  13. Chọn cấu trúc index
  14. Best Practices
  15. Tài liệu tham khảo
Table of contents
  1. Overview
  2. Fundamentals
  3. Why indexes exist: the cost of a full scan
  4. The index as a second, smaller structure
  5. Linear / sequential indexing
  6. Key Concepts
  7. Tree-based indexing: B-trees and B+ trees
  8. Hash indexes
  9. ISAM and its historical role
  10. The write cost of an index
  11. Composite indexes and the leftmost-prefix rule
  12. Covering indexes and index-only scans
  13. Choosing an index structure
  14. Best Practices
  15. 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 IndexingQuery 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:

# 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:

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-treeB+ tree
Dữ liệu/pointer nằm ở đâuỞ mọi node, cả nội bộ lẫn leafChỉ ở 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 lookupCó 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 scanCần in-order traversal đi lên đi xuốngCác leaf được liên kết ⇒ một lần đi xuống, rồi đi ngang
Được dùng bởiMột số filesystem, map trong memoryVề 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ánB+ tree indexGhi chú
Point lookupO(log_f n) ≈ 3–4 lần đọcf = fan-out, thường 100–500
Range scan k rowO(log_f n + k/số_record_mỗi_page)Tuần tự ở tầng leaf
InsertO(log_f n)Cộng thêm việc split node thỉnh thoảng lan lên trên
DeleteO(log_f n)Cộng thêm merge/rebalance, hoặc tombstone + vacuum sau
SpaceO(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:

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:

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 patternCấu trúcLý do
Equality range, sorting, MIN/MAX, LIKE prefixB+ treeMặ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ầuHash indexO(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ờ updateFile 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 ISAMIndex rất nhỏ; suy giảm nặng khi có insert
Nặng ghi, đọc chủ yếu tuần tựLSM-treeBuffer việc ghi, compact ở nền
Kiểm tra thành viên trên keyspace khổng lồ, chấp nhận false positiveBloom filterBit 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ỗiTrie / radix trieXem ./17-advanced-tree-structures.md
Tìm substring trên một corpus cố địnhSuffix array / FM-indexXem ./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

Tài liệu tham khảo

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:

# 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:

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-treeB+ tree
Where data/pointers liveIn every node, internal and leafLeaves only; internal nodes hold separator keys
Internal node fan-outLower (each key carries a payload)Higher (keys only ⇒ more keys per page ⇒ shallower tree)
Point lookupCan stop early at an internal nodeAlways descends to a leaf (uniform, predictable cost)
Range scanRequires in-order traversal back up and downLeaves are linked ⇒ one descent, then walk sideways
Used bySome filesystems, in-memory mapsEssentially 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:

OperationB+ tree indexNote
Point lookupO(log_f n) ≈ 3–4 readsf = fan-out, typically 100–500
Range scan of k rowsO(log_f n + k/records_per_page)Sequential at the leaf level
InsertO(log_f n)Plus occasional node splits propagating upward
DeleteO(log_f n)Plus merges/rebalancing, or tombstones + later vacuum
SpaceO(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:

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:

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 patternStructureWhy
Equality and range, sorting, MIN/MAX, prefix LIKEB+ treeThe default; handles nearly everything at 3–4 page reads
Equality only, very long keys, measured needHash indexO(1), but no ordering, no ranges, no prefixes
Read-only data, bulk-built, never updatedSorted flat file + binary searchSimplest and smallest; O(n) insertion makes it read-only
Physically sorted data, batch sequential processingSparse/ISAM-style indexTiny index; degrades badly under insertion
Write-heavy, mostly sequential readsLSM-treeBuffers writes, compacts in the background
Membership test over huge keyspace, false positives OKBloom filterBits, 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 stringsTrie / radix trieSee ./17-advanced-tree-structures.md
Substring search over a fixed corpusSuffix array / FM-indexSee ./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

References