All writing2026.09.22 · CS 411 · Database Systems · 3 min

B+ Trees and Hash Indexes: Choosing Through Page I/O

Tree structure, fanout math, update cost, runnable PostgreSQL, EXPLAIN interpretation, and a practical index decision table.

The purpose of an index is not to save a few comparisons. It is to reduce the number of pages a query reads.

B+ Tree preserves order

                         [40 | 80]          root
                    /        |        \
          [10 20 30] -> [40 50 60] -> [80 90 100]
             leaf          leaf           leaf

Internal nodes contain separator keys and child pointers. Sorted leaf pages contain index entries and links to neighboring leaves.

An equality lookup descends to one leaf. A range lookup finds its lower bound, then follows leaf links until it passes the upper bound. The same order can satisfy ORDER BY when the requested ordering matches the index.

In plain terms: internal pages are a directory; leaves are shelves. Once a range query finds its first book, it walks along the shelf.

Fanout keeps the tree shallow

The course uses a simplified 4,096-byte page, 4-byte key, and 8-byte pointer:

(2d × 4) + ((2d + 1) × 8) ≤ 4096
d ≈ 170

After allowing free space for updates, an illustrative average fanout is 133:

133³ ≈   2,352,637 entries
133⁴ ≈ 312,900,700 entries

These are scale estimates, not an exact formula for a particular engine. The lesson is that one page chooses among many children, so the tree gains levels slowly.

Writes maintain the tree

A full leaf splits and sends a separator to its parent. A parent can split in turn; only a root split increases tree height. After deletion, an underfull node tries redistribution with a sibling before merging.

Each additional index therefore adds work to inserts, updates, and deletes. Read improvement must justify write amplification and cache usage.

Hash gives up order

A hash index computes h(key) and jumps to a bucket. With even distribution and no long overflow chain, equality can be efficient. But neighboring values need not occupy neighboring buckets, so hashing cannot naturally serve range scans or ordered output.

Static hashing can accumulate overflow pages. Extensible hashing splits through a directory; linear hashing grows one bucket at a time. Both improve growth behavior without restoring key order.

In plain terms: hashing knows the locker number. It does not know which lockers contain “everything from 42 through 90.”

Reproduce the difference

CREATE TABLE events AS
SELECT g AS event_no,
       g % 1000 AS topic_id,
       DATE '2020-01-01' + (g % 1825) AS created_at
FROM generate_series(1, 1000000) AS g;

ANALYZE events;
CREATE INDEX events_no_btree ON events USING btree (event_no);

EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT * FROM events WHERE event_no BETWEEN 420000 AND 430000;

Now test hash alone:

DROP INDEX events_no_btree;
CREATE INDEX events_no_hash ON events USING hash (event_no);

EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT * FROM events WHERE event_no = 424242;

EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT * FROM events WHERE event_no BETWEEN 420000 AND 430000;

Hash can support the equality predicate, not the range. The planner may still prefer a sequential scan when a query returns a large fraction of the table.

Read EXPLAIN in this order

Signal Interpretation
Scan node How rows are reached: sequential, index, or bitmap
Index Cond Predicate that narrows index access
Filter Predicate applied after candidates are read
estimated / actual rows Cardinality-estimation quality
Buffers: hit/read Cached versus read pages

EXPLAIN ANALYZE executes the statement. Use caution with writes.

Multicolumn indexes follow query shape

For equality on topic followed by a date range:

CREATE INDEX events_topic_date
ON events (topic_id, created_at);

The first column isolates one topic; the second narrows a range inside it. Column order determines which search space is reduced first.

Review card

  • B+ Tree order supports equality, range, and sorting.
  • Leaf links make range scans sequential after the first lookup.
  • High fanout keeps the tree shallow.
  • Split, redistribution, and merge are write costs.
  • Hash serves equality, not ordered range access.
  • A sequential scan can be correct when selectivity is low.
  • Verify with actual rows and buffers, not one timing number.

Sources

索引的目的不是少做幾次 comparison,而是讓 query 少讀幾個 page。

