44.8 索引原理 (B+ 树 / 哈希 / 覆盖索引 / 最左前缀)

一句话

索引是用写放大和空间换更少的页读取:B+ 树赢了范围查询,哈希赢了等值点查,覆盖索引赢在“不回表”——选型不是比谁先进,而是算清“这类查询要读几页”。

四种结构,四种胜负

结构 等值 范围 排序 写放大 典型场景
B+ 树 O(log n) ✅ 叶子有序、区间扫描 ✅ 天然有序 页分裂 绝大多数 OLTP 索引
哈希 O(1) ❌ 完全无序 ❌ 低 内存表、等值查找
倒排(全文) 按词 ❌ ❌ 高 搜索(45.1 倒排索引与分词)
位图 按集合 ⚠️ 低基数 ❌ 中 数仓低基数维度

🔑 B+ 树的本质是**“让叶子节点按 key 有序,并且用兄弟指针串起来”**:所以 BETWEEN、ORDER BY、LIMIT 都能在索引上顺序扫,不用回表排序。哈希把 key 打散,天然丢失顺序,所以只能等值。

聚簇索引与二级索引:回表的代价

索引类型 叶子存什么 查一行要几次 I/O
聚簇索引(主键) 整行数据 树高次(3–4 页),拿到就是整行
二级索引 索引列 + 主键值 树高次 + 回表(按主键再查一次聚簇索引)
覆盖索引 查询需要的全部列 树高次,不回表
package main

import "fmt"

const (
	rows         = 1_000_000 // 表行数
	rowsPerPage  = 100       // 每页 100 行
	treeHeight   = 3         // 扇出约 500 时,1M 行约 3 层
	randReadMs   = 0.10      // 随机读一页
	seqReadMs    = 0.01      // 顺序读一页
)

func pages(n int) int {
	p := n / rowsPerPage
	if n%rowsPerPage != 0 {
		p++
	}
	return p
}

func main() {
	tablePages := pages(rows) // 全表扫描要读的页数

	type result struct {
		name  string
		desc  string
		pages int
		rand  int // 其中随机读的页数
	}
	cases := []result{
		{"主键点查", "B+ 树 3 层", treeHeight, treeHeight},
		{"二级索引查 1,000 行(非覆盖)", "3 页索引 + 1,000 次回表", treeHeight + 10 + 1000, treeHeight + 1000},
		{"二级索引查 1,000 行(覆盖索引)", "3 页索引 + 10 页叶子", treeHeight + 10, treeHeight + 10},
		{"索引下推(索引层过滤)", "扫 1,000 条,仅 120 条回表", treeHeight + 10 + 120, treeHeight + 120},
		{"哈希等值点查", "1 页定位", 1, 1},
		{"哈希做范围查询", "不支持,退化为全表扫描", tablePages, 0},
		{"B+ 树范围查 1,000 行", "3 页索引 + 10 页顺序叶子", treeHeight + 10, treeHeight},
	}

	fmt.Printf("表 %d 行、每页 %d 行、全表 %d 页;随机读 %.2fms、顺序读 %.2fms\n\n",
		rows, rowsPerPage, tablePages, randReadMs, seqReadMs)
	fmt.Printf("%-32s %-28s %-8s %-10s %s\n", "场景", "做什么", "页数", "估算耗时", "备注")

	for _, c := range cases {
		seq := c.pages - c.rand
		ms := float64(c.rand)*randReadMs + float64(seq)*seqReadMs
		note := ""
		if c.name == "二级索引查 1,000 行(非覆盖)" {
			note = "回表是主要成本"
		}
		if c.name == "二级索引查 1,000 行(覆盖索引)" {
			note = "省掉 1,000 次随机读"
		}
		if c.name == "索引下推(索引层过滤)" {
			note = "回表从 1,000 降到 120"
		}
		fmt.Printf("%-32s %-28s %-8d %-10s %s\n", c.name, c.desc, c.pages,
			fmt.Sprintf("%.1fms", ms), note)
	}
}

本机实测(macOS,Apple M1,Go 1.27):

表 1000000 行、每页 100 行、全表 10000 页;随机读 0.10ms、顺序读 0.01ms

场景                               做什么                          页数       估算耗时       备注
主键点查                             B+ 树 3 层                     3        0.3ms      
二级索引查 1,000 行(非覆盖)               3 页索引 + 1,000 次回表            1013     100.4ms    回表是主要成本
二级索引查 1,000 行(覆盖索引)              3 页索引 + 10 页叶子               13       1.3ms      省掉 1,000 次随机读
索引下推(索引层过滤)                      扫 1,000 条,仅 120 条回表          133      12.4ms     回表从 1,000 降到 120
哈希等值点查                           1 页定位                        1        0.1ms      
哈希做范围查询                          不支持,退化为全表扫描                  10000    100.0ms    
B+ 树范围查 1,000 行                  3 页索引 + 10 页顺序叶子             13       0.4ms      

