☰
慢查询优化实战:从索引策略到数据分布,千万级订单查询复盘
2026/10/13 4:29:51 网站建设 项目流程

1. 先别急着加索引:慢查询背后藏着的三层真相

1.1 慢SQL三分靠看,七分靠查:如何绕过表象定位真凶

我接手过不少“慢查询优化”的活儿,很多开发同学的第一反应都是“加索引”。但说句实在话,索引只是优化链条里的最后一环,甚至经常不是最关键的那一环。手上一张千万级的数据表,一条关联查询跑了十几秒,你噼里啪啦加完索引,结果发现执行时间只降了10%,那种挫败感我太熟悉了。

为什么会这样?因为一条SQL慢下来,表象是“执行时间长”,但背后通常是三层问题的叠加:第一层是语句本身写得有问题,导致优化器根本没机会利用索引;第二层是表和索引的设计与数据分布不匹配,索引建了,选择性却低得可怜;第三层才是真正的索引缺失或失效。这三层问题不拆开,你加索引就是盲人摸象。

我建议的排查顺序是固定的:先看执行计划,再看表统计信息,然后做最小化实验,最后才动手改索引。执行计划里最关键的几个字段——type、rows、filtered、Extra——能直接告诉你优化器打算怎么干活。比如type是ALL,说明全表扫描,这时候再考虑索引;type是ref或者range,说明索引其实走了,但rows很大,这时候问题可能在索引选择性上,而不是索引存在性上。很多人在第一步就判断错了方向。

1.2 数据分布、基数与统计信息:索引判断的第一道门槛

索引有没有效,从根上讲取决于基数(Cardinality)。说白了就是某一列上不同值的数量。性别列只有“男”“女”两个值,基数就是2,你给这种列建索引,大多数情况下优化器会直接无视它,因为即使走索引也要回表查一半的数据,还不如全表扫描来得痛快。这个过程优化器是精打细算过的,它依赖的是统计信息,而不是你以为的“这个字段经常在WHERE里出现”。

但统计信息有一个容易被忽略的坑:它不是实时更新的。MySQL里的InnoDB引擎,统计信息的更新受innodb_stats_auto_recalc和innodb_stats_persistent控制,默认情况下,表数据变化超过一定比例才会自动重新计算。如果你刚导入了一批数据,或者删掉了一大批旧数据,统计信息可能还停留在老状态,优化器基于过期统计信息做出的判断自然不准。遇到这种情况,ANALYZE TABLE跑一下,很多诡异的不走索引问题当场就解决了。

实操里我还会额外看一个东西:字段的NULL值占比和重复值分布。完全均匀分布的数据,索引效果最好;但现实中的数据往往是倾斜的,比如订单表里的状态字段,90%都是“已完成”,你查那10%的“处理中”时索引可能还有点用,查“已完成”时优化器大概率选择全表扫描。所以建索引之前,先跑一条分组统计看看数据长什么样,再决定策略,这个习惯能帮你少走很多弯路。

1.3 为什么“看着该走索引”却没走:优化器决策里的隐形成本

还有一种特别迷惑人的情况:索引明明存在,条件也符合最左前缀,但优化器就是不走。我在处理一个用户中心的慢查询时就遇到过,WHERE user_id = 12345 AND status = 1,组合索引(user_id, status)也建得好好的,但执行计划显示全表扫描。

后来查下去才发现,问题出在user_id列的类型是VARCHAR,而传入的参数是整数。虽然在SQL里写WHERE user_id = 12345看起来毫无问题,但MySQL会做隐式类型转换,把字符串列转成数字再比较。这个转换导致索引失效。这就是典型的“看着该走索引,实际优化器在帮你擦屁股”。在调优的时候,我会习惯性地把这条SQL的字段类型和参数类型逐一对一遍,凡是条件两边类型不一致的,先改掉再说,因为隐式转换带来的索引失效是最容易被忽略、也最容易修的一类问题。


2. 索引策略的底层博弈:从B+树到覆盖索引再到冗余取舍

