
MySQL 索引失效与慢查询优化我被这些SQL坑了3次后总结的保命指南做后端开发和数据库运维的朋友大概率都经历过这种时刻线上某个接口突然从200ms飙到3秒数据库CPU直接拉满监控告警响成一片你登录到服务器上敲下SHOW PROCESSLIST发现一堆慢查询阻塞了整个连接池。我在这三年里因为SQL问题把线上库搞出过三次重大事故每次都是索引失效和慢查询惹的祸。这篇文章不打算讲教科书上的理论而是想把我踩过的坑、排查的路径、还有最终沉淀下来的优化方案完整复盘一遍。如果你正在被MySQL慢查询折磨或者想提前给自己备一份保命指南这篇文章应该能帮你省下不少加班时间。很多人以为索引失效就是功能上查不出数据但实际业务里更常见的表现是查询结果还是对的就是慢得离谱。这种问题最危险——系统没有报错告警也不一定触发等到用户投诉或者接口超时才被发现往往已经拖垮了整个数据库实例。我三次数库事故有两起都是这种无声变慢的类型排查起来比报错难十倍。所以这篇文章的核心逻辑是先搞清楚索引为什么会失效再学会用工具快速定位慢SQL最后给出我在生产环境里真正验证过的优化套路每一步都有真实的踩坑痕迹希望能让你少走一些弯路。1. 第一次事故复盘函数操作让索引彻底罢工那是某次促销活动的前一天晚上订单查询接口突然开始超时。我当时的第一个反应是数据库连接数满了结果上去一看连接池确实被占满了但根源是一条看起来人畜无害的查询语句。这条SQL本身没有任何语法错误执行计划也能跑就是慢——全表扫描扫描行数超过800万。因为这个接口平时调用量不大上线半年都没出过问题谁会想到它会在关键时刻掉链子。1.1 一条正常SQL是如何变成全表扫描的当时的SQL长这样SELECT * FROM orders WHERE DATE(create_time) 2024-06-17 AND status 1create_time字段上明明建了索引而且数据分布很均匀理论上走索引只需要扫几千条记录就够了。但实际执行计划显示typeALL全表扫描8个G的表被完整读了一遍。问题就出在DATE()函数上。MySQL的索引结构是B树叶子节点按字段值的原始顺序排列。当你对索引列套上函数时优化器在计算时发现DATE(create_time) 2024-06-17这个条件无法直接和B树里的某个区间对应上因为DATE()是把create_time先转换成年月日再比较B树里存的是2024-06-17 12:30:45这种完整值。除非优化器能把条件改写为等价的范围查询否则它只能放弃索引把每一行的create_time都取出来算一遍DATE()再和常量比较。这就相当于你有一本按姓氏拼音排序的电话簿却要找所有名字里有伟字的人——只能从头翻到尾。1.2 排查过程中的误判与反向尝试第一次排查时我差点被表象骗了。我先看索引是否存在确认idx_create_time确实建了然后试了FORCE INDEX(idx_create_time)结果执行计划确实走了索引但扫描行数一点没少耗时反而更长。这是因为FORCE INDEX只是强迫优化器使用索引但它没法改变函数导致无法定位区间这个本质。走了索引却还是逐条回表等于额外付出了索引扫描的成本没有任何收益。后来我做了个关键测试把条件改成本质等价的范围查询SELECT * FROM orders WHERE create_time 2024-06-17 00:00:00 AND create_time 2024-06-18 00:00:00 AND status 1执行时间从2.8秒降到了0.03秒。这两个写法在业务上完全等价但后者能让索引直接定位到目标区间扫描行数从800万变成了3000。这就是我今天要说的第一类索引失效——对索引列使用函数。除了DATE()常见的还有YEAR()、MONTH()、CONCAT()、LEFT()这些只要出现在索引列上基本等于宣判索引死刑。注意MySQL 8.0虽然加了函数索引功能但这是要专门建INDEX((DATE(create_time)))这种表达式索引才生效的老库没做兼容改造前改写SQL仍然是最稳的方案。2. 第二次事故复盘隐式类型转换导致索引被无视第二次事故更隐蔽。某个数据统计接口传入的参数是一个用户ID字段类型我建表时定义成了VARCHAR(32)但接口层在拼接SQL时没有加引号直接把数值拼了进去。结果就是WHERE user_id 20240617001而不是WHERE user_id 20240617001。你猜怎么着索引又失效了全表扫描直接把从库拖垮了。2.1 类型不一致引发的隐式转换机制这个问题的本质是MySQL的隐式类型转换规则。当比较的两边类型不一致时MySQL会自动把其中一个转成另一个再做比较。在这个案例里user_id是字符串类型右边的字面量是整数MySQL的规则是将字符串转换为数值再比较也就是说它会把每一行的user_id字段值都先转换成数字然后再和20240617001比较。这里请想一个问题如果要对字段值执行转换函数是不是又变成了对索引列使用函数是的逻辑和DATE(create_time)完全一样——索引在B树里是按字符串排序的把每条记录的字符串值转成数字再比大小没法走区间查找优化器直接放弃索引。更让人头疼的是这类问题在测试环境极难发现。测试数据量只有一万行的时候全表扫描也就几毫秒谁都不会注意到等到生产环境积累了几百万用户同样的SQL瞬间变成慢查询。这也是我后来坚持在测试库灌入生产级数据量的原因——性能问题在小数据量下几乎不可见。2.2 如何快速识别这类问题排查的时候直接看执行计划是不够的你还需要确认两边的字符集和排序规则。我给出的判断方法是在SQL执行前先用EXPLAIN看type字段如果是ALL就说明没有使用索引再去看表的DDL确认字段类型最后对比传入参数是否带引号。三步就能定位。另一个常用方法是直接运行一条简单的验证SQLSELECT * FROM orders WHERE user_id 20240617001; SELECT * FROM orders WHERE user_id 20240617001;两条语句的耗时差异能说明一切。经验是所有字符串类型的字段在业务代码拼SQL时一律显式加引号不准偷懒。开发规范里要写死这一条因为隐式类型转换是只要发生一次就足以让索引失效的典型场景而且它不像函数操作那样看一眼SQL就能发现。注意隐式类型转换不止发生在数值和字符串之间字符集不同也会触发。比如utf8mb4和utf8比较时MySQL同样会发生隐式转换索引照样失效。建表时统一字符集不是洁癖是保命。3. 第三次事故复盘前导模糊查询与OR条件的连锁反应第三次事故的SQL长这样SELECT * FROM user_log WHERE user_name LIKE %张% AND create_time 2024-01-01起初我只注意到底层逻辑是模糊搜索觉得这是业务需求没办法。但真正把数据库搞挂的其实不止这一条而是它和另一条OR查询组合在一起两条SQL同时扫描上千万行把IO打满了。3.1 左模糊匹配为什么必然失效LIKE %张%这种写法代表的是目标字符串的任意位置包含张而B树索引是按前缀顺序排列的。如果模糊匹配的%在最前面优化器无法确定扫描的起始位置——它不知道应该从树的哪个节点开始走只能全表扫描逐一匹配。但LIKE 张%右模糊就不同了索引可以定位到以张开头的区间这时候索引是能生效的。对这个业务需求我当时的处理方案不是强行优化单条SQL而是改方案。用户搜索一定需要一个输入框但如果只给用户姓名包含这一个条件这种查询在数据量大之后无解。我把功能改成了前缀匹配用户输入关键词走LIKE 张%同时前端加上标签化的筛选维度比如按时间范围、按操作类型来缩小数据范围这比单纯依赖模糊搜索体验更好性能也完全可控。3.2 OR条件对索引选择的破坏性OR条件的问题要更隐蔽一些。我遇到的SQL是SELECT * FROM orders WHERE status 1 OR user_id 20240617001status上有索引user_id上也有索引理论上两个条件分别走索引、再合并结果不就行了MySQL确实有index_merge优化可以这么做但前提是优化器认为合并的代价比全表扫描小。在大多数场景下OR条件的两个子条件扫描的区间广且交集少比如status1可能命中几百万行优化器就会推断合并代价过高改成全表扫描。在实际优化时我总结出了一个铁律遇到OR条件拆成两个查询再UNION ALL或者用UNION自动去重。改写后的SQL是这样的SELECT * FROM orders WHERE status 1 UNION ALL SELECT * FROM orders WHERE user_id 20240617001改写之后两条子SQL分别可以走各自索引然后合并结果。需要注意的是如果全表扫描的行数本身不多——比如表只有几万行——改写带来的提升并不明显反而多了一次查询和合并的开销。是否拆分建议用EXPLAIN看实际行数再决定不能一刀切。3.3 翻车之后我发现组合排序索引的坑第三次事故的排查过程中我还发现了一个额外的问题ORDER BY排序字段和WHERE条件字段没有组成联合索引。MySQL 8.0虽然引入了降序索引但优化器对ORDER BY的处理依然是尽量使用索引有序性来避免filesort。如果WHERE用了create_time筛选ORDER BY却用user_id排序优化器只能先把结果集查出来再排序。当结果集有几十万行时排序用的临时文件和内存会飙升。我最终的优化是建立一个联合索引(create_time, user_id)让过滤和排序都走同一个索引filesort直接被消除。这一步的收益有时候比前面的所有改动都大因为排序是CPU密集操作临时表过多还会导致磁盘IO压力。这个细节很多小伙伴容易遗漏——优化索引时只看WHERE条件忽略了ORDER BY和GROUP BY。4. 慢查询定位三板斧慢日志、EXPLAIN和pt-query-digest三次事故之后我意识到一个残酷的事实所有临场排查都太被动了。真正应该做的是把定位慢SQL变成自动化、日常化的流程而不是每次等线上出问题再临时抱佛脚。我的做法分三步开启慢查询日志、用EXPLAIN分析执行计划、再用pt-query-digest定期分析慢日志。这套组合拳让我能在一分钟内定位到可疑SQL。4.1 慢查询日志的配置参数详解慢查询日志是排查的起点。它不是默认打开的很多云厂商的RDS还会屏蔽对参数的直接修改需要走控制台。我习惯的配置是这样的slow_query_log 1 slow_query_log_file /var/log/mysql/mysql-slow.log long_query_time 1 log_queries_not_using_indexes 1long_query_time 1表示超过1秒的查询会被记录。有些团队设置成0.1想抓得更细结果日志文件一天几个GIO都被日志拖慢了。我建议生产环境先设成1跑一周看日志量再调整。log_queries_not_using_indexes是个好配置它能记录所有没用索引的查询——虽然会带来额外的日志写入开销但相比全表扫描造成的隐患这个开销完全值得。然后你还需要一套查看慢日志的方法。最简单的是mysqldumpslow -s at -t 10 /var/log/mysql/mysql-slow.log-s at按平均查询时间排序-t 10只看最多的10条。这个命令适合快速浏览但它只能做简单的汇总不如pt-query-digest精细。4.2 EXPLAIN执行计划的几个关键信号拿到可疑SQL之后EXPLAIN是必做的分析。很多人看EXPLAIN只盯着type字段实际上有三个字段要一起看type、rows、Extra。type从好到坏依次是system const eq_ref ref range index ALL。ALL是全表扫描必须优化index是扫描整个索引树也不理想range是索引范围扫描通常是可接受的const/ref是精度很高的索引查找。rows是优化器估算的需要扫描的行数这个数字和实际返回行数差距越大说明优化器判断越不准或者统计信息过期了。Extra里如果出现Using filesort或Using temporary意味着有额外的排序或临时表操作这两项往往是慢查询的元凶。举一个真实案例。我有一次用EXPLAIN看一条子查询EXPLAIN SELECT * FROM orders WHERE user_id IN (SELECT user_id FROM blacklist WHERE status 1)结果type是ALLExtra里出现了Using where; Using temporary; Using filesort。MySQL优化器在碰到IN (子查询)时有时候不能把子查询改写为半连接semi-join就退化成对每个外部行执行一次子查询。这种情况下我直接改写为JOINSELECT o.* FROM orders o JOIN blacklist b ON o.user_id b.user_id WHERE b.status 1一改完rows从80万降到2000执行时间从11秒降到0.06秒。所以说EXPLAIN不光是看一眼要养成看到UNION想改写、看到临时表看内存参数、看到filesort查索引的条件反射。4.3 pt-query-digest帮你找出最贵的SQLpt-query-digest是Percona Toolkit里的工具它比mysqldumpslow强大的地方在于会聚合相似的SQL模板把占资源最重的语句排到最前面还会输出每类SQL的响应时间占比、扫描行数、返回行数等指标。基本用法如下pt-query-digest /var/log/mysql/mysql-slow.log digest_report.txt打开报告后我最关心的是第一个Overall表格和后面的Profile排名。如果某条SQL占用了总响应时间的60%即使它没进Top 5也值得立刻处理。曾经有一次一条每天只跑几十次、但每次耗时20秒的批量UPDATE就是被这个工具揪出来的——日常只看Top 10不留意这类低频高耗SQL迟早出大问题。注意pt-query-digest需要安装Percona Toolkit如果你用的是云数据库没有服务器权限也可以把慢日志下载到本地分析或者用云厂商自带的分析页面。核心是定期看每周至少一次别等出事。5. 生产环境实测有效的慢查询优化套路有了定位方法还要有一套改SQL的统一方法论。我总结了六个在生产环境里真实落地过的优化套路一条条说清楚它们的适用场景和原理方便你直接拿去用。5.1 套路一改写为覆盖索引查询这条是我用得最多的。所谓覆盖索引是指索引里已经包含了这次查询需要的所有字段查询过程不需要回表。MySQL执行一次索引查询后如果发现还需要回表拿其他列会对每一行执行一次随机IO。单次随机IO大概0.1ms如果查1万行就要1秒——很多慢查询就慢在这里。举个例子SELECT order_id, user_id, amount FROM orders WHERE user_id U10001原本只有(user_id)单列索引执行时索引定位到目标行但仍然需要回表获取amount字段。我把索引改成(user_id, order_id, amount)联合索引之后索引里已经覆盖了查询所需全部字段优化器发现不需要回表Extra从Using index condition变成Using index速度自然快了不少。不过要记住索引不是越多越好覆盖索引会增加写操作的成本和存储空间只对高频查询里最核心的那几条SQL做。5.2 套路二利用MRR和索引下推优化范围查询MySQL 5.6之后的MRRMulti-Range Read和ICPIndex Condition Pushdown是两项自动优化但很多人并不知道它们的作用有时候还会因为配置不当导致它们失效。ICP的意思是当使用联合索引且WHERE条件里包含索引列的非最左前缀字段时MySQL会把部分过滤条件下推到存储引擎层只回表那些真正满足条件的行。它受optimizer_switch里的index_condition_pushdown控制默认是开启的。MRR会把回表的主键ID排序后再批量读取把随机IO转成顺序IO。它的开关是mrr和mrr_cost_based。遇到范围查询慢时先确认这两项是开启的SHOW VARIABLES LIKE optimizer_switch;再举一个索引下推的例子。(name, age)联合索引执行SELECT * FROM user WHERE name 张三 AND age 20时如果没有ICPMySQL需要先按name查出所有记录再回表然后逐条判断age 20有了ICP引擎层会先根据age 20过滤回表数量大幅减少。实际压测时这个优化能让查询耗时降低50%以上。要注意的是ICP在EXPLAIN里的Extra会显示为Using index condition如果你没看到这行优先检查优化器开关是否被误关了。5.3 套路三深分页LIMIT的性能陷阱与游标方案分页是最容易出慢查询的场景。LIMIT 500000, 20看起来只取20条但MySQL要先扫描并丢弃前50万行才能返回第50万行之后的20条。前50万行的扫描和回表成本一点都不会少。数据量大时每一页深下去的查询都会越来越慢直到突破接口超时阈值。我测试过一次单表500万行LIMIT 200000, 20耗时约3.5秒LIMIT 20耗时0.02秒。差异全是偏移量的扫描成本。深分页优化有两个方向延迟关联推迟回表先用覆盖索引查询出目标主键ID再关联原表取完整行。SELECT * FROM orders t1 JOIN (SELECT id FROM orders ORDER BY id LIMIT 500000, 20) t2 ON t1.id t2.id游标分页基于上一页的最后一条ID前端每次传上次列表最后一条的ID用WHERE id last_id ORDER BY id LIMIT 20替代LIMIT偏移。这个方案复杂度不高只是需要前端配合。这两种方案的实际效果差距非常大我强烈建议像订单列表日志列表这类无限翻页的场景直接考虑游标分页或加载更多的模式彻底去掉深分页的隐患。5.4 套路四优化器的COUNT和SUM陷阱很多统计类SQL会在COUNT和SUM上踩坑。COUNT(*)和COUNT(1)在InnoDB里没有本质性能差异因为InnoDB不像MyISAM那样保存了精确的行数COUNT(*)必须遍历索引统计。真正的问题是很多人在大表上执行SELECT COUNT(*) FROM orders WHERE status 0即使status有索引MySQL也可能选择全表扫描或扫描整个索引。对于统计需求如果结果不需要实时精确我的做法是建一个统计汇总表由定时任务每隔一段时间更新一次或者用Redis缓存数值并在订单状态变更时更新。如果一定要实时精确那就得接受扫描成本能做的是把条件条件尽量落在索引前缀上让rows尽量小。还有一类典型的坑是SUM配合非空判断SELECT SUM(amount) FROM orders WHERE status completed这里如果amount允许为NULLSUM会忽略NULL行。你以为统计是对的但业务上如果某行数据异常为NULL这个总和可能悄悄少一笔这种问题不属于慢查询却同样会引发线上质疑。我建议对关键金额字段用IFNULL(amount,0)显式处理并加上非空约束。5.5 套路五改写NOT IN和!时不要盲目NOT IN和!经常导致索引失效原因是MySQL优化器很难估计不是这些值的选择性。比如status ! 1如果值只有0和1两种这个条件仍然会命中近一半行优化器当然选择全表扫描。但如果你查的是排除后只剩极小部分的情况比如status ! 4而绝大多数行都是4优化器本可走索引但因为统计信息不够细也可能放弃。我的做法是改成反连接LEFT JOIN...WHERE NULL或者把条件拆成两个已知值范围。举一个实际验证过的写法SELECT * FROM orders WHERE status NOT IN (4, 5)改成SELECT * FROM orders o LEFT JOIN (SELECT id FROM orders WHERE status IN (4,5)) t ON o.id t.id WHERE t.id IS NULL在某些场景下这个改写让执行计划从全表扫描变成索引扫描。但说实话这种写法可读性差如果表本身不大不如保留原样别为了优化而优化。总的来说NOT IN一律改写成JOIN这种说法应该持保留态度一切以EXPLAIN的实际输出为准。5.6 套路六分批处理大事务UPDATE和DELETE最后一条不是查询优化而是写操作优化但引发的慢查询现象非常普遍。比如运营跑了一个大批量更新UPDATE orders SET discount discount * 0.9 WHERE create_time 2024-01-01这条SQL可能会锁住几十万行期间所有相关查询全部被阻塞连接堆积最终表现为大量慢查询。我的处理办法是分批更新UPDATE orders SET discount discount * 0.9 WHERE create_time 2024-01-01 AND id 1000000 LIMIT 5000;每批只更新5000行分批提交配合SLEEP短暂停顿让其他事务有机会执行。这不能减少总工作量但能显著降低锁阻塞的影响范围。批处理任务和一些跑批脚本尤其要注意这一点——别让一条UPDATE把整个库的查询拖死。6. 索引失效的八种经典场景排查表我最后整理了一张表把日常开发里最容易遇到的索引失效场景统一列出来。这张表适合贴在工位上也适合放在团队Wiki里当Checklist。每次写SQL之前对照检查一遍能避免大部分线上事故。失效场景典型SQL写法失效原因推荐改写方案对索引列使用函数WHERE DATE(create_time)2024-06-17B树无法定位函数计算后的值区间改为范围查询和隐式类型转换WHERE user_id 20240617字段为VARCHAR字段被转换后参与比较等价于使用函数参数显式加引号保证类型一致前导模糊匹配WHERE name LIKE %张%无法确定索引扫描起点改前缀匹配或换搜索方案OR连接多个条件WHERE status1 OR user_idU001合并索引代价高优化器弃用拆分为UNION ALL联合索引不满足最左前缀WHERE age 20索引为name,age缺少最左列无法使用索引调整索引顺序或增加条件NOT IN / !WHERE status NOT IN (4,5)优化器难以估算选择性改写反连接或拆分范围IS NULL单独查询WHERE phone IS NULL普通索引部分版本对NULL判断不能有效使用索引默认值代替NULL或改IS NOT NULL需验证范围查询后条件失效WHERE age 20 AND name张三索引为age,name范围查询后右侧字段无法用于定位调整索引顺序为name,age这里面有两条值得多说一句。第一个是范围查询后失效的问题。联合索引(age, name)查询条件是age 20 AND name 张三。B树是先按age排再按name排。当你用了age 20这个范围条件时后面name的等值条件已经无法精确落到某个区间了因为满足条件的age是一段连续区间这段区间内name并不保证有序。所以建联合索引时一定要把等值判断的字段放前面范围判断的字段放后面。这个顺序问题联合索引里翻车率极高。第二个是关于IS NULL的。在MySQL 8.0中IS NULL在某些条件下也能走索引但取决于优化器版本和数据分布不能当成铁律。最稳妥的写法是业务上把无手机号存成默认值然后查phone 这种等值条件走索引基本无障碍。不要让字段出现NULL说白了也是减少三值逻辑带来的各种坑这个习惯越早养成越好。7. 从源头杜绝慢SQL的规范与监控把三次事故处理完之后我做的不是万事大吉而是从事后救火转向事前预防。如果每次都要等线上出问题再调优那就永远在被动挨打。我从工具和流程两个维度做了调整。7.1 开发阶段拦截SQL规范与Code Review检查点首先把SQL规范写进了团队的开发手册不需要长篇大论只需要几条硬性规则禁止对索引列使用函数、计算、隐式类型转换。禁止使用%开头的模糊查询。联合索引场景等值条件列在前范围条件列在后。OR条件优先考虑改写为UNION ALL除非确认数据量很小。所有涉及核心表的查询提交前必须附带EXPLAIN结果type不允许为ALL。UPDATE、DELETE涉及大批量时必须拆分批次执行。配合Code Review每次有SQL改动都要看执行计划。很多管理后台的查询开发自己本地测不出问题这就要靠审查环节强制要求。哪怕多花几分钟也比事后加班定位强。7.2 数据库层的三道防线第一道防线是慢查询日志和pt-query-digest的每周巡检第二道防线是性能监控工具比如Prometheus mysqld_exporter对Threads_running、QPS、慢查询数量做实时告警第三道防线是在关键接口加一个查询超时熔断机制一旦SQL执行超过设定阈值先熔断保护数据库再触发告警通知人来排查。鼓励一下慢查询数量这个指标它比CPU使用率更能反映SQL问题。CPU波动有很多原因但慢查询数量突然上升几乎90%意味着有SQL出了问题。7.3 压测与数据量模拟不让测试环境骗你最后一点是关于测试的。我在第二次事故之后专门让DBA从生产库脱敏导出了一份核心表数据导入到预发环境。从那以后每次新功能上线前都要求跑一遍核心查询的EXPLAIN和实际压测。道理很简单一万行的表上任何SQL都是快的只有在百万、千万级数据量下索引失效的真实影响才能暴露出来。如果你的团队还没有做脱敏数据导入我强烈建议尽快排上日程。它不需要每次全量同步一个月一次或者按核心表的关键字段采样就够了。毕竟我们优化的目标不是让慢SQL看起来优化了而是让它在大数据量、高并发场景下依然扛得住。回到开头那三次事故实际上最后的解决方案都不复杂一条SQL改成范围查询一条SQL加了引号一条SQL换了联合索引。真正难的不是解决而是为什么当时没看出来。现在我把这套排查逻辑和优化方法固定下来之后团队里的慢SQL数量下降了80%以上。如果你也被索引失效和慢查询折磨过希望这份指南能让你少踩几个坑。最后再分享一个小技巧每次优化完SQL把EXPLAIN结果和执行时间截图存档。时间久了慢慢沉淀成你自己的慢SQL病例库这才是最有价值的个人资产。