1. 从一次慢查询说起:为什么加了索引还是慢
先抛一个我最近排查的真实问题。有个订单表,数据量到了两千多万行,业务反馈某个列表页打开要四五秒。开发同学说索引都加了,我一看执行计划,明明走了索引,可还是慢。再往深挖,发现查询条件里用了DATE_FORMAT(create_time, '%Y-%m-%d') = '2024-06-01',左侧对索引列做了函数操作,索引确实被用上了,但全扫了当天所有记录再逐行计算,等于索引白建。
这个场景挺典型,它牵扯出的问题链其实很长:MySQL索引底层到底是什么结构、为什么B+树能扛住千万级数据、联合索引在什么情况下会失效、索引下推到底优化了什么。很多人背过八股文,知道"索引是B+树",但面试官一问页分裂、回表、覆盖索引,就开始含糊。这篇文章我不打算复述官方文档,而是结合我自己的调优经验,把InnoDB索引从物理存储到算法设计整个链路拆开讲一遍。适合刚接触索引、准备面试、或者被线上慢查询折磨过的开发者。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 索引为什么长成B+树的样子:从数据结构的根源说起
2.1 平衡二叉树和B树的对比
先说个基础认知。MySQL索引默认存储引擎是InnoDB,底层用的是B+树。为什么不直接用平衡二叉树(AVL树)或者红黑树?这得从磁盘IO的特性说起。
机械硬盘随机读一次大概要10毫秒,就算是SSD,随机读也要几十微秒。而内存访问是纳秒级,差了至少三个数量级。数据库数据是落在磁盘上的,操作系统的页缓存、InnoDB自己的缓冲池,都是尽量减少磁盘IO的手段。索引结构每增加一层树高,就可能带来一次额外的磁盘IO。
平衡二叉树的问题是节点只存一个键值,树的高度太高。两千万数据,AVL树的高度大约是log2(20000000),约25层。极端情况下查一条数据需要25次磁盘IO,这是不可接受的。B树一个节点能存多个键值,树高变矮,但是B树有个问题:每个节点既存索引键也存数据,范围查询时需要中序遍历多个节点,而且非叶子节点占了太多空间,导致每个节点能存的孩子数量受限。
2.2 为什么InnoDB最终选了B+树
B+树解决了这几个痛点。它的非叶子节点只存索引键值,不存数据,这样每个节点能容纳更多键值,树更矮更胖。InnoDB一页默认16KB,假设主键是BIGINT(8字节)+指针(6字节),一个节点能存约16384/14≈1170个键值。三层B+树能存多少数据?1170 * 1170 * 16,大约是两千万行。什么意思?两千万数据的表,从根节点到叶子节点最多三层,查找一条数据最多三次磁盘IO,而且根节点常年驻留在内存里,实际只需要两次IO。
另一个关键原因是叶子节点之间用双向链表串联。InnoDB的B+树叶子节点是按主键顺序排列的,相邻节点之间有指针。这意味着范围查询(比如WHERE id BETWEEN 100 AND 200)、排序(ORDER BY id)、分组(GROUP BY)都能通过顺序扫描叶子节点完成,磁盘预读特性也让这类扫描非常高效。
数据库的聚簇索引、二级索引都基于B+树构建,但叶子节点存储的内容不同,这块我放在后面专门讲。先说结论:B+树是InnoDB在"磁盘IO代价高、内存有限、查询模式多样"这三个约束下做出的最优解。
2.3 索引的物理载体:InnoDB数据页结构
要说清楚索引,得先说存储结构。数据页是InnoDB磁盘管理的最小单位,默认16KB。页的内部结构包括文件头、页头、Infimum和Supremum(虚拟的最小/最大记录)、用户记录、页目录、文件尾。
用户记录在页内是物理连续的吗?不是。记录之间通过单向链表连接。新插入的记录会放到页的偏移量上,逻辑顺序靠next_record指针维持。页目录(Page Directory)把页内记录分成若干组,每组最后一条记录的偏移量记录在目录槽里,查找时先二分定位槽,再在组内顺序遍历。这就是为什么即使页内有上千条记录,单页内的查找依然很快。
插一条记录时,如果页满了,会发生页分裂,把部分记录移到新页。页分裂的位置通常不是50%对半分,InnoDB会尽量保证插入的稳定性,但无论如何,频繁随机插入会导致页分裂和碎片化,这是后文讲主键设计时要重点展开的内容。
3. 聚簇索引与二级索引:一张表到底有几棵树
3.1 聚簇索引:表数据本身就是一棵B+树
InnoDB中,每张表都有一个聚簇索引(Clustered Index),它决定了表数据的物理存储顺序。注意一个关键点:InnoDB表数据就是按主键B+树组织的,叶子节点直接存储整行数据。
聚簇索引的构建规则是这样的:如果表定义了主键,主键索引就是聚簇索引;如果没有主键,InnoDB会找第一个非空唯一索引作为聚簇索引的候选键;如果这也没有,InnoDB会隐式生成一个6字节的ROWID作为聚簇索引键。
这里引出一个非常重要的推论:InnoDB表不建议使用UUID或业务随机字符串作为主键。原因有两个层面。第一是空间浪费,一个二级索引的叶子节点会存储主键值,随机字符串主键导致每个二级索引都变大。第二是性能问题,UUID是随机的,插入时新记录的主键值可能落在B+树中间,触发频繁的页分裂和页重排,产生碎片。
线上曾遇到过一个以UUID为主键的日志表,写入性能持续恶化。后来加了自增ID作为主键,UUID改为普通索引列,写入TPS直接从不到2000提升到7000左右。这不是玄学,是B+树顺序插入和随机插入的本质差异。自增主键永远只追加到树的右叶子节点,最少触发页分裂。
3.2 二级索引:叶子节点存的是主键,不是行数据
二级索引(Secondary Index),也叫辅助索引或普通索引,是开发者手动创建的索引。它的B+树结构和聚簇索引一样,但叶子节点不存整行数据,只存索引列值 + 主键值。
为什么只存主键?因为这样设计可以保持二级索引的"瘦身",一页能容纳更多索引项,树更矮,查询更快;同时避免了数据冗余,行数据只存在于聚簇索引里,更新数据不需要同步更新所有索引。
但这引出一个问题:通过二级索引查数据,得先查二级索引B+树拿到主键,再拿着主键去聚簇索引里查完整行,这个过程叫回表。回表意味着额外的IO。举例来说,表结构user(id, name, age),执行SELECT * FROM user WHERE name = '张三',name上有索引的话,流程是:
- 在name索引树上找到"张三",拿到主键id。
- 拿着id再去主键索引树里查完整行数据。
如果命中了100条记录,假设每条
