从一次慢查询说起:读懂最左前缀、覆盖索引与回表
很多慢查询并不是因为数据量突然变大,而是因为查询没有走上预期的索引,或者走上了索引却又做了大量额外工作。本文用一张订单表作为例子,讲清楚排查时最常遇到的三个概念:最左前缀、覆盖索引与回表,并给出可以直接照着做的检查步骤。文中讨论的是 InnoDB 存储引擎下常见的行为,不同版本与配置可能存在细节差异,结论以实际执行计划为准。
一、先从执行计划看,而不是先加索引
遇到慢查询,第一步通常不是马上加索引,而是先看执行计划。执行计划会告诉我们优化器打算用哪个索引、大约要扫描多少行,以及有没有额外的排序或临时表。
EXPLAIN SELECT id, status, created_at
FROM orders
WHERE user_id = 1001 AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;
重点看几个字段。type 描述访问方式,从好到差大致是 const、ref、range,再往后是 index 和 ALL,其中 ALL 意味着全表扫描,需要重点关注。key 是实际使用的索引,rows 是优化器的估算行数,它只是估算,不是精确值,但在对比改动前后时很有参考意义。Extra 里如果出现 Using filesort,说明需要额外排序;出现 Using temporary,说明需要临时表;出现 Using index,则说明查询可以只用索引完成,不需要回表。
假设订单表是下面这样的结构,并建了一个联合索引:
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
created_at DATETIME NOT NULL,
INDEX idx_user_status_time (user_id, status, created_at)
);
有了这张表,就可以具体讨论索引列的顺序为什么重要。
二、最左前缀:索引列的顺序决定了能用上几列
联合索引可以理解为先按第一列排序,在第一列相同的情况下再按第二列排序,依此类推。最左前缀原则的意思是,查询条件要从索引的最左列开始连续使用,中间不能跳过某一列,否则跳过之后的列就很难再用于索引定位。
上面的索引是 user_id、status、created_at。查询同时给出 user_id 和 status 的等值条件时,两列都可以用于索引定位;再加上按 created_at 排序时,排序也可能直接利用索引顺序,避免额外排序。
如果查询只给出 status,不给 user_id,优化器就无法从索引的最左列开始定位,通常只能全表扫描,或者在其他条件允许时选择别的索引。只给出 user_id 和 created_at,跳过 status 时,user_id 仍然可以用于定位,但 created_at 一般只能用于过滤,而不能像连续使用时那样用于精确定位。这并不意味着 created_at 的条件完全没用,而是它的作用从定位退化为过滤,扫描范围会变大。
范围条件也有类似的截断作用。当某一列使用了范围条件,例如 created_at 在某个时间段内,那么排在它后面的索引列通常就不能再用于索引定位,只能在取到的数据里继续过滤。因此,把经常用于等值查询的列放在前面,把范围查询的列放在后面,往往是更稳妥的顺序。还有一种常见情况是对索引列使用函数,例如对 created_at 做日期截取后再比较,这会让优化器难以直接使用索引。同样,查询值与列类型不一致时发生的隐式类型转换,也可能让索引失效。遇到这类问题,先检查条件是否直接作用在列本身,而不是作用在表达式的结果上。
三、回表与覆盖索引:少取一列,有时差别很大
在 InnoDB 中,二级索引的叶子节点保存的是索引列的值和主键值,而不是整行数据。如果查询需要的列都在索引里,数据库可以直接从索引中得到结果,这就是覆盖索引,执行计划的 Extra 会显示 Using index。如果查询还需要索引之外的列,就要拿主键再回到主键索引里找整行,这个过程就是回表。
回表本身不是错误,点查少量数据时回表的代价很小。问题出现在需要回表的行数很多时:每一次回表都可能是一次随机读,行数一多,总体代价就可能超过直接扫描。优化器在这种情况下有时会干脆选择全表扫描,这也是为什么同样一条查询在数据量小时走索引、数据量大时却不走索引的原因之一。
一个直观的对比是:只查索引里已有的列时,查询可能完全不需要回表;而改成 SELECT 星号,把表中所有列都取出来时,就几乎一定需要回表。实际开发中,列表接口常常只需要少数几个字段,却习惯性地取出全部字段,这就把一次本可以覆盖索引的查询变成了大量回表。把查询改成只取需要的列,既减少了回表,也减少了网络传输的数据量。
覆盖索引也不是越多越好。把很多列都塞进索引,会让索引变宽、写入变慢、占用更多空间,维护成本也会上升。合理的做法是:为高频查询里真正需要的列建覆盖索引,而不是为了覆盖所有查询去建一个无所不包的索引。
四、排序与分页:容易被忽略的第二类代价
当 ORDER BY 的列与索引顺序一致,并且前面的等值条件已经把范围限定住时,排序可以直接利用索引的有序性,不需要额外排序。如果索引顺序与排序要求不一致,数据库就需要把结果取出来再排序,执行计划里会出现 Using filesort。结果集很小时,额外排序的代价并不明显;结果集很大时,排序就可能成为主要开销。
深分页也有类似的问题。LIMIT 加上很大的偏移量时,数据库仍然要先扫描并丢弃前面的行,只返回最后几行。偏移量越大,浪费的工作越多。一种更常用的替代做法是键集分页,也就是记住上一页最后一条记录的排序键,下一页用大于该键的条件继续取数。这种做法要求排序键稳定且有合适的索引支持,不能生搬硬套到所有分页场景,但在按时间或自增主键顺序翻页的列表里效果通常很好。
五、可以照着做的排查清单
第一步,用执行计划确认 type、key、rows 和 Extra,先定位问题是全表扫描、回表太多,还是额外排序。第二步,对照最左前缀检查条件:是否从最左列开始连续使用,有没有跳列,有没有范围条件截断后面的列,有没有在索引列上使用函数或发生隐式转换。第三步,检查 SELECT 列表:只取需要的列,看看能否变成覆盖索引;如果必须回表,评估回表的行数是否在可接受范围。第四步,检查排序与分页:排序是否能利用索引顺序,深分页是否可以改成键集分页。
如果需要调整索引,先想清楚查询模式再动手。把区分度高、经常用于等值条件的列放在前面,把范围列放在后面;一次只改一个因素,改完重新看执行计划,对比 rows 与 Extra 的变化。验证时尽量使用与生产接近的数据量和数据分布,在只有几行数据的空表上,执行计划的结论往往没有参考价值。索引调整涉及写入与存储成本,上线前也需要在测试环境确认写入性能与已有查询没有明显退化。
六、小结
最左前缀回答的是索引能用上几列,覆盖索引回答的是能不能不回表,排序与分页回答的是拿到结果之后还要做多少额外工作。排查慢查询时,先看执行计划,再按这个顺序逐一核对,往往比凭感觉加索引更有效。把查询写清楚、只取需要的列、让条件与索引顺序保持一致,是大多数场景下投入最小、收益最稳定的做法。
- 点赞
- 收藏
- 关注作者
评论(0)