2.1 B+树不是用来背的:聚簇索引与二级索引的读写代价

很多人面试时B+树背得滚瓜烂熟,但实际建索引时完全用不上。B+树这套结构,抽象出来就两句话:数据按序排列,查询走树路径。对于InnoDB来说,整张表的数据本身就是一个B+树,主键索引就是聚簇索引,叶子节点存的是整行数据;其他索引叫二级索引,叶子节点存的是主键值。

这两者的差异直接决定了读写代价。走二级索引查一条记录,需要先在二级索引的B+树里找到主键,然后拿着主键再到聚簇索引的B+树里回表,取出完整行。一次回表就是一次随机IO。你建了索引,命中率很高,但回表次数一多,查询照样快不起来。所以,性能突破的关键不一定在于“建了多少索引”,而在于“能不能减少回表”。

引申出来的一个实用原则是:主键设计要考虑写入代价。如果你用一个超长的UUID作为主键,二级索引里存的每个主键值都很长,索引体积膨胀,内存里能放下的索引页变少,IO次数自然增加。而且UUID无序,插入时B+树频繁分裂,写放大非常明显。现在的新项目我倾向于用自增主键或有序的雪花ID,不是不能接受UUID,而是要让索引这棵树的叶子节点尽量保持顺序写入。

2.2 最左前缀的失效现场:组合索引设计中的顺序陷阱

组合索引的核心是最左前缀原则,这个大家都听过,但真正设计时很容易掉进顺序陷阱。最常见的错误有两种:一是把区分度最高的列放前面,二是只考虑查询条件不考虑排序和分组。

先看第一种。WHERE a = ? AND b = ?,如果a列的基数只有几十,b列每种值对应几万行,把a放前面就意味着索引第一层就过滤不掉多少数据,优化器会认为这个索引没什么用。正确的做法通常是让过滤能力强的列靠左。但注意,这不绝对,如果条件里有范围查询,比如a > 1000 AND b = ?,那a放前面会让b的索引完全派不上用场,因为范围右边的列无法用于定位。这种情况我会反过来把等值条件b放前面。

第二种分布更隐蔽。一个报表查询,条件只有WHERE create_time >= ?,但ORDER BY user_id LIMIT 10,如果你单独建(create_time)索引,MySQL只能先查出满足时间条件的所有数据,再做一次排序,行数一多就是filesort。但如果把索引设计成(create_time, user_id),因为B+树本身就是有序结构,ORDER BY user_id在时间等值的情况下可以直接利用索引顺序,省掉排序,这对深分页场景收益极大。我见过太多人只盯着WHERE,完全忘了ORDER BY,结果SQL改了索引也没用。

2.3 覆盖索引、索引下推与回表:用一次查询说清三种优化手段

用一个实际场景把这三个概念串起来。假设有一张订单表,SQL是:

SELECT order_id, status, pay_time FROM order_record WHERE user_id = 12345 AND status = 'PAID';

如果只有一个(user_id, status)的二级索引,那么MySQL先通过索引找到匹配的主键,再回表三次拿到order_id、status、pay_time。回表次数取决于匹配行数,行数越多,IO越多。改进方式有两种。

第一种叫覆盖索引,把索引扩展成(user_id, status, order_id, pay_time)。因为InnoDB的二级索引叶子节点包含了索引定义的所有列,查询需要的字段都在索引里,MySQL就不需要回表了,直接扫索引就能返回结果。对于高频查询,这种“用空间换一次随机IO”的代价非常划算。

第二种叫索引下推(Index Condition Pushdown,ICP)。如果只保留(user_id, status)索引,但SQL里status本来就是索引的一部分,MySQL在索引遍历过程中就会先过滤status,而不是把每一条满足user_id的记录都回表后再过滤。5.6以后默认开启ICP,很多人可能没注意过它的存在,但它确实在默默减少回表次数。

