1. 面试场景还原:这道题到底在考什么
这是一个我印象特别深的面试场景。候选人简历上写着“精通MySQL索引优化”,前面几轮基础题答得也不错,结果在二面的时候,面试官问了一句:“你的订单表查询加了索引,但线上监控显示这条SQL还是慢,你觉得问题可能出在哪里?”
候选人愣了一下,然后开始背八股:索引失效嘛,比如查询条件里用了函数、隐式转换、LIKE '%xxx' 这种。面试官听完点点头,又追问了一句:“如果索引都用上了呢?EXPLAIN里 type 是 ref,key 也显示走了索引,但还是慢,你怎么办?”
大部分候选人到这里就卡住了。
这个问题其实特别典型,因为它在考察的并不是“你是否背过索引失效的七种场景”,而是你有没有真正在线上环境处理过慢查询,有没有从执行计划、数据结构、优化器行为、存储引擎机制等多个维度去定位过一个“看起来不该慢”的SQL。我自己带过不少团队,也经常帮别人review慢查询,实话讲,能一口气把这个问题答完整的候选人,十个人里未必有两个。
先说一个核心结论,也是这篇文章想帮大家建立的最重要的认知:索引生效,和索引高效,是完全不同的两件事。我们在面试里背的那些“索引失效”场景,比如函数操作、隐式转换、违反最左前缀原则,只是最表层的原因,它们让查询走不上索引;但真正让线上慢查询难以排查的,往往是那些索引用上了、但仍然“徒劳无功”的场景。比如回表次数爆炸、索引区分度太低、深分页导致的随机IO、优化器选错执行计划……这些才是90%候选人答不到的点。
这篇文章我会按照一个完整的技术复盘思路,从“为什么这个问题难回答”开始,把索引生效但查询依旧慢的几类深层原因全部拆开,再配合我实际排查过的案例,给你一套可以直接复制去用的慢查询定位流程。如果你正准备面试,或者正在被线上慢SQL折磨,这篇文章应该能帮上大忙。
2. 第一层认知:索引失效的经典场景,别在这些坑里翻车
虽然我前面说“只会答索引失效”不够,但这绝不意味着索引失效不重要。恰恰相反,这是排查慢查询的第一道关卡。如果你连索引有没有走对都没判断清楚,后面的分析全部是空中楼阁。所以这篇文章里还是先把这块完整过一遍,同时纠正几个大家最常见的认知误区。
2.1 违反最左前缀原则
大多数候选人知道“联合索引要遵守最左前缀原则”,但理解往往是机械的。比如有一个联合索引(user_id, status, create_time),查询条件是WHERE status = 1 AND create_time > '2024-01-01',这时候索引能不能用?
答案是用不上的。因为联合索引在B+树里是按照字段顺序逐级排序的:先按user_id排序,user_id相同再按status排序,最后才按create_time排序。你跳过了第一个字段直接查status,B+树根本没有办法利用索引本身的有序性去定位,只能扫整个索引或者回表再过滤。
但这里有个细节我建议面试时主动讲出来:MySQL 8.0 引入了一个叫Skip Scan(跳跃扫描)的优化,在特定条件下即使查询条件里没有联合索引的第一个字段,也可能用上索引,它会把第一个字段的每个不同值都当成一个扫描起点去执行。它适用于第一个字段区分度很低、第二个字段选择性高的场景。大部分情况下这个优化不生效,但你能主动提出来,就说明你真的研究过,而不只是背了一条“最左前缀原则”。
2.2 索引列上做运算、函数和隐式类型转换
这个场景也是经典中的经典。只要你对索引列做了任何形式的“加工”,优化器就无法直接使用索引列原本的有序性去做二分查找了,因为 B+树里存储的是原始值,你搜索的是加工后的结果,两者对不上号。
- 函数操作:
WHERE DATE(create_time) = '2024-01-01',应该改成WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00'。 - 隐式类型转换:最常见的是字段类型是
varchar,但你传了一个数字进去,比如WHERE phone = 13800138000,MySQL 会自动把phone转成数字再比较,等于对索引列加了 CAST 函数,索引直接失效。反过来也一样,字符串传给数字字段也会有影响。 - 字符串编码问题:
WHERE name = 'abc'如果字段的排序规则是utf8mb4_bin而查询的常量带有不同 collation,也可能让优化器放弃索引。
我见过很多团队在初筛这种问题时,对“函数导致索引失效”有印象,但一碰到“怎么改写”就露怯。比如上面那个时间范围改写的例子,改写前后的执行计划差距是数量级的。你在面试时可以顺手补一句:“这类问题本质上是破坏了索引列的有序性,改写要围绕让索引列保持原始形态去思考。”
2.3 LIKE 模糊查询、OR 条件与范围查询的坑
LIKE 以通配符开头的查询,比如WHERE name LIKE '%张%',由于不知道目标值的前缀,B+树没法定位起点,索引自然失效。但WHERE name LIKE '张%'是可以走索引的,因为字符串有前缀顺序。
OR 条件则要分情况:如果 OR 连接的多个条件里,某个列没有索引,MySQL 大概率会放弃所有索引选择全表扫描,因为它需要对多个结果集合做并集,而其中一个集合必须通过全表扫描获得,那还不如一次全表扫完。这就是为什么有些优化方案会把OR改成UNION ALL,让每个分支各自走最优的索引。
范围查询需要注意一个更隐蔽的点:联合索引里,如果一个字段做了范围查询(比如>、<、BETWEEN),那么它后面的字段就没办法继续利用索引的有序性精确定位了。比如索引(a, b, c),查询WHERE a = 1 AND b > 10 AND c = 5,理论上c = 5是等值条件,但实际访问路径里b > 10已经是一个范围,c只能在b确定的范围里做内存过滤,无法通过索引直接跳转。
提示:你可以在 EXPLAIN 的
key_len字段上验证这一点。如果key_len只覆盖了a和b两个字段的长度,说明c字段没有真正参与索引定位。用key_len倒推索引使用程度,是一个面试官很爱听的细节。
2.4 失效场景速查表
我在平时带团队的时候,经常让大家把这张表打印出来贴工位上。排查慢查询前先对照一遍,可以排除掉大部分低级问题。
| 场景 | 示例 | 后果 | 常用解法 |
|---|---|---|---|
| 违反最左前缀 | 联合索引(a,b)直接查b | 无法利用索引有序性 | 调整索引字段顺序,或加单列索引,或依赖 Skip Scan(条件苛刻) |
| 索引列函数操作 | WHERE DATE(create_time)=... | 放弃索引 | 改成范围查询 |
| 隐式类型转换 | varchar 列传入数值 | 放弃索引 | 保证参数类型与字段类型一致 |
| LIKE 前导通配符 | LIKE '%abc' | 放弃索引 | 用LIKE 'abc%'或全文索引 |
| OR 含无索引列 | a = 1 OR b = 2,b 无索引 | 可能全表扫描 | 改 UNION ALL,或为 b 加索引 |
| 联合索引范围后置字段 | (a,b,c)中b用范围,c等值 | c 字段只能回表过滤 | 调整索引字段顺序(等值字段在前) |
| IS NOT NULL 与否定条件 | IS NOT NULL、!=、NOT IN | 可能放弃索引 | 视数据分布而定,必要时改写 |
| 数据分布量过大 | 小表全表扫描更划算 | 优化器主动放弃索引 | 不用管,这是正常优化行为 |
说句实在话,绝大部分面试者聊到这里就停住了,给出的解决方案也基本是“给查询条件加索引、避免函数、避免隐式转换”。这些方向没错,但在面试官眼中,这些只算入门。他真正想听的东西,从下一章才开始。
3. 第二层认知(重点):索引明明生效了,查询为什么还是慢?
这是整个问题最核心的部分,也是90%候选人答不到点上的原因。我先把这个问题的答案拆成一个核心模型:一次查询的开销,大约等于“从索引树定位的成本 + 扫描索引叶子节点的成本 + 回表访问聚簇索引的成本 + 网络传输与客户端处理的成本”。加索引解决的是“定位”问题,但后面三项,索引不一定帮得上忙。
当 EXPLAIN 显示你的 SQL 走了一个索引,type 是 ref 或 range,key 也确实是某个索引,但查询依然要几百毫秒甚至几秒,原因往往出在这几个方面。
3.1 回表:被严重低估的随机IO成本
这是我最想讲清楚的一点。InnoDB 的表数据本质上是一个以主键为叶子节点顺序的聚簇索引,而其他二级索引(也就是我们普通建的普通索引)的叶子节点,存的是索引列的值 + 主键值。
当你通过二级索引查询时,大致流程是这样的:
- 在二级索引的B+树里,通过二分查找定位到满足条件的叶子节点。
- 读取叶子节点上的主键值。
- 用主键值再到聚簇索引(主键索引)里查一次,找完整行数据。
第3步就叫回表。如果命中了大量二级索引记录,每一行都要做一次主键查找。主键在聚簇索引里是按主键值物理排序的,但你二级索引叶子节点的顺序通常和主键顺序不一致,于是每次回表对应的也是一个分散位置的磁盘随机读。机械硬盘时代这就是灾难,SSD 时代随机IO依然比顺序IO慢一个数量级。
大多数“索引生效但慢”的查询,本质上都是回表次数太多。
举个例子。订单表有1000万行,你执行SELECT * FROM order WHERE user_id = 123,user_id上有索引,EXPLAIN 显示走了idx_user_id,type 是 ref。但是,如果user_id=123的用户下过20万单呢?这意味着索引扫描到20万个主键值,然后回表20万次。如果这20万行分散在磁盘不同页面上,即便用SSD,也需要大量的随机读。最终这个查询可能耗时几百毫秒甚至秒级,完全谈不上快。
那怎么办?常见方案有:
- 覆盖索引(Covering Index):把查询要的所有字段都放到索引里,让索引叶子节点本身就包含所需数据,连回表都省了。比如查询只选
user_id和status,索引(user_id, status)本身就是覆盖索引,直接扫叶子节点就完事,EXPLAIN 的 Extra 列会显示Using index。 - 索引下推(Index Condition Pushdown,ICP):MySQL 5.6 引入,它允许在索引遍历过程中,先用索引里已有的字段做条件过滤,减少回表次数。比如联合索引
(user_id, status),查询WHERE user_id=123 AND status='PAID',ICP 会先在二级索引里就把status过滤掉,只对剩余少量记录回表。EXPLAIN Extra 列有Using index condition就是用了 ICP。 - 分批/缩小范围:如果业务允许,改为分页或按时间分批处理,把一次干20万行回表的任务拆成多次,避免单次查询过重。
面试的时候如果你能主动提到“用索引下推和覆盖索引减少回表”,面试官会立刻把你和那些只会背“失效场景”的人区分开。因为这说明你理解的是存储引擎层面的执行机制,而不是停留在语法表面。
3.2 低区分度字段:扫描行数依旧巨大
第二个容易踩的坑,是索引的区分度(Cardinality,基数)太低。面试官问“加了索引为什么还慢”,他很可能在等你检查索引的基数。
基数,简单理解就是索引列上不同取值的数量。它直接决定了 B+树里能过滤掉多少无关记录。比如性别字段只有“男、女”两个值,基数就是2。你在性别上建索引,索引B+树里大概一半记录是“男”、一半是“女”,如果查询WHERE gender='男',扫描的行数大约是表总行数的一半——这和全表扫描几乎没有区别,优化器甚至可能主动放弃这个索引。
但这里有一个更微妙的场景:单条 SQL 走了索引,但因为基数低,扫描的行数还是巨大。比如某个状态列status有“待支付、已支付、已取消、已退款”4种值,你为它建了索引,查询WHERE status='待支付',EXPLAIN 显示走了索引idx_status,type 是 ref。结果待支付这个状态占了全表800万行,你要不停回表800万次。索引确实用了,但查询没快起来。
我刚接手一个系统的时候,就遇到过这种诡异情况:一个“统计待办数量”的查询,明明用了状态索引,却要跑2秒多,原因就是待办状态对应的数据量占到了全表的60%,索引帮不上忙。
处理这种问题的思路有这么几条:
- 先看数据分布。如果某个值对应行数太多,单靠这个字段建索引意义不大,要组合其他过滤条件一起使用。
- 考虑组合索引,把区分度高的字段前置。比如
WHERE status='待支付' AND user_id=123,索引应该建(user_id, status),让高区分度字段先定位到少量用户,再过滤状态。 - 考虑使用汇总表、缓存,或者让统计任务异步化,不要在查询链路里全量统计。
- 极端情况下,对于“大量小值重复”的情况,位图索引(比如某些分析型数据库)会更合适,但MySQL的B+树索引不是为这种场景设计的,不要硬抗。
你需要在面试时表达的核心观点是:判断索引是否高效,不能只看 SELECT 是否用了索引,还要看它实际扫描了多少行。扫描行数接近表总量的大比例,索引就失去了意义。这时候可以主动说,你会去查EXPLAIN的rows字段,以及用SHOW INDEX FROM table查看Cardinality字段,来评估索引的区分度。
3.3 深分页问题:LIMIT 1000000, 20 的“虚假性能”
这一节我猜很多资深后端都会心一笑,因为这是分页接口最常见的性能杀手。
先看一条SQL:
SELECT * FROM order WHERE user_id=123 ORDER BY create_time DESC LIMIT 1000000, 20;这条SQL走了索引(user_id, create_time),EXPLAIN 看着也没问题。但它慢,而且随着页数越往后越慢,最后可能慢到不可接受。
原因在于 MySQL 执行LIMIT offset, size时,并不是直接从第1000000行开始读,而是会把前1000000行全部扫描出来,扔掉,再取后面的20行。对于索引扫描来说,前100万行虽然不需要回表,但每一行的索引扫描、主键值比较、排序判断都是实打实的开销。更糟的是,如果查询的字段不在索引里,这100万行每一行都会触发回表,那代价直接爆炸。
深分页问题的解法在业界有几种常用套路:
- 延迟关联(Deferred Join / 子查询先拿主键):先用覆盖索引查出目标主键,再关联原表取数据。
SELECT o.* FROM ( SELECT id FROM order WHERE user_id=123 ORDER BY create_time DESC LIMIT 1000000, 20 ) AS tmp JOIN order AS o ON tmp.id = o.id;子查询里只有id和create_time,走覆盖索引扫描,不需要回表,代价小很多;外层再对20个主键做回表,成本很低。
- 游标/键集分页(Keyset Pagination):不用 offset,而是记住上一页最后一条记录的排序值,下一页直接查大于该值的记录。
SELECT * FROM order WHERE user_id=123 AND create_time < '2024-01-15 12:00:00' ORDER BY create_time DESC LIMIT 20;这能让每次查询都直接定位到目标位置,不存在扫描并丢弃大量行的问题。代价是业务前端需要改动,没法直接跳页。
面试时提到这个点,会非常有说服力,因为它说明你真的接触过高并发下的分页性能问题,而不是只在测试库里跑过几条简单SQL。
3.4 优化器误判:统计信息不准与执行计划偏差
比前面几种更隐蔽的一类问题,是MySQL 优化器本身“看走眼了”。
MySQL 的优化器依赖存储引擎提供的统计信息来决定走哪个索引、做哪种 join 顺序。统计信息来自SHOW INDEX里的Cardinality字段,以及采样估算的数据分布。如果表的统计信息过期,优化器对“这个索引能过滤多少行”的估算就会偏差很大。
我在线上实际遇到过一个特别典型的例子:某张表数据量从百万涨到千万,A索引本来是区分度更高的索引,但由于统计信息里Cardinality还停留在几百万的量级,优化器认为另一个区分度低的索引过滤效果更好,就选错了。排查了很久,最后重新ANALYZE TABLE更新统计信息之后,执行计划才恢复正常。
另一个常见因素是查询条件里的参数值本身影响执行计划。一条SQL可能大部分时候参数值过滤效果好,但只要某个参数传了一个“宽泛”的值,优化器基于它的估算可能就直接选了全表扫描或错误索引。这就导致线上用户偶发性的“这条SQL怎么突然变慢了”问题。
排查这类问题,手段有几个:
EXPLAIN ANALYZE(MySQL 8.0.18+):不仅显示执行计划,还会输出每步实际耗时和实际行数,直接对比预估行数和实际行数,就能看出优化器估算是否离谱。OPTIMIZER_TRACE(优化器追踪):把优化器决策过程完整打印出来,能看到它比较了哪些候选索引、每种的估算成本、最后为什么选了某一个。- 定期
ANALYZE TABLE:手动触发统计信息更新,防止因长时间不更新导致的误判。 - 在SQL上加
FORCE INDEX(临时用)或通过改写SQL让优化器选对索引(根治);同时排查是不是SQL写法导致优化器无法准确估算,必要时可以拆SQL或加 hint。
这一块是很多候选人完全意识不到的盲区。大多数人对MySQL的理解还停留在“索引失效——走全表——加索引”这个线性链条,完全没想到优化器甚至可能基于错误信息主动“放弃”更优索引。写SQL的人觉得“明明加对了索引”,而数据库觉得“基于我的算盘,走另一个方案更快”。这个认知落差,正是这个面试题最锋利的地方。
3.5 排序、锁等待、事务快照带来的隐性放大
最后一类原因,是索引本身没问题,但查询的整体成本被其他机制放大了。这个点很细致,但面试官如果是个老手,很可能希望你能往这个方向多聊两句。
第一个是排序(filesort)。如果你ORDER BY的字段和索引的顺序不一致,MySQL 需要在内存或磁盘里做额外排序。比如你用了索引(user_id, create_time),但查询里是ORDER BY create_time,那排序就无法利用索引顺序,只能把满足条件的行先捞出来排序。数据量大时内存临时表放不下,还会落到磁盘,用归并排序,性能会成数量级下降。EXPLAIN 的 Extra 列如果显示Using filesort,就要注意了。反过来,只要让排序字段和索引顺序保持一致,就能让B+树的天然有序性替代排序操作,性能会显著提升。
第二个是锁等待。如果你执行的是一条UPDATE,它虽然也走了索引,但要修改的行持有行级锁,而你的事务一直没有提交,后面的查询就需要等待锁释放。这种慢不是因为索引,而是因为在等锁。排查方法可以用SHOW ENGINE INNODB STATUS看事务和锁信息,或者直接查performance_schema.data_lock_waits。很多人在面试时答“慢查询”只盯着单条SQL执行计划,但在线上,大量慢查询其实是等锁导致的。
第三个是事务隔离级别与undolog版本链。在REPEATABLE READ级别下,如果一个行被多个事务反复更新,SELECT 语句在读取时可能需要顺着 undo log 找出符合当前事务可见性的版本。这一行数据每次被修改,多版本链就长一点,读取就需要回滚更多版本,成本也随之增加。如果你发现一条SQL的执行计划没问题、IO也不高,但就是慢,可以考虑是不是这个行被频繁更新,导致版本链过长。这类问题在实际排查里算是进阶题了,但聊到它能直接展示你对 InnoDB 底层机制的掌握深度。
4. 第三层认知:慢SQL排查的完整方法论
面试题聊完了,接下来聊聊真正能落地的实操。毕竟面试官问“索引生效为什么还慢”,本质上是想看你有没有一套排查慢查询的方法论。我把自己平时定位慢查询的流程完整列出来,你可以直接照着做。
4.1 慢查询日志:从哪看、关键字段怎么读
一切的起点,永远是先把“慢”这件事量化。
在 MySQL 里,先确认慢查询日志是否打开:
SHOW VARIABLES LIKE 'slow_query_log'; SHOW VARIABLES LIKE 'long_query_time'; SHOW VARIABLES LIKE 'log_queries_not_using_indexes';slow_query_log:是否开启慢日志。long_query_time:阈值,一般建议线上设置成 1(秒),如果你的业务对延迟要求极高,可以设成 0.5 甚至 0.1。log_queries_not_using_indexes:是否记录没走索引的SQL,这个是排查隐性问题的重要开关,打开后能抓到大量被漏掉的潜在慢SQL。
慢日志的记录会带上查询时间、锁等待时间、返回行数、扫描行数等。其中Rows_examined和Rows_sent这两个值对比特别有价值。如果Rows_examined是几十万,Rows_sent只有几十,说明扫描了大量行却只返回极少结果,大概率是索引过滤做得不好,正好对应前面讲的低区分度或深分页问题。
提示:线上不要随手
SET GLOBAL long_query_time=0然后长时间开启,这会让所有SQL都进慢日志,日志文件会爆炸。我一般建议先开着阈值1秒,统计一段时间,再针对高峰时段去分析。
4.2 EXPLAIN 的正确打开方式:不只关注 type 和 key
拿到慢SQL后,第一步就是EXPLAIN,但很多人只会看type是不是ref、key是不是走了索引,这远远不够。我给团队定的标准是至少看这几个字段:
- type:
system>const>eq_ref>ref>range>index>ALL。index代表全索引扫描,ALL是全表扫描,这两个都要警惕。ref和range是比较合理的访问方式,但要结合rows看。 - key:实际选中的索引。有时候你建了索引,但优化器没选,这里显示的就不是它,需要注意。
- key_len:用于计算索引实际使用了多少个字节。通过它你可以反推联合索引到底用到哪一列。key_len 越长,说明索引覆盖的条件越多。
- rows:优化器预估的扫描行数。这是判断“索引有没有真的过滤掉足够多的行”的最直接指标。如果
rows占了表总行数的一大半,索引选择大概率有问题。 - Extra:包含大量关键信息。
Using index说明覆盖索引;Using index condition说明用了索引下推;Using filesort说明需要额外排序;Using temporary说明用了临时表,这两种往往都需要优化。
以我之前帮同事看的一条SQL为例:
EXPLAIN SELECT * FROM `order` WHERE user_id = 1001 AND status = 'PAID' ORDER BY create_time DESC LIMIT 10;EXPLAIN 输出里 key 显示idx_user_status,type 是ref,乍一看没问题。但key_len只有user_id那一段的长度,说明status没有参与索引定位,可能因为索引字段顺序是(user_id, create_time),本身没有status;或者虽然有(user_id, status),但order by create_time需要单独排序,优化器综合成本后选了别的方案。这种细节要结合查询需求、索引定义一起判断,不能只看一个字段。
4.3 optimizer trace 和 profiling:让优化器告诉你原因
EXPLAIN 只能看到最终结果,看不到优化器内部的决策过程。如果两条索引都可用,优化器为什么选了这条而不是那条?这时候就需要上optimizer_trace。
在 MySQL 8.0 里,可以这样查看:
SET optimizer_trace='enabled=on'; SELECT * FROM `order` WHERE user_id=1001 AND status='PAID' ORDER BY create_time DESC LIMIT 10; SELECT * FROM INFORMATION_SCHEMA.OPTIMIZER_TRACE; SET optimizer_trace='enabled=off';输出里会有rows_estimation(每个候选索引的行数估算)、considered_execution_plans(评估的执行计划)以及最终的chosen结果。你能清楚地看到每个索引的成本是多少,为什么最终选了眼前这个方案。
EXPLAIN ANALYZE则更进一步,它会真实执行SQL,并返回每一步的耗时和行数。我最常用它来验证“优化器估算的行数”和“实际扫描的行数”是否一致。如果不一致,说明统计信息不准,这时候可以去ANALYZE TABLE;如果一致但数量依然很大,那就是索引本身区分度不够或者查询条件太宽泛,需要用索引优化手段了。
这一整套流程走下来,慢查询基本上都能定位到原因。我建议面试时把这个流程讲出来,面试官马上就会意识到你不是背题,而是真的有一套可复用的排查方法论。
5. 实操案例:一次真实的慢查询定位与优化
光讲原理容易飘,我拿一个自己实际参与处理的线上案例来串一遍,把前面所有点串成一个完整的“破案”故事。这是几年前一个电商类系统的订单查询场景,具体表结构和SQL都做了脱敏,但排查思路完全一致。
5.1 案例背景与现象
业务方反馈运营后台的“订单列表”页面打开越来越慢,尤其是按某个大客户查询历史订单时,接口耗时经常超过3秒,数据库监控里偶尔还能看到慢查询告警。
相关表结构(简化):
CREATE TABLE `order` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `order_no` varchar(64) NOT NULL, `status` tinyint NOT NULL, `amount` decimal(10,2) NOT NULL, `create_time` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB;同步慢查询日志后,抓到了这几条典型SQL(简化):
SELECT * FROM `order` WHERE user_id = 10086 ORDER BY create_time DESC LIMIT 5000, 20;5.2 排查步骤与关键输出解读
第一步,EXPLAIN这条SQL。结果如下:
- type: ref
- key: idx_user_id
- rows: 接近30万
- Extra: Using filesort
注意几个问题:
- 索引是走了
idx_user_id,但优化器预估要扫30万行。 - Extra 有
Using filesort,说明ORDER BY create_time没法利用索引顺序,扫描完30万行后还要额外排序。 - 深分页
LIMIT 5000, 20,意味着前5000条也要全部扫描并丢掉。
这个三件事叠加起来,慢是必然的。索引确实生效了,但它只能帮你把用户过滤出来,后续的排序、回表、丢行都是额外开销。
第二步,我看了一下该用户的历史订单量,发现这个客户的订单数高达36万。从数据分布上看,user_id虽然是索引,但对该用户来说选择性接近于“不过滤”。如果这条SQL里只有user_id一个过滤条件,无论索引建得多好,都要面对几十万行数据的排序和分页问题。
第三步,用SHOW INDEX FROM order看了下索引区分度,idx_user_id的基数和总行数接近,说明字段本身是高区分度的,问题不在索引设计上,而在于这个特定查询的过滤条件太宽泛——单靠用户维度查所有历史订单,数据量本身就是巨大的。
5.3 三套优化方案与效果对比
最终我给业务方提了三套方案,按实施成本和收益排序:
方案一:增加(user_id, create_time)联合索引,把“按用户+时间排序”这个场景覆盖掉。
ALTER TABLE `order` ADD KEY `idx_user_create` (`user_id`, `create_time`);这样WHERE user_id = ? ORDER BY create_time DESC既能用user_id精确定位,又能直接利用create_time的索引顺序,避免filesort。
经过实测,加了联合索引后,Extra列不再出现Using filesort,单次查询耗时从秒级降到百毫秒级,后面再配合延迟关联处理深分页,效果更明显。这是收益最高的改动。
方案二:把LIMIT 5000, 20这种深分页改成游标分页(Keyset Pagination)。
由于运营后台主要还是按时间倒序浏览订单,并不需要真正跳转到任意页,只是按顺序翻页,完全可以用WHERE create_time < 上次最后一条记录的时间来替代 offset。这样每次查询不再需要丢弃前几千条数据,直接定位到目标时间点附近。如果产品不能接受没有跳页功能,可以做“前N页用普通分页,超过N页后自动转换为游标分页”的折中方案。
方案三:如果运营需求是按时间范围精确过滤,可以在页面上增加时间筛选器,强制用户选择日期范围。
这能从根本上限制扫描行数,避免运营一上来就查全量历史订单。说实话,很多慢查询问题,与其硬扛SQL,不如和业务方聊一下使用场景,把查询范围收窄。
三条方案落地之后,这条接口的响应时间从头部的3秒多降到了200ms以内。整个优化过程并没有用到什么“神奇”的索引技巧,核心就是把“索引高效”和“业务查询模型合理”两点做扎实。
我在这个案例里最想提醒大家的是:索引优化不是加完索引就完事了,它需要和业务语义、查询条件、分页策略放在一起通盘考虑。这恰恰是面试官问“索引生效为什么还慢”时希望听到的思维深度。
6. 面试答题策略与个人心得
文章最后这个部分,我把它当成一个过来人的私货分享,专门写给正在准备面试的人,以及那些已经在一线写SQL但始终对慢查询定位没什么章法的朋友。
6.1 一套高通过率的回答框架
如果面试官问“明明加了索引,查询为什么还是慢”,我建议你按照“从现象到本质、从执行计划到存储引擎”的结构来答,而不是一上来就抛一大堆名词。可以参考下面这个思路:
- 先明确:索引生效不等于索引高效。我会先让面试官知道,我理解这两者的区别,接下来重点分析“索引高效”可能被什么因素破坏。
- 第一层,查执行计划。用
EXPLAIN看type、key、key_len、rows、Extra,先排除索引没走对的情况。 - 第二层,分析扫描行数。如果
rows很大,就要看这个索引的基数够不够高、过滤条件是不是落到了单个用户或单个状态这种“数据量天然大”的情况。这时候大概率会引出覆盖索引和索引下推的优化。 - 第三层,分析回表成本。如果查询字段很多,需要大量回表,就考虑覆盖索引、延迟关联。
- 第四层,分析优化器自身的判断。看统计信息是否过期,用
EXPLAIN ANALYZE对比预估行数和实际行数,用optimizer_trace查看优化器的选择依据。 - 第五层,分析隐性因素。是不是排序导致
filesort,是不是锁等待,是不是事务版本链过长,这些都要纳入考虑。
这套框架既能展示扎实的底层原理,又能体现你面对线上问题的排查能力。面试官很难挑出硬伤。
6.2 我在实际调优中踩过的坑
一些个人经验和踩坑记录,也分享出来。这些内容在教科书里很难看到,但对实际工作非常有帮助。
第一个坑:优化索引前忘记看数据分布。有一次我认为某个联合索引一定能大幅提升查询性能,结果加完索引之后查询效果没有明显改善。后来一查才发现,这个表里大量行的状态都是同一个值,索引的区分度极低,怎么加都没用。从那之后,我每次建索引前都会先跑几条SELECT COUNT(*) ... GROUP BY看看数据分布,确认这个字段真的能把数据快速过滤出来。
第二个坑:过度依赖覆盖索引导致索引膨胀。为了让更多查询走覆盖索引,我把很多字段都塞进了索引。结果索引本身变得很宽,占用的磁盘空间和内存缓存大幅增加,写入性能也受到了影响。后来我学会了克制:只有查询频率极高、性能瓶颈明确的情况下才考虑覆盖索引,而且要控制索引字段数量。
第三个坑:忽略业务高峰期的并发放大效应。有些查询单次执行只要几十毫秒,看似不慢,但在高峰期被并发调用几百次,数据库整体负载就上去了。排查慢查询时需要结合监控看整体吞吐量和连接数,不能只看单条SQL。这也提醒我们,优化不只是为了单次查询的速度,更是为了降低整体资源消耗。
第四个坑:统计信息更新本身也可能引发执行计划抖动。ANALYZE TABLE之后,优化器可能因为统计信息变化而选择完全不同的执行计划,有概率是变差而不是变好。所以线上操作不要贸然全表ANALYZE,可以先在只读副本上验证一下执行计划变化。
6.3 最后分享一个特别有用的小技巧
文章最后再分享一个小技巧。排查慢SQL时,我通常会把这次高频用到的几个监控命令整理成自己的“体检脚本”,每次新接手一个系统都会先跑一遍:
-- 查看当前正在执行的SQL SELECT * FROM information_schema.processlist WHERE command != 'Sleep' ORDER BY time DESC LIMIT 10; -- 查看慢查询日志状态和阈值 SHOW VARIABLES LIKE 'slow_query_log%'; SHOW VARIABLES LIKE 'long_query_time%'; -- 查看总连接数和当前活跃连接 SHOW STATUS LIKE 'Threads_connected'; SHOW STATUS LIKE 'Threads_running'; -- 查看是否堆积了大量事务或锁 SELECT * FROM information_schema.innodb_trx ORDER BY trx_started LIMIT 20;这组命令在MySQL 5.7和8.0上都能用,信息密度高、执行成本低,非常适合线上巡检。你先判断当前服务器是不是健康状态,再来定位慢SQL,思路会清晰很多。
回到最开始那个问题:一块索引,其实是数据库系统里一个小小的“有序数据结构”,但它背后牵扯着存储引擎、优化器、统计信息、并发控制、业务查询模型非常多层面的机制。能真正讲清楚“加了索引为什么还慢”的人,要的不是背诵能力,而是对整个数据库系统运行方式的理解深度。这也是为什么大厂面试官偏爱这个问题的原因——它像一个入口,能快速探测出候选人到底是在背题,还是真的在跟数据库系统打交道。
我每次帮团队复盘这种线上问题,最后都会重复同一句话:加索引只是优化开始的第一步,不是终点。真正的优化往往发生在你理解了数据的分布、理解了查询的模式、理解了存储引擎的行为之后。希望这篇文章能帮你建立起一套完整的、能应对面试又能落地实战的思考框架。