B+ Tree Indexes: Internals and Optimization

Why B+ trees, page splits and fill factor, clustered vs secondary, composite index ordering, covering indexes, and index-only scans.

IntermediateUpdated 2026-10-2961K views

Why a B+ Tree

Databases read data in fixed-size pages (InnoDB: 16KB). A B+ tree keeps every path from root to leaf the same height, so a point query touches exactly height pages — a 3-level tree indexes tens of millions of rows with 3 disk-range reads. Only leaf nodes hold data, and leaves form a linked list for fast range scans.

               [根节点]
              /    |    \
        [内部节点 ...]            ← 只存索引键 + 子页指针
         /    |    \
    [叶子] → [叶子] → [叶子] →   ← 存数据(聚簇)或主键值(二级)

树高估算:每页约存 1170 个键(16KB / ~14B)
  3 层 = 1170 × 1170 × 16 ≈ 2 千万行
  4 层即可覆盖数十亿行(多一层 IO)

Clustered vs Secondary Indexes

-- 聚簇索引(主键):叶子存整行数据,一张表只有一个
-- 二级索引:叶子存主键值,查到主键后要回表再取整行

CREATE TABLE t (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,    -- 聚簇
    email VARCHAR(128),
    user_id BIGINT,
    UNIQUE KEY uk_email (email),             -- 二级(唯一)
    KEY idx_uid (user_id)                    -- 二级
);

SELECT * FROM t WHERE email = 'a@b.com';
-- 1. uk_email 找到主键 id(一次 B+ 树查找)
-- 2. 回表(第二次 B+ 树查找)取整行

-- 为什么主键要用自增整型:
--  顺序写入不触发随机页分裂;整型比较与二级索引体积都小
-- 用 UUID 做主键?二级索引体积膨胀 + 随机插入频繁分裂

Composite Index Ordering

-- 原则:等值列在前,范围列在后
KEY idx (status, created_at, user_id)

SELECT * FROM orders WHERE status = 1
  AND created_at >= '2026-10-01' AND created_at < '2026-11-01';
-- status 等值定界 + created_at 范围扫描 ✓

SELECT * FROM orders WHERE created_at > '2026-10-01';
-- ✗ 跳过 status,违反最左前缀

-- 排序也要用上索引:ORDER BY 与索引顺序一致则免 filesort
SELECT * FROM orders WHERE status = 1
ORDER BY created_at DESC LIMIT 10;
-- 命中 (status, created_at),无需额外排序

-- 选择性差的列放前面会怎样?
-- WHERE sex = 'M' AND age > 30:sex 只有 2 值,几乎不裁剪行
-- 应把选择性高/能定界的列放前面

-- 前缀索引:长字符串场景
ALTER TABLE t ADD KEY idx_name (name(20));   -- 前 20 字符
-- 注意:前缀索引无法做覆盖索引与 ORDER BY 优化

Page Splits and Insert Patterns

  • Page split: inserting a row into a full page copies half to a new page — random keys (UUID) make splits and fragmentation constant.
  • Auto-increment keeps inserts sequential: splits only at the rightmost edge, pages stay dense.
  • Optimize table rebuilds the index to defragment — a long, table-copying operation; schedule in low traffic.
  • Batch insert in chunks (a few thousand rows) to amortize log and page costs over big imports.

Covering Indexes and Index-Only Scans

-- 回表的成本:每行多一次 B+ 树查找
-- 覆盖:把 SELECT 用到的列全部塞进索引

-- 反面:SELECT * 强制回表
SELECT * FROM orders WHERE user_id = 7;

-- 正面:只取索引里的列
SELECT user_id, status FROM orders WHERE user_id = 7;
--   KEY idx (user_id, status) → Extra: Using index,不回表

-- 典型优化:分页深翻页
SELECT * FROM orders ORDER BY id LIMIT 1000000, 10;
-- 扫 100 万行才返回 10 条 → 改成两段
SELECT o.* FROM orders o
INNER JOIN (
    SELECT id FROM orders ORDER BY id LIMIT 1000000, 10
) x ON x.id = o.id;
-- 子查询在主键索引上纯索引扫描,命中 10 行再回表

-- 索引扫描代替全表:count(*) 的替代方案
EXPLAIN SELECT COUNT(*) FROM orders WHERE status = 1;
-- 若 (status) 有索引,Extra 为 Using index,扫描数据量骤降
Check EXPLAIN Extra for 'Using index' as the goal: it means the query never touches the table rows. Every hot query should aim for it.

Friends & Partners