实操中我会用EXPLAIN的Extra列来确认:看到Using index说明走了覆盖索引,看到Using index condition说明走了ICP,两者都是好事。如果看到Using where,说明索引只负责定位了一部分,剩下的过滤动作在做回表之后才发生,这时候就要考虑能不能通过调整索引或改写SQL,把过滤条件推给索引。

2.4 索引冗余的代价:写放大与维护成本如何量化

索引不是越多越好,这是我反复强调的一条。每一行数据插入的时候,所有二级索引都要同步维护一份;每次更新,如果涉及索引列,B+树还要做节点分裂和结构调整。换句话说,写操作的成本会随着索引数量成倍增长。很多时候你只看到了查询变快了,没看到写入变慢了、锁等待变多了、binlog变大了。

量化一个“冗余索引”其实很简单。假设已有索引(a, b, c),你再建一个(a, b),后者就是前者的最左前缀,完全冗余。工具上可以用pt-duplicate-key-checker扫描,但就算没有工具,手动看一下SHOW INDEX FROM table也能分辨。我的原则是:能复用的前缀绝不复建,宁可把索引设计得宽一点,也不要为单独查询堆一堆重复前缀的小索引。索引占用的不只是磁盘,更是内存缓冲池的宝贵空间,冗余索引会让整个实例的热点命中率下降,最后所有查询一起变慢。


3. 查询重写的实战套路:把逻辑拆到优化器喜欢的形状

3.1 隐式类型转换:看似无害的条件如何干掉索引

前面提到了一次隐式类型转换,这里展开说说。MySQL的规则是,如果一个字符串列和一个数字比较,它会尝试把字符串转成数字。这个大坑的影响面非常广,不仅仅是WHERE条件,JOIN的关联条件也一样。我曾经优化过一个数仓抽数任务,两个表关联字段一个VARCHAR一个BIGINT,结果整个任务跑了40分钟,改成统一类型后直接缩到4分钟。

同类的问题还包括日期比较。比如你在一个DATETIME字段上做WHERE create_time = '2024-01-01',MySQL会把字符串隐式转成日期,这个本身不是大问题。但如果你写的是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'

这个改写不需要函数参与,索引能正常命中。类似的还有WHERE YEAR(create_time) = 2024,也应该改写成上述形式。总结起来就一句话:索引列上尽量不要做任何运算,不管是隐式的还是显式的,都要把运算挪到参数一侧。

3.2 OR/IN/EXISTS/JOIN的取舍逻辑与半连接改写

很多人纠结OR和IN要不要改写,EXISTS和JOIN哪个快。其实在MySQL 8.0之前,IN和EXISTS的优化策略差异很大,但8.0引入了半连接(SEMI JOIN)优化,很多情况会自动转换。真正需要人工干预的,是一些极端场景。

先说OR。WHERE a = 1 OR b = 2在a、b各自有索引的情况下,MySQL可能选择Index Merge,也可能选择全表扫描。一个比较稳妥的做法是拆成UNION ALL,让每一段SQL各自走索引,再合并结果。如果两段结果集有交集,再考虑用UNION去重。但这种改写的收益依赖数据分布,如果每个条件都能过滤掉大部分数据,收益明显;如果条件本身就很宽泛,改写也没意义。

再说子查询和JOIN。我见过大量把JOIN改写成IN子查询的习惯,试图“减少连接次数”。但在MySQL里,JOIN和IN的优化路径并不一样。5.6之前相关子查询的执行方式是逐行驱动,效率极差,所以老一辈开发者总结出“能JOIN就JOIN,别用IN”的经验。到了8.0,子查询会被自动改造为半连接,性能差距缩小,但有些边缘场景还是会出幺蛾子。我的建议是:先在执行计划里确认有没有出现<materialize>或者dependent这些关键词。dependent表示相关子查询,往往是性能重灾区;看到这个,果断改写成临时表或JOIN。

3.3 分页深翻页问题:延迟关联与游标分页的取舍

