第一次接到线上慢SQL告警的时候我的第一反应和大多数人一样给查询字段加个索引。结果没过两天同一条SQL换了参数又慢回来甚至加上的索引开始拖慢写入。SQL优化真正靠谱的起点不是加索引而是先读懂执行计划。执行计划会告诉你当前SQL的访问路径、估算扫描行数、排序与回表等额外开销你才能基于这些信号反推索引策略而不是拍脑袋加一个感觉有用的索引。这篇文章是我在某订单系统、某物流平台做SQL优化时的实战复盘覆盖执行计划解读、联合索引与覆盖索引设计、两类线上案例的完整排查链路以及索引上线之后的维护边界。适合刚开始接触数据库调优的后端开发也适合所有索引明明加了还是很慢的排查者。看完不求你把优化器源码背下来只求以后拿到一条慢SQL心里有一条清晰、可复用的排查路径。1. 先别急着加索引把优化执行的链路走对1.1 慢SQL应急处理与系统性优化是两码事真实环境里一条SQL慢下来往往是监控告警先发现。业务还在等时间压力很大很多人会立刻打开客户端跑一遍EXPLAIN看到type列是ALL就说没走索引然后迅速建索引上线。这种应对在压力下可以理解但它跳过了关键确认当前SQL为什么没走索引走了索引之后真实代价是否真的下降我踩过一个很典型的坑。某订单查询的where条件里既有create_time的日期范围又有status的等值条件当时负责的同学直接给create_time建了单列索引。上线之后范围条件确实绕过了全表扫描但回表次数极高查询不但没变快反而从500ms左右涨到了900ms。原因很简单这个单列索引的选择性太差优化器通过它捞出一大批满足时间范围的记录再逐条回表过滤status代价甚至比直接全表扫描更大。这就是典型的没有先读执行计划就动手加索引。所以我的习惯是任何索引动作之前先把目标SQL的执行计划完整拉出来至少看懂四类信号——访问路径type列、扫描行数估算rows列、Extra列里的额外开销Using filesort、Using temporary、Using index等以及filtered过滤精度。这四个信号能回答这条SQL到底慢在哪一步之后才谈得上设计索引。完整的优化链路应该是定位慢SQL → 获取并解读执行计划 → 推断瓶颈 → 设计并验证索引 → 灰度上线 → 观察长期效果。任何一步跳过都可能白忙活甚至制造新问题。1.2 优化器、统计信息和执行计划的三角关系要真正会看执行计划还得先知道它是从哪来的。数据库收到一条SQL之后并不会老老实实按你写的语句顺序执行而是交给优化器生成多个候选执行方案再基于成本模型选出它认为开销最小的那个。成本模型依赖统计信息这张表有多少行、某个字段基数不同值的数量有多高、索引区分度如何、是不是有大量重复值等等。执行计划就是这一整套评估过程最终吐出来的结果。可以拿导航来类比。你从公司回家导航结合距离、红绿灯、实时路况推荐一条路线但它推荐的未必总是最优——假如路况数据过期它可能把你带进堵车路段。数据库也一样统计信息长期不更新、小表采样失真、或者线上数据分布和开发环境差异很大优化器就会选出错误的执行计划。这也是为什么经验丰富的DBA在批量导入数据之后会顺手执行一次ANALYZE TABLE目的就是让统计信息贴近真实数据避免优化器凭旧数据做判断。明白这层关系之后你会意识到加索引不等于让SQL一定变快。索引只是给优化器多提供了一条可选路径最终选不选它取决于成本模型的估算。同一张表数据分布变了、传入参数变了、关联顺序变了执行计划都可能跟着变。因此索引上线之前必须用多个参数、多组数据分布复测不能只测开发环境里那一条SQL就算完事。后面案例里我会反复提到这个点。2. 执行计划里的信号灯type、rows与Extra的联动阅读2.1 type列从ALL到const的性能梯度执行计划里最直观的信号是type列。它不是严格意义上的索引类型而是访问路径类型直接反映优化器打算怎么拿数据。下面这张表是我自己整理的习惯版本按性能从差到好排列type值含义典型场景性能判断ALL全表扫描无可用索引、选择性太差差数据量大时危险index全索引扫描遍历整个索引树比ALL略好但仍是大范围扫描range索引范围扫描等值、范围、IN条件命中索引可控需关注返回行数ref非唯一索引等值匹配单列索引或联合索引前缀等值良好eq_ref唯一索引等值匹配主键或唯一索引关联优秀const/system常量匹配主键或唯一索引查单行最优日常排查90%的情况盯住ALL、index、range、ref这几个值就够。看到ALL先别慌小表全表扫描未必是问题比如一张只有几千行的配置表全扫反而比走索引更快只有当大表上出现ALL同时rows估算很大才是必须处理的信号。真正需要警惕的是大表 ALL 大rows这三件套同时出现。另外很多人忽略这一点type列显示的是当前这一步最高能用的访问方式不代表每行都会走最优路径。比如join场景里驱动表扫描一次、被驱动表每次匹配都用reftype列会分别展示你要看的是被驱动表的访问方式而不是盯着驱动表自我安慰。2.2 rows与filtered估算值要用批判的眼光看rows列是优化器估算的扫描行数注意估算两个字。它不是实际扫描行数只是成本模型根据统计信息算出来的期望值。真实执行时实际行数可能和估算行数差一个甚至两个数量级尤其在数据分布不均匀、有大量重复值、或者统计信息过期的时候。filtered列表示经过where条件过滤后剩余行数占扫描行数的百分比。假设rows10000filtered10%意味着优化器预期最终返回1000行左右。如果filtered长期偏低说明这条查询在索引扫描之后还要做大量额外过滤这时要思考是不是联合索引的列顺序不对把区分度最高的条件放后面了是不是存在隐式类型转换导致索引用不上现在的主流数据库都支持更精确的执行信息查看方式。比如MySQL 8.0可以用EXPLAIN ANALYZE直接拿到实际执行时间和实际行数。我强烈建议优化关键SQL时不要只看普通EXPLAIN至少要结合一次真实执行版本做对照把估算和实际之间的偏差量出来偏差越大越说明统计信息或模型有问题。提示优化关键SQL时不要只看普通EXPLAIN建议配合EXPLAIN ANALYZE或实际执行信息把估算行数和实际行数的偏差量出来。2.3 Extra列隐藏开销大多藏在这里Extra列经常被扫一眼就略过但它往往才是慢的真相。下面几个关键词要刻进脑子里Using filesort需要额外排序。注意filesort不代表真的用了磁盘文件小数据量时在内存排序也显示这个值但它说明查询无法直接利用索引顺序必须付出额外排序成本。数据量大时这是灾难源头。Using temporary需要临时表常见于GROUP BY、DISTINCT、某些子查询和union场景往往伴随磁盘临时表性能极差。Using index覆盖索引生效查询所需字段都能从索引里取出不需要回表这是最理想的状态。Using where在存储引擎返回记录后还需要在Server层做条件过滤。配合filtered列看如果filtered很低说明大量行在Server层被过滤索引设计有优化空间。Using join buffer关联查询时被驱动表没有可用索引只能靠join buffer做内存匹配行数一大就是性能黑洞。我自己判断一条SQL是否真健康会先看它有没有Using filesort和Using temporary再看有没有覆盖索引的机会。很多慢SQL的真凶不是没走索引而是走完索引之后还要排序/建临时表。这类问题光加单列索引救不了需要把索引设计从命中where升级到覆盖排序和过滤。2.4 执行计划也会骗人统计失真与优化器选错执行计划不是圣旨。我在项目里见过不止一次优化器给出的计划明显不合理有可用索引不选、偏要全表扫描或者把小表当驱动表做大表的全表关联。原因通常出在统计信息上——数据从几万行涨到几千万行分布变了却没人重新收集统计信息。面对计划疑似错误的情况不要急着用hint去强制指定执行计划那是最后手段。正确的排查顺序应该是先检查统计信息是否过期再检查是否有新的索引可以用但优化器没考虑到最后检查SQL写法是否给优化器设置了障碍比如OR条件、函数包裹字段、隐式类型转换。只有这些原因都排除了才考虑用hint或改写SQL来纠正。hint的问题在于它把优化器的手脚绑死了数据量一变之前的最优计划可能变成最差计划副作用藏得很深。3. 从执行计划反推索引策略联合索引与覆盖索引的设计逻辑3.1 联合索引列顺序等值条件优先排序和范围靠后很多人知道联合索引要遵循最左前缀原则但列顺序排错的情况依然大量发生。最左前缀说的是联合索引按列的顺序从左到右匹配查询里必须命中最左边的列才能继续用后面的列。比如(a,b,c)联合索引能匹配a、ab、abc但不能跳过a直接用b或c。那列顺序怎么排我提供一个实战经验等值条件永远优先。凡是where里用等值命中的列放在联合索引最前面其次是高频过滤列再考虑把排序字段和范围字段放到最后。因为等值条件下定位精度最高放在前面能让后续的范围和排序更精准地命中索引。举个具体的例子。订单表orders有user_id、status、create_time三个常用查询条件典型SQL是SELECT order_id, order_no, amount, create_time FROM orders WHERE user_id 123456 AND status 1 ORDER BY create_time DESC LIMIT 20;这种场景单列索引user_id虽然能命中where但排序还是得filesort。正确的联合索引应该建(user_id, status, create_time)前两列走等值过滤第三列直接提供排序顺序。这样一条SQL既不需要filesort也不需要回表过滤太多行。如果status的区分度实在太低比如90%都是1可以考虑把它拿掉索引改成(user_id, create_time)避免在索引里塞一个几乎不提供过滤能力的列浪费空间——这就属于需要结合真实数据分布做取舍的细节不能死记套路。3.2 覆盖索引与回表差距往往藏在一两个字段里InnoDB的主键索引是聚簇索引叶子节点直接存整行数据二级索引的叶子节点存的是索引列加主键值。所以当查询走二级索引时如果select的字段不在索引里数据库就要根据主键回到聚簇索引去取完整行这就是回表。回表不是免费的每一行都要做一次主键查找扫描行数一多性能立刻塌方。覆盖索引的思路就是让select需要的字段全部包含在索引里让查询只扫索引本身连回表都省掉。我处理某报表查询时原始SQL要查订单号、金额、状态、创建时间四个字段一开始只在(user_id, create_time)上建索引type是rangeExtra里能看到Using index condition速度还可以但总觉得差口气。后来把select涉及的字段全部并进索引改成(user_id, create_time, order_no, status, amount)Extra变成了Using index查询时间和扫描成本肉眼可见地下降。不过覆盖索引有个反直觉的代价索引越长存储开销和写入维护成本越高索引树也更高大扫描单条索引记录的速度会略降。所以覆盖索引只建议加在最热、对延迟最敏感的查询上不要为了覆盖而覆盖。判断标准就一条这条SQL的QPS高不高如果一天只跑几次的报表SQL多回表几次完全无所谓不值得拿大索引去覆盖。3.3 排序与分组的索引设计从Using filesort到天然有序GROUP BY和ORDER BY在数据库里非常依赖索引顺序。如果你能设计一个联合索引让排序字段恰好排在等值条件的后面优化器就能直接按索引顺序扫描天然有序Extra里的Using filesort彻底消失。原理很简单二级索引本身就是按索引列的顺序排好的查询命中索引前缀后剩下的记录天然就按后续列有序。我处理过的另一个典型问题是深分页。业务页面上有一个跳转到第5000页的入口SQL长这样SELECT order_id, order_no, amount FROM orders WHERE user_id 123456 ORDER BY create_time DESC LIMIT 9980, 20;就算有联合索引优化器也得先扫描前10000条符合条件的记录再扔掉前面9980条只返回最后20条。扫描成本随着页码增大线性上升这是索引救不了的深分页问题。解决办法是改查询形态保留上一页最后一条记录的create_time和主键下一页直接用位置条件跳过去也就是所谓的keyset或cursor分页。SELECT order_id, order_no, amount FROM orders WHERE user_id 123456 AND (create_time, order_id) (2025-03-01 10:00:00, 1000023) ORDER BY create_time DESC, order_id DESC LIMIT 20;这里用元组比较精准定位到上一页末尾只扫描20条目标记录就返回。字段顺序要和索引里的顺序一致否则索引匹配不上。这个改写对高QPS的分页接口是质的提升代价是前端逻辑要改不能随便跳页——业务上99%的场景其实不需要随便跳页这一步值得和产品聊清楚。4. 两个线上案例复盘从慢SQL告警到索引落地的完整链路4.1 案例一订单查询的filesort与深分页双重问题某订单系统的线上监控某天告警一条订单列表查询在高峰期P95延迟从200ms涨到2.5s以上。SQL形态如下字段做了脱敏简化SELECT order_id, order_no, user_id, status, amount, create_time FROM orders WHERE user_id 10086 ORDER BY create_time DESC LIMIT 0, 20;我先拉执行计划关键信息是typeref走的user_id单列索引rows估算为12000行Extra里有明显的Using filesort。一眼就能看出两个问题第一用户订单多单靠user_id索引返回了一万多行然后全部在内存里排序第二虽然LIMIT是20但排序发生在limit之前1.2万行里只有最后20行被保留前面全是无用功。高峰期这个用户的订单量又膨胀排序数据量一上去P95自然就崩了。排查到这里索引方案其实呼之欲出把原单列索引user_id替换成联合索引(user_id, create_time)。等值条件user_id在前排序字段create_time在后这样一个索引同时覆盖where过滤和order by排序排序直接走索引顺序Using filesort消失。线上数据量大我用了在线DDL的方式变更避免锁表影响写入。上线后同一SQL的P95回落到80ms左右执行计划里Extra变成干净的无额外排序状态。4.2 案例一上线后的二次验证与深分页治理第一个索引上线后我做了多参数复测发现大部分情况都稳定但有一个隐藏问题还在当查询页码很深时比如LIMIT 100000, 20联合索引也救不了。因为优化器依然要从第0行扫到第100020行再把前面十万行扔掉。这个场景在普通用户列表里很少见但运营导出、客服查询时会用。我给出的方案是分两条路径解决。普通用户端继续用LIMIT浅分页深层分页入口从产品层面关闭内部查询工具则改成上面提到的keyset分页每次携带上一页最后一条记录的create_time和order_id通过元组比较直接从指定位置取20行。改造后内部工具把跳转到十万行之后这种查询从秒级降到几十毫秒而且扫描行数稳定在LIMIT范围内不再随页码膨胀。这个案例给我的教训是索引可以解决排序和过滤层面的问题但解决不了深分页这种查询形态问题。遇到LIMIT很大的SQL先别急着加索引得先想明白页面的业务含义是不是真的需要那么深的跳页。两个优化动作要配合着做否则索引再漂亮也只是缓解不是根治。4.3 案例二关联查询被驱动表全表扫描的排查链路某物流平台的运单查询接口一张运单主表waybill日均增量几十万行扩展表waybill_ext一一对应SQL大概长这样SELECT w.waybill_no, w.route_id, w.accept_time, e.package_count, e.special_mark FROM waybill w JOIN waybill_ext e ON w.waybill_no e.waybill_no WHERE w.route_id 15 AND w.accept_time 2025-02-01 00:00:00 AND w.accept_time 2025-02-02 00:00:00 ORDER BY w.accept_time DESC;初看执行计划时waybill表走了route_id的索引rows估算2000行这部分没问题但waybill_ext表的访问方式是ALLExtra里出现Using join buffer。也就是说waybill查出来的2000行每匹配一行都要对waybill_ext全表扫一遍虽然现代版本的联表可能会用hash join转换但整体成本依然高达表规模的乘积查询最终花了3s多。问题定位到这里其实很直接waybill_ext.waybill_no上根本没有索引。waybill_no是一个类似运单号的字符串在扩展表建表时没有被设计成索引列。解决方案就是在waybill_ext.waybill_no上建二级索引。这里有一个绕不开的线上细节waybill_ext已经很大直接建索引可能长时间锁表所以我用了在线变更工具并且选择业务低峰期执行同时监控主从延迟。索引建成后被驱动表的type列从ALL变成refUsing join buffer消失查询耗时从3.2s降到45ms左右。4.4 案例二的统计信息教训索引建完不等于一劳永逸索引建完的头一周一切正常但第二周监控显示这条查询偶发回到秒级。我当时觉得很反常索引明明在type怎么又退化了呢拉出执行计划一看waybill表这次被选成了被驱动表而waybill_ext成了驱动表——驱动表选择反了。原因在于waybill表经历了大促期间的数据批量导入表行数暴增统计信息还没来得及更新优化器拿着旧的估算行数认为waybill是小表于是让扩展表走全表扫描。这个案例是最典型的统计信息过期导致优化器选错执行计划。我当时的处理分两步第一步手动触发了一次统计信息收集让优化器拿到真实的行数分布执行计划立刻恢复正常第二步把统计信息自动收集的周期调短并在这个核心链路上加了监控专门盯执行计划是否发生非预期变化。后续又经历过一次数据暴涨因为监控提前发现没有再出现长时间劣化。这里我想强调的排查思路其实很简单当一条SQL原本正常、突然劣化而且执行计划的关键节点明显不对时第一反应应该是检查统计信息新鲜度而不是急着改代码、加hint。hint能解决眼前但会把优化器长期锁死在一个固定计划上数据量再变化就无从适应。统计信息的问题应该用刷新统计信息来解决。注意hint是最后手段。统计信息过期导致的问题应该通过刷新统计信息解决而不是用强制计划把优化器锁死。5. 索引策略的边界与维护优化上线之后才是真正的开始5.1 写入放大与冗余索引优化动作的隐形负债索引不是免费的。每次INSERT、UPDATE、DELETE都要同步维护所有相关索引索引越多写入放大越严重。很多团队只关心读查询的快慢不关心写入吞吐结果为了几十条查询建了一大堆索引把写入链路拖垮这是一种本末倒置。我在项目里见到的另一个常见问题是冗余索引。明明已经有了联合索引(user_id, create_time)又单独保留了一个user_id单列索引。这两个索引功能高度重叠单列索引几乎不会被优选用到却在每次写操作时白白多维护一份。排查冗余索引的简单办法是找出所有联合索引检查它的最左前缀是不是恰好等于某个已存在的单列索引如果是那个单列索引基本可以安全删除。上线前先跑一遍慢查询日志确认没有SQL专门依赖它。5.2 索引失效的高频场景函数运算、隐式转换与低选择性索引设计得再完美SQL写法不对也白搭。我总结过三个高频的索引失效场景。第一个是函数包裹字段比如WHERE DATE(create_time) 2025-03-01这样写等于把每个记录的create_time都做一次函数运算索引顺序直接被破坏优化器只能全表扫描。正确写法是改成范围条件create_time 2025-03-01 AND create_time 2025-03-02。第二个是隐式类型转换字段是varchar类型SQL里却传了数字参数数据库为了比较会先把字段转成数字索引也跟着失效。这类问题光看SQL不容易发现执行计划里type从ref悄悄变成ALL就是信号。第三个是低选择性索引比如gender字段只有男和女两个值在其上建索引优化器算完成本觉得还不如全表扫索引自然不被使用。遇到这三个场景先解决SQL写法本身再谈索引。函数包裹和隐式转换属于改写SQL就立刻见效的低选择性则要考虑业务上是否真的需要这个过滤条件或者是否可以用其他高区分度字段替代。一句话索引只能帮到不破坏索引语义的查询。5.3 例行巡检把SQL优化变成可持续机制一次慢SQL优化完成只是这个查询的终点。真正阻断慢SQL反复出现的是把优化动作固化成例行机制。我的做法分三块。第一块是慢查询日志和实时监控任何超过阈值的SQL都会被记录并按SQL指纹聚合这样能快速抓住相同形态SQL的整体影响而不是被某一次偶发误导。第二块是定期执行计划基线对比核心链路的SQL固化成基线执行计划每次发布或数据变更后自动比对出现type退化、rows暴涨就告警。第三块是索引生命周期管理每季度做一次冗余索引清理同时结合数据增长趋势评估现有索引是否还有效。这套机制看起来简单但坚持做下来很多问题都能在用户感知之前被发现。我自己最大的体会是SQL优化不是一个救火技能而是一套防火体系。执行计划是它日常的眼睛索引策略是它动手的工具统计信息维护是它持续运转的血液。三者缺一不可。