目标:你能把"B+ 树适合范围查询"落到数据库实现细节:叶子链表、页(page)组织、页分裂/合并,以及这些细节如何影响索引设计和 SQL 写法。
1. 范围查询的本质:从"定位起点"到"顺序扫描"
一个典型范围查询:
sql
select * from t where k between 100 and 200;
如果索引是 B+ 树,执行可以拆成两步:
- 定位起点 :在树上走
log_m N层,找到第一个满足条件的叶子位置 - 顺序扫描:沿叶子链表/页内顺序读取,直到超过上界
关键收益:
- 起点定位很快(树矮)
- 后续读取是"有序的、局部连续的"(更接近顺序 IO)
2. 叶子链表为什么重要:避免回到父节点反复跳
没有叶子链表时,想做范围遍历需要不断回到父节点找后继,访问路径会在树上来回跳。
有叶子链表后:
- 一旦到叶子层,就可以一直向右扫描
- 数据页往往在缓冲池中更容易命中
对数据库来说,这就是"范围查询性能稳定"的来源。
3. 页(page)视角:为什么 B+ 树节点大小要接近页大小
数据库通常把一个节点(或节点的一部分)组织在页里。
设计目标:
- 读一次页,就拿到足够多的 key 来决定下一跳
- 页内是连续内存,二分/顺序扫描很快
因此 B+ 树的阶(扇出)在实践中会非常大(远大于教材里 3 阶、4 阶)。
4. 页分裂(split):索引写入成本的来源
当你在中间位置插入 key,目标叶子页可能已满:
- 就需要把一个页拆成两个页
- 并把分隔 key 上推到父节点
影响:
- 写放大:一次插入可能改多个页
- 碎片化:页利用率下降
- 锁竞争:高并发写入热点页可能更严重
4.1 为什么递增主键页分裂更少
递增主键插入基本在最右边追加:
- 大概率只动"最后一个叶子页"
- 分裂发生频率更低
随机主键会把写入打散到树中间:
- 更容易触发分裂
- 更难利用缓存局部性
5. 页合并(merge):删除多了也要付成本
大量删除可能让页利用率很低,数据库可能触发:
- 向兄弟借
- 或与兄弟合并
这同样会产生额外写入成本。
因此"频繁插入删除"的表更需要关注:
- 主键策略
- 索引数量(索引越多写入越慢)
6. 索引设计与 SQL 写法:直接决定扫描方式
6.1 避免让索引失去有序性
常见导致索引无法按范围高效扫描的写法:
- 对索引列做函数:
where date(create_time)=... - 隐式类型转换:
where varchar_col = 123 like '%xxx'(前缀%)
这些会让优化器无法利用 B+ 树的有序性,只能走全表或大量回表。
6.2 大 offset 分页:看起来是范围,其实是"丢弃扫描"
sql
select * from t order by id limit 100000, 20;
问题:
- 数据库需要先扫描/跳过 100000 行,再取 20 行
- 即使走索引,也会做大量无效读取
优化:
- 基于"上次最后一条 id"的 seek 分页:
sql
select * from t where id > ? order by id limit 20;
这能把扫描变成真正的"连续范围"。
6.3 覆盖索引 + 回表控制
范围查询返回很多行时,回表会很贵。
策略:
- 尽量只查必要列
- 设计覆盖索引减少回表
- 或先查主键集合再分批回表(注意仍可能随机 IO)
7. 常见面试题:为什么 MySQL InnoDB 用 B+ 树不用 B 树
可以从三点答:
- B+ 树内部节点更轻,扇出更大,高度更低
- 叶子链表让范围查询/排序更高效
- 查询路径更稳定,缓存/预取更友好
8. 线上诊断:如何判断"范围查询没走成顺扫"
你要看的证据:
- 执行计划:是否使用索引、是否出现 filesort
- 扫描行数:
rows是否远大于返回行数 - 慢日志:范围条件是否被函数/类型转换破坏
实践里最常见的是"明明建了索引,但 where 写法让索引失效"。
9. 面试背诵稿(50 秒)
B+ 树的范围查询快是因为它可以先用树高很低的查找定位到范围起点,然后在叶子层通过链表按顺序扫描,这种访问模式更接近顺序 IO,性能稳定。
在数据库实现里一个节点通常对应一个页,页大小固定,所以写入时如果叶子页满会触发页分裂并把分隔 key 上推,产生写放大;因此递增或趋势递增主键能减少中间插入导致的分裂。
SQL 优化上要避免对索引列做函数或隐式类型转换破坏有序性;大 offset 分页会导致大量无效扫描,应该用基于 lastId 的 seek 分页;大范围返回时要用覆盖索引减少回表。