分页是每个业务系统都绕不开的点,也是SQL优化里最容易被低估的一环。ORDER BY id LIMIT 10000, 20这种写法,看着简单,实际MySQL要先把前10020行取出来排序,然后丢掉前10000行。数据量越大,越往后翻,这个“丢弃”的成本越高。等到OFFSET到了几十万,查询时间飙到秒级完全不意外。

一个有效的套路是延迟关联。所谓延迟关联,就是先用覆盖索引快速定位到目标行的主键ID,再通过ID去关联原表获取完整数据:

SELECT o.* FROM order_record o INNER JOIN ( SELECT id FROM order_record WHERE user_type = 1 ORDER BY create_time DESC LIMIT 10000, 20 ) t ON o.id = t.id;

内层查询只扫二级索引,不需要回表,跳过的行数再多,成本也远低于外层直接扫全表。这个改写我在多个项目里都用过,深分页场景收益通常是数量级的。

但如果产品本身是无限下拉流或者“上一页下一页”的模式,更好的方案是游标分页。比如记录上一页最后一条数据的create_time和id,下一页直接WHERE create_time < ? OR (create_time = ? AND id < ?) ORDER BY create_time DESC, id DESC LIMIT 20。这样不管翻多深,扫描范围始终只有20条。业务改造量稍大,但性能上限高得多,而且不会因为中间插了数据导致分页错乱。我的习惯是:一次性列表用延迟关联,滚动加载用游标分页。

3.4 聚合与排序的优化空间:临时文件和filesort的避让

涉及GROUP BY、ORDER BY、DISTINCT的查询,如果执行计划里的Extra列出现了Using temporary或Using filesort,就要警惕了。这两个词都不一定致命,但都意味着MySQL需要额外的内存或磁盘空间来完成计算。

Using filesort不是真的用文件排序,更准确的说法是“无法利用索引顺序完成排序,需要额外的排序操作”。排序数据量小,在内存里排;数据量大,会在tmpdir里建临时文件,产生磁盘IO。优化方向很明确:让排序字段参与索引,而且排序顺序要和索引完全一致。比如索引是(a, b),你用ORDER BY a ASC, b ASC就能直接利用索引顺序;如果你写ORDER BY b ASC, a ASC,顺序不匹配,优化器只能filesort。

GROUP BY同理。GROUP BY a, b和索引(a, b)顺序一致时,MySQL可以边扫边分组,不需要临时表。另外,很多人不知道MAX()、MIN()在索引前缀上可以直接取两端值,根本不需要扫全表。比如SELECT MAX(id) FROM order_record,InnoDB直接读取索引最右侧叶子节点,执行计划秒回。这种小细节在实际优化中经常能带来惊喜。


4. 执行计划与性能验证:用EXPLAIN之外的手段逼近真相

4.1 EXPLAIN到底在看什么:type、rows、Extra的读法

执行计划是SQL优化的基础,但很多人查了执行计划等于没查。EXPLAIN SELECT...的输出里,我第一眼看type。从好到坏大致是:system、const、eq_ref、ref、range、index、ALL。前面几种都算不错的访问方式,到了index说明在扫全索引,到了ALL就是全表扫描。一个合格的优化目标,是把关键查询的type至少推到range以上。

第二眼看rows,这是优化器估算的需要扫描的行数。注意,是估算值,不是真实值。如果rows和实际数据量差距过大,十有八九是统计信息过期了。这时候先ANALYZE TABLE再看计划,通常比直接改SQL更有效。

第三眼看Extra。这里的信息密度最高:Using where表示在存储引擎层过滤完之后,Server层又做了一次过滤;Using index表示覆盖索引;Using index condition表示ICP;Using temporary和Using filesort就是前面提到的问题信号;Using join buffer则说明连接过程没有合适的索引,需要用join buffer来存中间结果。这每一项都是下一步优化方向的直接线索。拿到执行计划先别急着改SQL,把Extra里出现的每一个关键词查明白,已经能解决一半问题。

4.2 optimizer trace:优化器为什么不听你的