B+ Tree 保留順序

                         [40 | 80]          root
                    /        |        \
          [10 20 30] -> [40 50 60] -> [80 90 100]
             leaf          leaf           leaf

Internal node 保存 separator key 與 child pointer;排序後的 index entry 位於 leaf,leaf 之間又互相連接。

Equality lookup 往下走到一個 leaf。Range lookup 先找到 lower bound,再沿 leaf link 讀到超過 upper bound。當排序方向吻合時,同一個順序也能服務 ORDER BY

白話來說: Internal page 是導覽,leaf 是書架。Range query 找到第一本書後,只要沿書架往右走。

Fanout 讓樹保持很矮

課堂用 4,096-byte page、4-byte key 與 8-byte pointer 做簡化估算:

(2d × 4) + ((2d + 1) × 8) ≤ 4096
d ≈ 170

若預留更新空間,把平均 fanout 估成 133:

133³ ≈   2,352,637 entries
133⁴ ≈ 312,900,700 entries

這是量級估算,不是特定 engine 的精確公式。重點是一個 page 能在大量 child 之間選路,所以樹高成長很慢。

寫入必須維護樹

Leaf 滿了會 split,並把 separator 傳給 parent。Parent 也可能繼續 split;只有 root split 才會增加樹高。刪除後節點太空時,先向 sibling redistribution,無法借 key 才 merge。

因此每多一個 index,insert、update、delete 都多一份維護成本,也會多佔 cache。Read improvement 必須值得這些 write amplification。

Hash 放棄順序

Hash index 計算 h(key) 後直接前往 bucket。分布平均且沒有長 overflow chain 時,equality lookup 可以很有效率。但相鄰原值不一定在相鄰 bucket,所以 hash 無法自然服務 range 或有序輸出。

Static hashing 可能累積 overflow page;extensible hashing 透過 directory split;linear hashing 一次增加一個 bucket。它們改善成長方式,沒有恢復 key order。

白話來說: Hash 知道置物櫃號碼,卻不知道「42 到 90 的所有物品」分散在哪些相鄰位置。

自己驗證差異

CREATE TABLE events AS
SELECT g AS event_no,
       g % 1000 AS topic_id,
       DATE '2020-01-01' + (g % 1825) AS created_at
FROM generate_series(1, 1000000) AS g;

ANALYZE events;
CREATE INDEX events_no_btree ON events USING btree (event_no);

EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT * FROM events WHERE event_no BETWEEN 420000 AND 430000;

再單獨測 hash:

DROP INDEX events_no_btree;
CREATE INDEX events_no_hash ON events USING hash (event_no);

EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT * FROM events WHERE event_no = 424242;

EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT * FROM events WHERE event_no BETWEEN 420000 AND 430000;

Hash 可以服務 equality,無法服務 range。若查詢要讀大部分 table,planner 仍可能正確選擇 sequential scan。

EXPLAIN 依這個順序讀

Signal 判讀
Scan node Row 是由 sequential、index 或 bitmap 取得
Index Cond 真正縮小 index access 的 predicate
Filter 讀到 candidate 後才套用的 predicate
Estimated / actual rows Cardinality estimation 是否可信
Buffers: hit/read Cache 命中與實際讀 page

EXPLAIN ANALYZE 會執行 statement;對 write 使用時要特別小心。

複合索引跟著 query shape

Topic equality 加日期 range 可使用:

CREATE INDEX events_topic_date
ON events (topic_id, created_at);

第一欄先隔離一個 topic,第二欄再縮小其中的日期範圍。Column order 決定資料庫先縮小哪個搜尋空間。

複習卡

  • B+ Tree 順序可服務 equality、range 與 sorting。
  • Leaf link 讓 range 定位後改成 sequential traversal。
  • Fanout 高,所以樹通常很矮。
  • Split、redistribution、merge 是 write cost。
  • Hash 服務 equality,不服務有序 range。
  • Selectivity 低時 sequential scan 可能是正解。
  • 用 actual rows 與 buffers 驗證,不只看一次時間。

資料來源