在写这篇长文之前,先说一下为什么会想到整理这个题目:这些年不管是在技术群、面试现场,还是后台留言里,MySQL索引相关问题几乎被反复问烂了——主键索引和唯一索引到底差在哪?为什么联合索引要遵守最左前缀?明明建了索引,SQL却还是慢得像爬?二级索引更新时锁的顺序为什么会造成死锁?索引表空间膨胀了怎么办?与其每次都零散地答一遍,不如把这些东西按底层逻辑串成一条线,写成一篇可以通读、也可以按目录跳读的全景长文。
这篇内容会从一次真实慢查询开始,一路讲到B+树的设计取舍、聚簇索引与二级索引的协作方式、索引失效的底层原因、锁与索引的关系,以及索引表空间的运维思路。适合刚接触MySQL索引、想搞懂原理的初学者,也适合已经写过不少SQL、但总感觉哪里隔着一层纱的开发者。
1. 先从一次慢查询讲起:索引到底在加速哪一步
1.1 一条没走索引的SQL,问题出在哪
以前排查过一个线上订单表,表里有接近两千万行,查询条件就这么简单:
SELECT order_id, user_id, status, amount FROM orders WHERE status = 0 ORDER BY create_time DESC LIMIT 20;当时这个接口的响应时间已经到了三秒多,而且并发一上来就超时。为什么这么慢?因为status = 0这个条件在表里命中的行数有八百多万。没有索引的情况下,InnoDB只能把整张表的聚簇索引叶子节点全部扫一遍,一条一条判断status = 0,再把所有符合条件的数据捞出来做排序,最后取20条返回。
说白了,慢就慢在它把大量与结果无关的行也拖进了整个流程。你要20条,它却把八百多万候选行翻了个底朝天。这就是“全表扫描”的真实代价——不是一行一行慢,而是根本没用到能够缩小范围的数据结构。
1.2 加了索引之后,MySQL的执行路径变成了什么
给status建一个普通二级索引,再看这条SQL的执行轨迹:
ALTER TABLE orders ADD INDEX idx_status (status);MySQL会按照status的值构建一棵B+树,每个值对应的叶子节点上存着主键ID。查询status = 0时,走二级索引直接定位到所有值为0的记录,再根据主键ID去聚簇索引里取完整行。虽然status = 0的数据量本身还是很多,但至少它不再需要把整张表从头到尾摸一遍。
这里也顺带回答了一个很容易被误解的问题:索引不是让“符合条件的数据变少”,而是让“找到这些数据的过程变快”。B+树把线性扫描变成了树形查找,范围从整表缩小到从根节点到某个叶子节点的路径。
1.3 索引为什么能快:磁盘IO和树的高度
数据库的数据最终在磁盘上,磁盘随机读一次的开销大约是毫秒级,而内存访问是纳秒级,中间差了好几个数量级。机械硬盘尤其明显,寻道加旋转延迟,一次随机IO基本就是一次“地震”。
B+树的每个节点对应一个数据页,默认16KB。一个三层高的B+树,就能存下千万甚至上亿级别的数据。换句话说,从根节点走到叶子节点,最多只需要几次磁盘IO,就能定位到目标记录所在的页。相比之下,全表扫描相当于从头到尾把所有页都读一遍,IO次数直接跟表的大小成正比。
所以索引的本质,就是拿“额外的磁盘空间”和“写入时维护树的代价”,来换“查询时大幅减少的磁盘IO”。搞懂这一层,后面所有关于索引失效、锁冲突、碎片整理的分析都有了出发点。
2. B+树、页和双向链表:InnoDB用空间换时间的底层逻辑
2.1 为什么不是哈希索引,也不是二叉树
问到索引底层结构时,很多人第一反应是“哈希表不是更快吗”。确实,单条等值查询哈希索引的复杂度是O(1),但它有个致命伤:哈希表天然不支持范围查询,也不能排序。WHERE status >= 0 AND status <= 5这种SQL走到哈希索引上,只能把所有bucket都拉出来重新筛选。
二叉树的问题是数据量大了之后高度不可控。如果插入的数据接近有序,二叉树会退化成一个链表,树的高度直接等于数据行数,查找复杂度变成O(n)。红黑树虽然能保持一定平衡,但每个节点只能存一个键值,层数依然很深,磁盘IO次数还是会很多。
B树做了改进,每个节点可以存多个键值,降低了树的高度。但B树每个节点都带数据,非叶子节点里也存着整行记录,导致同样的16KB数据页能容纳的键数量变少,树的高度反而压不下去,而且范围查询时需要来回回溯到父节点。
2.2 B+树到底做了哪三件事
InnoDB的B+树跟经典B树的区别,可以归结成三点:
- 非叶子节点只存索引键,不存数据。一个16KB页里能堆大量键值,非叶子节点的扇出特别大,树的高度被压得很低。两千万行数据的表,聚簇索引通常也就三层。
- 所有数据都放在叶子节点,并且叶子节点之间用双向链表串起来。这就让范围查询变得非常顺滑:先找到起点,然后顺着链表往后拉就行,不需要回溯到上一层。
- 叶子节点内部本身也是有序的。同一个数据页里的记录按主键顺序排列,页与页之间通过链表维持整体有序性。
这一点在面试里特别常考:为什么范围查询效率高?因为叶子节点是个双向链表,走完一个页自然过渡到下一个页,IO基本是顺序读。热搜词里出现的“双向索引”,其实就是这个叶子节点链表的体现。
2.3 数据页和页分裂:写入侧的代价来源
索引不是免费的,每次插入、删除都在维护B+树。如果往一个已经写满的页里插入新记录,而根据主键顺序它必须待在这个页里,InnoDB就会把页拆成两个,把一部分数据挪到新页里——这就是页分裂。
页分裂不仅意味着写入时要多做IO,还会在索引里留下碎片。后面章节讲到索引表空间时会再展开,这里先记住一个点:乱序插入主键(比如UUID)会导致频繁页分裂,插入性能远低于自增主键的顺序写入。这就是为什么常见最佳实践里,要让主键尽量自增、尽量短。
3. 聚簇索引与二级索引:回表、覆盖索引、主键那些事
3.1 聚簇索引:表本身就是一棵大B+树
InnoDB里每张表都有一个聚簇索引,通常是主键。如果没有主键,MySQL会找第一个非空唯一索引作为聚簇索引;如果再没有,就隐藏生成一个GEN_CLUST_INDEX。聚簇索引的叶子节点存的就是整行的全部数据。
这意味着:
- 通过主键查找数据是最快的路径,直接走聚簇索引,一次就能拿到整行,不需要二次查询。
- 表数据物理上是按主键顺序组织的,所以主键连续的行大概率落在同一个或相邻的数据页里。
- 二级索引(普通索引)的叶子节点不存整行记录,只存“索引列 + 主键值”。
这也是“主键索引和唯一索引的区别”这个高频问题的基础:主键索引就是聚簇索引本身,它决定了数据的物理布局;唯一索引只是一个约束,保证列值不重复,它仍然属于二级索引,叶子节点存主键。
3.2 回表到底是什么,以及怎么避免
通过二级索引查数据时,如果二级索引的叶子节点里没有你要的全部列,InnoDB就得拿着主键ID再回聚簇索引查一遍完整行,这个过程叫回表。一次回表就是一次额外的随机IO,如果二级索引命中了上千行,回表就得上千次,代价相当可观。
要避免回表,最直接的方法是覆盖索引——让SELECT需要的所有列都包含在同一个二级索引里。比如:
SELECT order_id, status FROM orders WHERE status = 0;如果索引是idx_status (status, order_id),那么查询要的status和order_id都在二级索引的叶子节点里,执行计划里会出现Using index,不需要再回表。这就是为什么“不要随便用SELECT *”不仅是规范问题,更是性能问题——星号几乎不可能被一个二级索引完全覆盖。
3.3 联合索引到底怎么设计字段顺序
联合索引的字段顺序,本质上是在回答“我按什么维度组织这棵B+树”。索引(user_id, status)意味着先按用户ID排序,同一用户ID内部再按状态排序。那么WHERE user_id = 123 AND status = 0就能高效定位;但反过来WHERE status = 0,就只能在索引里顺序扫描所有状态为0的记录。
所以设计联合索引时,一个实用的顺序参考是:
- 先放等值查询的字段,因为等值条件能精确定位;
- 再放排序字段,让索引天然提供排序结果,避免filesort;
- 最后放范围查询的字段,让它作为范围过滤条件。
当然,这跟数据分布也有关系,不能一概而论。比如性别这种区分度极低的字段,放前面往往会让优化器觉得“走索引还不如全表扫”。
3.4 主键设计:自增与UUID的差距
关于聚簇索引的争论里,最经典的就是主键到底选自增还是UUID。从B+树的角度看,自增主键是顺序写入,新记录永远追加在当前最大主键附近,不会频繁触发页分裂;UUID主键是随机写入,新记录可能落在任意位置,大概率触发页分裂和随机IO。
有人会抬杠说“生产环境UUID也有道理,因为这样可以避免暴露业务量”。这没问题,但代价就是写入性能下降,并且碎片率上升。折中方案有雪花ID这类趋势递增的分布式ID,既保证了全局唯一,也保留了顺序写入的特性。这里面的取舍,一定要从聚簇索引的物理特性出发去理解,而不是背一个“必须自增”的结论。
4. 最左前缀和那些“据说索引会失效”的翻车现场
4.1 最左前缀不是规则,而是B+树的结构决定的
网上关于联合索引最常看到一句话:“查询必须从最左列开始,否则索引失效。”这句话其实只说对了一半。更准确的说法是,联合索引(a, b, c)是一棵先按a排、再按b排、再按c排的树。只有用了a,b才能利用有序性;只有用了a和b,c才能利用有序性。
拿人的通讯录做类比:你先按姓氏拼音排,再按名字排。如果只报名字“小明”让我找,我无从下手,因为我手里的目录是按姓组织的;但如果报出姓氏“张”,我就能快速翻到张姓区域,再在小范围里找小明。
所以不是“规则让你必须从最左列开始”,而是索引的数据结构本身就要求你从最左列开始才能发挥树查找能力。优化器确实允许你跳过某些列,但那是全索引扫描或索引条件下推(ICP)在兜底,效率跟最左前缀精确定位完全不是一个级别。
4.2 六个高频失效场景的根因分析
下面这些场景被问过太多次,我把它们按“为什么会失效”分成几类:
- 对索引列使用函数或运算:
WHERE DATE(create_time) = '2025-01-01'。索引里存的是原始值,不是函数计算结果,优化器没法在B+树上直接比较函数值。 - 隐式类型转换:索引列是字符串,查询条件写成
WHERE phone = 13800138000。MySQL会把字符串转成数字去比较,导致索引列本身被“处理”了。 - 前模糊匹配:
WHERE name LIKE '%张'。B+树是按前缀排序的,以“%”开头的条件无法确定起始位置,自然没法用二分查找。 - OR条件中有一个非索引列:
WHERE a = 1 OR b = 2,其中只有a有索引。优化器可能选择全表扫描,因为单个索引无法同时处理两个分支。 - 负向查询:
WHERE status != 0或WHERE status NOT IN (...)。这类条件通常要扫描大量记录,优化器认为走索引的代价不比全表小。 - 对索引列做隐式运算:
WHERE id + 1 = 100。虽然语义上等于id = 99,但MySQL不会自动做这种等价变形,索引就白建了。
理解这些场景时,没必要死记“哪个写法不行”,抓住一个核心:索引失效,本质上是查询条件无法直接利用B+树的有序结构进行精确定位或范围裁剪。只要让索引列参与到任何“加工”中,索引就很容易废掉。
4.3 一个例外:索引下推(ICP)怎么“抢救”失效
有些被判定为“索引失效”的场景,其实在MySQL 5.6之后已经有了一定缓解,这就是索引下推(Index Condition Pushdown)。比如联合索引(a, b),查询WHERE a > 1 AND b = 2,因为b不是前缀列,按理说只能按a的范围把相关索引记录全部捞出来再回表过滤。
但有了ICP,MySQL会在索引遍历过程中直接对索引记录里的b列做过滤,只有真正满足b = 2的记录才回表。这意味着“部分失效”的场景下,回表次数大幅减少。但要注意,ICP并没有改变“无法用b做B+树定位”的事实,它只是减少了无效回表,查询的扫描路径依然是基于a的范围。
5. 二级索引更新时,锁的顺序会决定你会不会死锁
5.1 为什么要聊锁:索引和并发是同一棵树的正面和背面
很多人学索引时只看查询路径,不看写入时的并发行为,这是不完整的。InnoDB的锁最终都是锁在索引记录上的,没有索引就意味着锁不了具体记录,只能锁更粗粒度的东西。这也是为什么“排他锁到底锁了哪一行”这个问题,必须回到索引结构里找答案。
更新一张表时,InnoDB需要先找到要更新的记录,再对相关索引项加锁。如果更新条件走的是二级索引,整个加锁路径就不是只发生在聚簇索引一棵树上。
5.2 二级索引更新时的加锁顺序
举一个真实场景:
UPDATE orders SET status = 2 WHERE order_no = 'A123';假设order_no上有二级索引idx_order_no,这条SQL的执行顺序大致是:
- 通过
idx_order_no定位到order_no = 'A123'的二级索引叶子节点; - 对这个二级索引记录加锁(记录锁/间隙锁);
- 拿到对应的主键ID后,回表到聚簇索引,对聚簇索引里的目标行加锁。
这里就出现了一个热搜词里反复提到的问题:先锁二级索引项,再回表锁主键,这个时间窗口容易形成交叉加锁,进而引发死锁。
假设有两张不同的二级索引,比如idx_order_no和idx_user_id,两个事务分别按自己的条件更新同一行数据:
- 事务A先通过
order_no锁二级索引记录,等待去锁聚簇索引里的主键行; - 事务B先通过
user_id锁另一个二级索引记录,等待回表锁同一个主键行。
两者回表的目标是同一行聚簇索引记录,却因为抢锁顺序不同,互相等待对方释放聚簇索引上的锁,形成循环等待。这就是死锁的经典形成路径。
5.3 怎么降低死锁概率
没有绝对免死锁的方案,但可以从加锁顺序和锁粒度两个层面下手:
- 确保多个事务更新同一组行时,能走同一个索引路径。比如业务上统一用主键或唯一索引作为更新条件,让加锁顺序收敛为“先二级索引,再聚簇索引”的固定顺序。
- 缩小锁范围。把大事务拆小,减少一个事务持有多个二级索引锁的机会。
- 留意间隙锁。在
REPEATABLE READ隔离级别下,范围条件会引入间隙锁,间隙锁的存在会让死锁场景更复杂。必要时考虑READ COMMITTED隔离级别,或者把范围条件设计成等值条件。
这些细节刷面试题的时候可能只是“死锁四要素”,但真正写业务时会发现,索引选择直接决定了并发更新的锁路径。理解这条链路,比背十个死锁案例都有用。
6. 索引表空间、页分裂与碎片回收:运维视角的索引
6.1 索引真的占表空间吗
很多人在information_schema.TABLES里看到DATA_LENGTH和INDEX_LENGTH两个字段,下意识会问:索引表空间到底是怎么算的?其实在InnoDB独立表空间模式下,索引和表数据都存在同一个.ibd文件里,只是逻辑上可以分为聚簇索引和二级索引两部分。INDEX_LENGTH统计的是非聚簇索引占用的空间,包含了所有二级索引的B+树页面。
所以索引不是“额外送你的数据结构”,它实实在在吃掉磁盘空间。索引建得越多,写入时维护的树越多,占用的表空间越大。这也是为什么不能无脑给每个字段都加索引。
6.2 碎片是怎么来的
碎片的主要来源有两个:
- 页分裂:乱序插入或不合适的DELETE操作,导致B+树页面内部出现空闲空间或页面之间不连续;
- 频繁更新可变长字段:比如
VARCHAR列变大后,记录在页内放不下,InnoDB得把记录挪到新位置,留下所谓“行迁移”。
碎片带来的后果是:同样多的数据占了更多页,查询时需要扫描的页也变多了;更糟的是碎片页可能分散在磁盘不同区域,顺序扫描变成随机IO。
6.3 什么时候需要清理碎片
这里有一个误区:碎片率不是越高就必须立刻清理,清理动作本身要重建索引,代价不小。
一般经验是,当满足以下条件之一时考虑整理:
- 表频繁增删改,且索引页碎片率明显偏高(可以通过
information_schema或工具估算); - 查询性能在数据量没怎么变的情况下,持续下滑;
- 磁盘空间压力明显来自
INDEX_LENGTH的大幅增长。
常用的整理手段有:
ALTER TABLE orders ENGINE=InnoDB; OPTIMIZE TABLE orders;OPTIMIZE TABLE的实质是重建表与索引,让数据重新紧凑排列。要注意的是,这个操作会长时间锁表,线上大表通常要用pt-online-schema-change这类在线工具来做,而不是直接在生产环境执行。
另外,分析统计信息也很重要:
ANALYZE TABLE orders;这个命令会更新优化器依赖的基数估算,让执行计划更准确。特别是批量导入大量数据之后,如果发现优化器选错索引,第一步先跑一遍ANALYZE TABLE,而不是急着改SQL。
7. 用EXPLAIN做一次真实的索引设计与调优复盘
7.1 从执行计划反推索引设计是否合理
前面讲了很多原理,最后落到实操,绕不开EXPLAIN。我通常不看那些“每条字段背下来”的教程,只看几个关键列:type、key、rows、Extra。
type:从好到差大致是system -> const -> eq_ref -> ref -> range -> index -> ALL。出现ALL就意味着全表扫描,绝大多数情况下是必须警惕的信号;key:实际用到的索引;rows:优化器预估扫描的行数,这个数字能直观反映索引“裁剪”效果好不好;Extra:重点看有没有Using filesort、Using temporary这类关键词。
比如我们处理过一个分页慢查询:
SELECT * FROM logs WHERE level = 'error' ORDER BY created_at DESC LIMIT 10;EXPLAIN结果里type = ref,key = idx_level,但Extra出现了Using filesort。原因很简单:idx_level只能处理level的等值过滤,无法提供created_at的有序性。把索引改成idx_level_created_at (level, created_at)后,排序直接走索引有序性,Using filesort消失,查询时间从秒级降到毫秒级。
7.2 JOIN和GROUP BY场景下的索引设计优先级
多表查询时,驱动表(外层表)的条件列要有索引,被驱动表的连接列也必须要有索引。连接列没索引时,对于驱动表返回的每一行,被驱动表都得全表扫一遍,代价是乘积关系。
比较实用的经验是,在建索引时按这个优先级分配字段:
- WHERE中的等值条件;
- JOIN的连接列;
- ORDER BY字段;
- GROUP BY字段。
等值条件下效率最高,排序字段能省掉 filesort,GROUP BY在索引有序性的基础上可以直接分组聚合。但注意GROUP BY的字段最好跟索引前缀匹配,否则它同样会走临时表。
7.3 一次看起来合理、实际帮倒忙的“过度索引”案例
有次项目里把一个十来个字段的业务表,按每个常见查询条件都建了索引,最后二级索引建了七个。结果写入变慢,binlog文件增长明显,占用磁盘比原来多了快一倍。重点是,优化器在多个可选索引之间还要做“择优”,统计信息一不准,执行计划反而摇摆。
这件事让我对索引设计有了两个很深的体会:
- 索引不是“查询的保险”,而是“查询的路径”。每多一条路径,写入时都要多维护一棵B+树。
- 尽量用联合索引覆盖多个查询,而不是为每个查询单独建索引。比如
(user_id, status, create_time)这一个索引,可以同时服务user_id等值查询、user_id + status查询、user_id + create_time排序。索引里的字段顺序,就是你对业务查询模式的优先级排序。
调整之后,表上只保留三个联合索引和一个主键,查询速度没有下降,写入压力和磁盘占用却明显改善。这算是我个人在索引设计里最常提的一条经验:少而精,永远好过多而杂。
这篇文章踩过的坑、总结的经验基本都写在上面的章节里了。如果只挑一句话记住,那就是:所有索引问题,最终都能从B+树的结构和InnoDB的组织方式里找到原由。多看几次EXPLAIN,多想想“这棵树能帮我少扫多少页”,很多面试题和实践难题,都会变得清楚很多。