有一种让人血压飙升的情况:你做了一个改写,理论上规划得很好,但执行计划还是老样子。这时候EXPLAIN看不出优化器的心理活动,得请出隐藏工具optimizer trace。在MySQL中,你可以针对当前会话开启优化器追踪:

SET optimizer_trace = 'enabled=on'; SELECT * FROM order_record WHERE user_id = 12345 ORDER BY create_time DESC LIMIT 10; SELECT * FROM information_schema.OPTIMIZER_TRACE\G

输出里会详细记录优化器每一步的思考过程,包括它考虑了哪些索引、为什么淘汰了某个索引、最终选择了什么执行方案。我在一次真实调优中发现,优化器拒绝某个索引的原因竟然是回表成本估算高于全表扫描,而这个估算偏差来自主键长度过大。看到这份trace,我立刻把主键从UUID改成了自增ID,SQL一行没动,查询时间从2秒降到了80毫秒。没有trace的话,我可能还在纠结索引结构怎么改。

optimizer trace的缺点是信息量大,一张几千行的JSON不是人读的。实际会搜关键词:"considered_access_paths"看索引候选,"cause"看放弃原因,"rows_estimation"看行数估算。这几个节点基本能覆盖大部分定位需求。生产环境不建议长期开启trace,但针对一条慢SQL开一个会话,得到结果后立刻关掉,是完全没有问题的。

4.3 线上压测与火焰图:从单条SQL到系统吞吐的放大镜

单条SQL优化完成,不代表系统就快了。数据库瓶颈往往不在某一条SQL上,而在多条SQL叠加出来的锁竞争、IO排队和网络延迟上。所以我把优化流程的最后一环放在压测和监控上。工具上,sysbench和jmeter都能做压测,但更贴近业务的做法是直接抓生产环境或预发环境的流量回放,观察优化前后的吞吐和延迟分位数变化。

如果怀疑SQL已经引发了系统层面的CPU或IO问题,我会看火焰图。生成火焰图的方式很多,perf record配合FlameGraph脚本是常见一套。但注意,数据库实例的火焰图分析要比应用复杂,因为MySQL内部线程多且状态机复杂,我不会一上来就分析函数级细节,而是先看整体CPU使用率分布在哪个子系统,是排序、是临时表创建、还是行锁等待。如果火焰图里大面积出现与排序相关的函数,那就说明还有大批量filesort在作祟,需要回到SQL和索引层面再排查一轮。

这里有一个我自己常用的经验:不要只看95分位延迟,要看99分位和最大延迟。慢SQL对系统的影响往往是“尾部延迟”被拖垮,平均延迟看起来还行,但每隔一段时间就卡一下,这种间歇性抖动最容易被人忽视。通过监控工具把这些尾部请求一一揪出来,结合慢查询日志时间线,通常能精准定位是哪条SQL在特定数据分布下变成了定时炸弹。


5. 一次近千万级订单查询的完整优化复盘

5.1 业务场景与原始SQL:问题出现在哪里

模拟项目X是一个订单管理系统,订单表order_record数据量在850万左右。业务上有一个核心查询,用户在后台按商户、时间范围和状态筛选订单,前端要做列表分页。原始SQL大致长这样:

SELECT * FROM order_record WHERE merchant_id = 10086 AND status IN ('PAID', 'REFUNDING') AND create_time BETWEEN '2024-01-01' AND '2024-06-30' ORDER BY create_time DESC LIMIT 200, 20;

生产环境已经有一个索引idx_merchant_status_time(merchant_id, status, create_time),但查询时间平均在4.5秒,高峰期接近8秒,后端接口超时频繁。一开始团队的反应是“索引都有了啊,为什么还慢”,于是先把SQL拿到测试库一顿EXPLAIN。