读数:

对比 差距 结论
非覆盖 vs 覆盖索引 1,013 页 vs 13 页(78 倍) 查的列都在索引里,就不要回表
索引下推前后 回表 1,000 → 120(8 倍) 把过滤条件下推到索引层,能少 88% 的回表
哈希等值 vs B+ 树等值 1 页 vs 3 页 哈希点查只读 1 页,但代价是丢掉范围与排序
哈希做范围查询 10,000 页(全表) 不支持就是全表,索引等于没用

🔑 所以“加索引”之前先问:这条查询的列,能不能被一个索引完全覆盖? 能 → 覆盖索引(最优);不能 → 至少把过滤条件下推到索引(联合索引把过滤列放在前面);范围查询与排序 → 只能 B+ 树。

最左前缀:不是规则,是物理事实

联合索引 (a, b, c) 在 B+ 树里是按 (a, b, c) 的字典序排列的:

(a=1,b=1,c=1) → (1,1,2) → (1,1,3) → (1,2,1) → (1,2,2) → (2,1,1) → ...

所以:

查询条件 能否用索引 为什么
a=1 ✅ 按 a 有序
a=1 AND b=2 ✅ 前缀连续
a=1 AND b=2 AND c=3 ✅ 完整前缀
b=2 ❌ b 在 a 相同的前提下才有序,跨 a 无序
a=1 AND c=3 ⚠️ 只用 a,c 靠回表过滤 c 跳过了 b
a>1 AND b=2 ⚠️ 只用 a 的范围 范围之后 b 不再有序

⚠️ 最常见的误解:“把选择性最高的列放最左”。真正的最左规则是**“等值条件放前面、范围条件放最后”**——因为范围一旦出现,它右边的列在索引里就不再有序,无法继续用于定位。

反模式

反模式 后果
给每个列都建单列索引 写放大、优化器选择困难,回表依旧
联合索引顺序按“选择性”而不是“查询形态”排 范围列放前面,后面的列全废
用 SELECT * 导致覆盖索引失效 每次查询都回表
在索引列上做函数/隐式转换(WHERE DATE(ts)=...) 索引失效,全表扫描
用哈希索引做范围/排序 退化为全表
索引列太多(超宽联合索引) 索引本身比数据大,写放大严重

自检

Q1 为什么“二级索引查 1000 行”比“覆盖索引查 1000 行”慢几十倍,而“点查一行”两者差不多?

▸ 答案:因为回表是按行数线性增长的:每回表一次就是一次随机 I/O(查聚簇索引的另一棵 B+ 树)。点查时回表只有 1 次,占比小;1000 行时回表 1000 次,直接主导成本。覆盖索引把查询需要的列都放在二级索引叶子里,回表次数为 0,所以行数越多差距越大。

Q2 WHERE b=2 用不上 (a,b,c) 联合索引,加一个 (b) 单列索引就好了吗?

▸ 答案:取决于查询形态。如果这条查询很频繁且只需要 b,加 (b) 或 (b, 查询需要的列) 覆盖索引是合理的;但如果它只是偶尔出现,多一个索引会带来写放大与空间成本(每个索引都要维护 B+ 树)。更常见的做法是调整查询或联合索引顺序,让高频查询走同一个索引。索引是写路径的成本,不是免费的读优化。

Q3 索引下推(ICP)到底省了什么?为什么它不能省掉“回表”本身?

▸ 答案:ICP 把“能在索引列上判断的条件”下推到存储引擎层,在回表之前先过滤掉不满足的行,从而减少回表次数(本节 1000 → 120)。但它仍然要回表取“索引里没有的列”,所以只能减少、不能消除回表。要消除回表,只能让索引覆盖查询需要的全部列。

Q4 为什么在索引列上做函数运算(如 WHERE date(created_at)='2026-01-01')会让索引用不上?

▸ 答案:因为 B+ 树索引存的是列的原值,不是 “函数结果”。对列做函数后,优化器无法用原值的有序性定位,只能逐行计算再比较(全表扫描)。解法是 “改写成范围查询”(created_at >= '2026-01-01' AND < '2026-01-02')或建 “函数索引/生成列索引”。

Q5 一张表建了 10 个索引,写入会付出什么代价?怎么判断 “索引太多”?

▸ 答案:每个索引都是一棵 B+ 树:每次写入要更新所有索引,写放大与锁开销随索引数线性增长,还会占缓冲池。判据是 “索引的使用次数”——把长期零命中(或只被低效查询用到)的索引删掉;通常单表 5–6 个索引以上就要逐个 review。

关联