B+Tree索引并不是MySQL独有的概念,它是数据库和文件系统中常见的一种多路平衡树结构。MySQL InnoDB引擎把表数据和索引都组织在B+Tree中,主键索引的叶子节点保存的是整行数据,二级索引的叶子节点保存的是主键值。要理解为什么一条SQL能走索引、为什么有时索引会失效,首先要弄清楚B+Tree的节点结构和查找路径。

B+Tree 的核心结构:为什么叶子要串成链表
B+Tree是一种多路平衡搜索树,和普通的二叉搜索树不同,它的每个节点可以容纳多个键值和多个子节点指针。非叶子节点只负责路由,不保存完整数据;所有真实的数据记录都落在叶子节点上,并且叶子节点之间通过双向链表连接。这个设计有两个直接好处:一是非叶子节点可以做得更小,单次磁盘页就能读取更多键值,树的高度随之降低;二是范围查询时,只要在叶子层定位到起始键,就可以顺着链表依次向后扫描,而不需要沿着树返回到上层节点。
一个B+Tree节点通常包含多个键值和指向下一层节点的指针,叶子节点还会额外保存指向相邻叶子节点的指针。下面是一个简化的节点结构示意,实际InnoDB的页结构要复杂得多,但核心字段基本相同。
typedef struct bplus_node {
int is_leaf;
int key_num;
int keys[MAX_KEY];
void *children[MAX_KEY + 1];
struct bplus_node *next;
} bplus_node;
假设InnoDB页大小为16KB,非叶子节点中一个键值占8字节,一个指针占6字节,扣除页头等开销后,一个非叶子节点大约能放1170个键值和指针。三层B+Tree的理论容量可以这样粗算:根节点有1170个指针指向中间层节点,每个中间层节点又有1170个指针指向叶子节点,叶子节点总数约为1170乘1170,接近137万个;如果每个叶子节点保存16行记录,那么三层B+Tree就能覆盖约两千万行数据。这意味着一次主键点查最多只需要两到三次磁盘IO,效率远高于二叉搜索树。
InnoDB 中的 B+Tree 索引:聚簇与二级索引
在InnoDB存储引擎里,表本身就是按照主键组织成的一棵B+Tree,这棵树的叶子节点保存的是完整的行记录,所以主键索引也被称为聚簇索引。如果建表时没有显式指定主键,InnoDB会先尝试使用第一个非空唯一索引作为聚簇索引;如果连这样的索引都没有,它会生成一个隐藏的6字节rowid。因此,主键不宜使用过长的字符串,因为主键值会被二级索引引用,主键太大意味着所有二级索引都会膨胀。
二级索引和聚簇索引有很大区别。二级索引的叶子节点不再保存完整行数据,而是保存索引列的值和对应的主键值。当查询通过二级索引找到记录后,还需要根据主键值回到聚簇索引中查找完整数据,这个过程就是常说的回表。下面这个建表语句中,id上会自动创建聚簇索引,而idx_name_age就是一个联合二级索引。
CREATE TABLE user ( id INT PRIMARY KEY, name VARCHAR(50), age INT, KEY idx_name_age (name, age) ) ENGINE=InnoDB;
联合索引的排序遵循最左前缀原则。对于idx_name_age来说,数据先按照name排序,name相同的记录再按照age排序。因此,只查询name可以使用该索引,同时查询name和age也可以使用该索引,但只查询age就无法高效利用这个索引,因为age在整个索引中并不是全局有序的。范围查询同样会截断后续列的使用,比如name = '张三' AND age > 20能走索引定位到张三的起始位置,但age部分只能做过滤,无法继续利用索引的有序性。
从查询计划看 B+Tree 的查找过程
一次使用B+Tree索引的点查,通常从根节点开始,在节点内部通过二分查找或页目录定位目标键值所在的分支,然后逐层向下,直到叶子节点。对于主键等值查询,InnoDB可以直接在聚簇索引的叶子节点拿到整行数据。对于二级索引等值查询,则需要先定位二级索引叶子节点拿到主键,再回到聚簇索引查找,这个过程在查询计划中可能出现Using index condition或Using where等提示。
范围查询的路径稍有不同。例如WHERE name = '张三' AND age > 20会先通过name等值条件定位到叶子节点中第一个符合条件的记录,然后利用叶子节点之间的双向链表向右扫描,直到name不再等于张三或者扫描完整个范围。如果满足条件的记录很多,回表操作就会非常频繁,优化器可能因此放弃索引而选择全表扫描。可以通过EXPLAIN观察执行计划。
EXPLAIN SELECT * FROM user WHERE name = '张三' AND age > 20;
如果查询只需要name和age两列,而这两列又都在idx_name_age索引里,那么二级索引的叶子节点已经包含了查询需要的全部数据,不需要再回表。这种情况在查询计划中会显示为Using index,也就是覆盖索引。覆盖索引能明显减少磁盘IO,尤其是在大表分页或高频查询场景下,合理设计联合索引的列顺序可以显著提升性能。
使用 B+Tree 索引时容易踩的坑
索引列一旦被函数或表达式包裹,B+Tree就无法利用键值的有序性进行定位。例如WHERE YEAR(create_time) = 2023,即使create_time上有索引,也大概率不会走索引,因为YEAR(create_time)的结果无法直接和索引键值比较。更合理的写法是改成范围条件,如create_time >= '2023-01-01' AND create_time < '2024-01-01'。类似的隐式类型转换也要注意,字符串列传入数字参数可能会触发类型转换,导致索引部分失效。
联合索引的顺序也是一个常见问题。比如查询条件只有age,但建立的是idx_name_age,那么这个索引基本帮不上忙。还有前导模糊查询name LIKE '%张三',因为键值比较时无法确定起始范围,通常只能扫描整棵索引。负向条件如NOT IN、!=也可能让优化器选择全表扫描。区分度低的列即使建了索引,优化器也可能认为回表代价过大而放弃使用。
理解B+Tree索引归根结底是要理解它的排序和存储方式。索引不是建得越多越好,每个索引都会占用额外空间,并在插入、更新、删除时带来维护成本。设计索引时优先考虑区分度高、经常出现在查询条件中的列,并尽量让查询走覆盖索引,才能发挥B+Tree在磁盘IO和范围查询上的优势。