执行计划显示type为range,rows估算接近30万,Extra里有Using filesort。三个信息一组合,问题就清楚了:虽然索引用上了,但status条件是IN,优化器只能利用索引到merchant_id这一层,然后对status做范围扫描,扫描范围覆盖了三个状态里所有符合条件的记录,总量非常大。并且因为create_time也在索引里,理论上排序可以直接利用,但IN导致索引连续扫描的顺序没办法严格等于create_time顺序,于是走了filesort。

5.2 从索引调整到查询改写:每一步改动与收益对照

第一版改动,把现有索引调整为idx_merchant_time(merchant_id, create_time)。为什么去掉status?因为status的IN条件本身就破坏了最左前缀的等值定位能力,把它放在中间既浪费空间,又让排序优化无从谈起。去掉之后,索引的等值前缀只有merchant_id,create_time作为范围条件继续参与定位,同时ORDER BY create_time DESC可以完全利用索引顺序,Using filesort消失。这一版上线后,查询时间降到约1.2秒。

第二版改动,改造分页。上面只解决了排序问题,但LIMIT 200, 20还在,MySQL仍然要扫前220条之前的记录。改成延迟关联写法:

SELECT o.* FROM order_record o INNER JOIN ( SELECT id FROM order_record WHERE merchant_id = 10086 AND create_time BETWEEN '2024-01-01' AND '2024-06-30' ORDER BY create_time DESC LIMIT 200, 20 ) t ON o.id = t.id;

内层查询只需要扫描二级索引,每行只取id和索引列,不需要回表,再配合idx_merchant_time的有序性,排序成本大幅降低。这一版上线后,查询时间降到了180毫秒左右,收益接近数量级。

第三版微调,处理业务侧的筛选变化。后台还存在“只看退款中”的筛选场景,这种场景下status其实是单一等值条件。我在模块化SQL时单独为这类场景保留了一个(merchant_id, status, create_time)索引。听起来有点打脸,但这是合理的冗余:两种查询模式分别使用不同的索引,各取所需,代价只是额外一份索引的写入成本。换来的收益是每种模式都能走最精准的执行路径。

5.3 常见的优化误区与我的取舍经验

复盘这次优化,有几个误区值得单独拎出来说。第一个误区是“索引覆盖的字段越多越好”。我把SELECT *改成延迟关联后,没有顺手再把查询列塞进索引。因为你一旦把所有需要的字段都塞进二级索引,索引体积膨胀,缓冲池命中率下降,整体收益反而可能是负的。覆盖索引适合高频、固定、小字段集的查询,不适合SELECT *这种宽查询。

第二个误区是“执行计划变好就代表性能变好”。第一版改动后执行计划确实漂亮了,但线上实测仍然有1秒左右的延迟,因为大分页问题还在。SQL优化必须以线上响应时间为准,不能只看EXPLAIN的输出。每次改完,我都会在测试环境用相同的数据分布跑一遍真实查询,记录耗时,再在预发环境验证一轮,最后才动生产。

第三个误区是“不敢动索引,怕影响其他查询”。这种谨慎是对的,但不能因噎废食。正确做法是通过information_schema查询索引使用情况,看看哪些索引的index_name在慢查询日志里根本没出现过,或者从performance_schema的表table_io_waits_summary_by_index_usage里看每个索引的统计。数据会告诉你,哪个索引该留,哪个索引纯属心理安慰。


从索引策略到查询性能,这条链路里真正值钱的不是某一招,而是一套稳定的排查方法:先理解数据分布,再读懂执行计划,最后用实验验证每一步收益。我自己踩过不少坑,最深的一个体会是:索引是手段,性能是结果,但中间隔着一个叫作“理解你正在查询的数据”的鸿沟。跨越这个鸿沟,你才能从“加索引碰运气”进化到“每一次改动都有明确预期”。

如果你最近也被一条慢SQL折磨得焦头烂额,不妨先把EXPLAIN结果贴出来,对照着rows和Extra逐项分析,大概率能找到比加索引更快的解法。等到你开始习惯用optimizer trace和压测数据来验证每一次判断时,你就会发现,SQL优化不是玄学,而是一道可以稳定复现的数学题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询