接手过一个内部系统的慢查询工单。一张按用户和创建时间筛选的订单表,数据量不过两百多万行,一条看似简单的SQL却稳定跑出2秒以上的延迟,每天被定时任务调用几十次,直接把整条批处理链路拖到深夜。排查过程并不复杂——表里只给用户列建了单列索引,时间字段裸奔,查询里还混着一层隐式转换。花了一个下午重建联合索引、改写查询之后,同样的逻辑降到30毫秒以内。这件事之后,我把《高性能MySQL》第6章翻了好几遍,又拿手头几个项目的真实执行计划反复对照,才慢慢把"索引"从书本概念变成一种条件反射式的设计直觉。
这篇笔记就是那轮沉淀的产物。我会从存储引擎的物理结构讲起,串联联合索引列顺序设计、执行计划解读、覆盖索引与延迟关联、索引维护这些实操环节,每一块都尽量带执行计划和数据对比。适合正在啃这本书但缺实战对照的开发者,也适合被慢查询折磨过、想系统补齐索引方法论的后端工程师。
1. 先从磁盘I/O说起:索引优化的本质是减少随机读
1.1 一条慢SQL的解剖:全表扫描的成本分布
当时那条SQL大概长这样(字段脱敏):
SELECT order_id, user_id, amount, status FROM orders WHERE user_id = 12345 AND create_time >= '2024-01-01' AND create_time < '2024-02-01';表结构里只有主键 id 和单列索引 KEY idx_user_id(user_id)。从执行计划看,type=ref,走了 idx_user_id,rows 估算八千多行,Extra 里没有 Using index。这意味着MySQL先从二级索引捞到八千个主键,再对每条记录回聚簇索引读完整行,最后在server层逐行过滤 create_time。八千次回表,每次都是一次随机I/O,这才是两秒耗时的真正来源。
这里藏着第一个关键认知:二级索引并不存储完整行数据,它只存储索引列加主键。很多人误以为"建了索引就不慢了",但回表次数一旦放大,索引反而会变成新瓶颈。所以优化的方向不是笼统地"加索引",而是"减少回表次数和扫描行数"。
1.2 B+Tree为什么能扛住千万级数据
B+Tree的层级通常浅得超出直觉。InnoDB默认页大小16KB,假设主键是8字节的bigint,加上约6字节的指针,每个非叶子页大约能放一千一百多个索引项。树高3层时,叶子页数量就是一千一百乘一千一百的量级,按每页存百来行计算,轻松覆盖千万级乃至上亿行记录,而查找路径只需要3次磁盘I/O。
对比二叉树动辄几十层的高度,B+Tree用"宽而浅"的结构把磁盘访问次数压到个位数;同时叶子节点之间通过双向链表串起来,范围查询只需要顺着链表顺序读。这也是 create_time BETWEEN 这类条件能走索引区间扫描的物理基础。理解了这一点,再去看书上关于B-Tree索引适用场景的表格,就会明白为什么它能同时满足等值、范围、排序、前缀匹配四种需求。
1.3 聚簇索引与二级索引:回表代价的根源
InnoDB是聚簇索引表。主键对应那棵B+Tree,叶子节点就是整行数据;所有二级索引的叶子节点存储的是主键值。回表,就是拿二级索引得到的id,回到聚簇索引里再查一次完整行。
这个设计有明确的取舍。好处是主键查询极快,行数据物理移动时二级索引不需要跟着改指针;坏处是每次使用二级索引都天然欠着一笔回表账。MySQL 8.0的InnoDB虽然支持二级索引降序存储,但并没有改变回表的本质。
理解了聚簇索引和二级索引的关系后,再回头看哈希索引、空间索引、全文索引这些类型,就会明白为什么日常OLTP里B+Tree占据统治地位:哈希索引只能做等值匹配,排序和范围一律靠全扫。除非有非常明确的内存等值查询场景,否则没必要为了一个等值查询去单独维护哈希索引。
2. 联合索引列顺序:教科书理论与真实数据碰撞后的修正
2.1 三星索引:判断一个索引能服务到什么程度
《高性能MySQL》第6章反复讲联合索引的重要性,但"哪列放前面"的口诀容易让人知其然不知其所以然。这里我想引入另一个评价体系——《Relational Database Index Design and the Optimizers》里的三星索引(Three-star index)。它把"索引好不好"拆成了三个可检验的标准:
- 第一星:索引能覆盖WHERE里的等值条件列,让这些列成为索引的前导列,精准定位行;
- 第二星:索引列顺序与ORDER BY一致,查询不需要额外的filesort排序;
- 第三星:索引包含SELECT所需的全部列,查询只扫索引页,完全不用回表。
注意,一颗星到三颗星不是非黑即白。实际项目里很难同时拿满三星,因为星与星可能相互冲突:为了第二星把排序列放前面,可能破坏第一星的最左前缀;为了第三星把无关列全部塞进索引,又会被写入放大反噬。设计目标应该是"在当前的查询负载下拿尽可能多的星"。
2.2 列顺序决策清单:等值列优先,范围列垫底
实战中我的列顺序决策固定如下,基本不犹豫:
- 先放WHERE里的等值条件列,也就是 = 或 IN 的列;
- 再放ORDER BY / GROUP BY的列,尽量与排序方向一致;
- 最后放范围条件列,比如 >、<、BETWEEN;
- 如果还差覆盖,再把SELECT里剩下的列补进索引,但必须先评估写入代价。
为什么等值列优先?因为联合索引的最左前缀规则下,等值列的价值是"精准定位",多个等值列叠加,等于在索引上加了多个过滤维度。而范围列一旦被用于索引扫描边界,它后面的列就无法继续参与过滤了,最多只能用来做覆盖索引减少回表。所以范围列放最后,算是设计上的铁律。
这里有个容易误解的细节:多个等值列之间,选择性高低其实不太影响"最终结果集合的大小",因为每个等值条件都会被应用;但选择性排序会影响"当业务SQL只带其中一个等值条件时"的索引收益,所以把选择性最高的列放最前面,等于给未来的查询变体留了一条后路。
2.3 一个做反的案例:单列选择性高不等于联合索引最优
有一回优化一张用户行为日志表,SQL长这样:
SELECT user_id, event_type, create_time FROM user_events WHERE user_id = 321 AND event_type = 'login' ORDER BY create_time DESC LIMIT 20;最初索引是单列 idx_event(event_type),理由是 event_type 看起来"很独特",其实全表总共只有5种取值。EXPLAIN显示rows估算十五万,Extra里躺着Using filesort。我把索引改成联合索引 idx_user_event_time(user_id, event_type, create_time) 之后,执行计划变成ref,rows降到几十,filesort消失,再补一个覆盖条件后Extra直接变成Using index。查询耗时从80ms降到1ms左右。
这个案例的关键错误是只看了单列选择性,没看列在查询里的角色。event_type即便再"独特",单独建索引也只能定位到一类事件,无法与user_id联合过滤。user_id放第一位后,等值列直接把扫描范围压到该用户的少量记录,event_type在这个范围内继续过滤,create_time则从索引里顺序取出,天然满足排序。这就是第2.1节说的:前两星同时拿下,代价几乎为零。
3. 用EXPLAIN验证索引路径:别让执行计划骗了你
3.1 执行计划里我只盯这四列
很多开发者看EXPLAIN喜欢把每列含义都背下来,我实际排查慢查询时只盯四列:
- type:从好到差大致是 const、ref、range、index、ALL。见到ALL基本就是全表扫描,range代表索引范围扫描,ref通常是等值匹配的二级索引;
- key:实际命中的索引名,值为NULL说明没用到索引;
- rows:优化器估算的扫描行数,这个数字直接决定路径选择;
- Extra:Using index、Using index condition、Using filesort、Using temporary这些标记是最重要的线索。
有个容易被忽略的点:rows是估算值,不是精确值。InnoDB的基数统计靠随机抽样,数据分布不均匀时,rows可能严重偏离真实情况。第4节我会专门讲优化器统计信息带来的假象。
3.2 最左前缀失效的四种现场,外加一种假救赎
假设联合索引是 (a, b, c),我在线上见过的失效现场基本可以归成四类:
WHERE b = 1,没有a条件——走不了这个联合索引;WHERE a > 10 AND b = 1——a的范围让b无法参与索引过滤,这就是范围列放最后一句的反证;WHERE a = 1 ORDER BY c DESC——中间断了b,排序无法用索引,出现filesort;WHERE a = 1 AND b = 2 AND c LIKE '%xx'——c的前导模糊匹配让c的索引过滤能力作废。
第2种最常见,因为开发者写SQL时习惯把时间范围条件写在前面,而时间列往往又不是联合索引的最左列。MySQL 8.0的Index Skip Scan在特定条件下可以部分绕过第1种问题,也就是跳过前导列的不同取值来使用后面的索引列,但它的适用边界很窄,查询成本可能不降反升,不要把它当常规武器。
还有一种假救赎:Extra里出现 Using intersect / Using union,说明优化器在同时合并多个单列索引的结果。这听起来很聪明,但出现这种情况通常意味着存在某个联合索引能一条路径搞定全部过滤,而优化器被迫用索引合并,恰恰暗示索引设计不合理。
3.3 隐式类型转换与函数操作:优化器的宁死不屈
另一个高频踩坑点是索引列上套函数。比如WHERE DATE(create_time) = '2024-01-15',即便create_time上有索引也走不了,因为条件本质上不是对索引列等值,而是对索引列的函数值等值,优化器没法直接定位。
MySQL 8.0支持函数索引,确实可以显式建:
ALTER TABLE orders ADD INDEX idx_create_date ((DATE(create_time)));但更优雅的解法是把SQL改写成可下推的范围形式:
WHERE create_time >= '2024-01-15 00:00:00' AND create_time < '2024-01-16 00:00:00'这样既不用为每个函数形态单独建索引,也保留了索引条件下推(ICP)的余地。
隐式类型转换和函数套用是同一个病根。字符串列和数字直接比较时,MySQL会把字符串列转成数字再比较,相当于在列上偷偷加了一层函数,索引直接失效。排查方法很简单:EXPLAIN看type是不是变成ALL,或者执行完之后用 SHOW WARNINGS 看一眼优化器实际重写后的SQL长什么样。
4. 覆盖索引与延迟关联:把回表次数压到最低的两种打法
4.1 覆盖索引的适用边界
覆盖索引指的是一个索引包含了SELECT所需的全部列,查询可以直接从索引页取数,完全跳过回表。它的收益是数量级的:从"每条记录一次随机I/O"变成"一次顺序扫描索引页"。
但覆盖索引不是万金油。第一,它只能覆盖"索引里已经包含的列",如果为了覆盖把表里所有列都塞进索引,索引体积会膨胀到接近聚簇索引,失去意义;第二,覆盖索引会放大写入时的页分裂概率和内存占用,写多读少的表要慎重;第三,覆盖索引对LIMIT分页只能解决回表,解决不了深分页本身——翻到第100万页时,前99万条记录依旧会被扫描然后丢弃。
4.2 延迟关联:深分页场景的一次完整优化
深分页的经典写法是:
SELECT * FROM orders WHERE status = 1 ORDER BY create_time DESC LIMIT 100000, 20;常规优化是让内层查询只走索引拿20个主键,再回表取完整行:
SELECT o.* FROM orders o INNER JOIN ( SELECT id FROM orders WHERE status = 1 ORDER BY create_time DESC LIMIT 100000, 20 ) t ON o.id = t.id;这个模式叫延迟关联(deferred join)。核心思路是让最内层查询只访问二级索引,索引天然有序,不需要filesort,一次性跳过前面10万条不需要的数据;随后只对这20条结果做回表。相比外层直接对10万条完整行排序再丢弃,这个改动的提升通常在10倍以上。
注意:子查询里千万不要顺手SELECT *,那样就退回原点了。内层只SELECT id,其余列全部留给外层JOIN去取。
4.3 统计信息与基数估算:rows可能骗你
InnoDB对索引选择性(Cardinality)的统计默认是持久化的,靠随机采样估算,采样页数由采样参数控制,并且不会每次查询都实时更新。所以EXPLAIN里经常出现rows和真实行数差一个数量级的情况,优化器会基于错误估算选出错误的索引路径。
处理手法有几个:先用 FORCE INDEX 或 USE INDEX 临时指定索引,确认是否正确路径;再执行 ANALYZE TABLE 重新采样统计信息;如果数据波动剧烈,考虑配合分区裁剪,让优化器至少拿到分区级的准确统计。重点在于不要盲信EXPLAIN的rows,它只是优化器自己的估算模型,不是真相。
5. 索引的隐形成本与日常维护:高速路也要养路
5.1 写入放大与缓冲池挤占
每次INSERT、UPDATE、DELETE,所有二级索引都要同步维护;索引越多,写路径越长,页分裂发生时还会带来额外随机写。对写入QPS很高的表,每多一个二级索引,写入吞吐下降10%-20%是常见量级。与此同时,InnoDB缓冲池要同时缓存数据和索引页,冗余索引会挤占数据页的缓存空间,整体命中率可能不升反降。
所以"索引建得越多越好"是有毒的思路。我的习惯是:每个查询先想清楚它到底需要什么索引,能用联合索引覆盖多个查询,就绝不拆成一堆单列索引堆在那里。
5.2 冗余索引识别与清理
冗余索引的经典形态有三个,见到基本可以二选一:
- 联合索引 (a, b) 存在时,单独索引 (a) 基本是冗余的,因为 (a) 就是 (a, b) 的最左前缀;
- 主键是bigint时,二级索引本来就存主键,所以单独建包含主键列的索引通常没意义;
- 两个索引列集合完全一样、顺序略有不同,一般只保留最常用那条。
线上排查可以利用MySQL 5.7以上提供的sys库视图 schema_redundant_indexes 直接列出可疑对象,再结合业务确认删除顺序。删索引和删表一样,先改成不可见索引(MySQL 8.0的 INVISIBLE INDEX)观察一段时间,确认没有查询依赖后再物理删除,是更稳妥的做法。
5.3 碎片整理与统计信息刷新
索引页长时间频繁更新会产生碎片,表现为索引的逻辑顺序和物理顺序错位,范围扫描变慢。定期用 OPTIMIZE TABLE 重建表可以回收碎片,但不要在高频表上频繁执行,重建本身也是锁和I/O的大户。更轻量的做法是监控 information_schema 里的 data_free 字段,或者直接跟踪实际查询耗时的变化,超过阈值再处理。
碎片之外,统计信息陈旧同样影响优化器。批量导入导致数据量发生数量级变化后,顺手跑一次 ANALYZE TABLE,但要避开业务高峰,它会短暂持有元数据锁。这两件事都不复杂,难的是养成定期做的习惯。
最后分享一个我自己的习惯。每次建索引之前,我会把这条SQL写进项目里的一个索引评审清单,注明查询场景、预期rows、实际rows、是否有filesort或temporary。两周后再回来看一遍,如果业务SQL已经演进,这个索引成了摆设,果断处理。索引是活的,它应该跟着查询负载一起进化,而不是建完就丢进保险箱。这也是《高性能MySQL》第6章读了许多遍之后,我觉得最值得带回日常实践的一点。