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字段上明明建了索引,而且数据分布很均匀,理论上走索引只需要扫几千条记录就够了。但实际执行计划显示type=ALL,全表扫描,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,确认字段类型;最后对比传入参数是否带引号。三步就能定位。
另一个常用方法是直接运行一条简单的验证SQL:
SELECT * 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 = '20240617001'status上有索引,user_id上也有索引,理论上两个条件分别走索引、再合并结果不就行了?MySQL确实有index_merge优化可以这么做,但前提是优化器认为合并的代价比全表扫描小。在大多数场景下,OR条件的两个子条件扫描的区间广且交集少(比如status=1可能命中几百万行),优化器就会推断合并代价过高,改成全表扫描。
在实际优化时,我总结出了一个铁律:遇到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想抓得更细,结果日志文件一天几个G,IO都被日志拖慢了。我建议生产环境先设成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是ALL,Extra里出现了Using where; Using temporary; Using filesort。MySQL优化器在碰到IN (子查询)时,有时候不能把子查询改写为半连接(semi-join),就退化成对每个外部行执行一次子查询。这种情况下我直接改写为JOIN:
SELECT 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帮你找出"最贵"的SQL
pt-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之后的MRR(Multi-Range Read)和ICP(Index 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时,如果没有ICP,MySQL需要先按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允许为NULL,SUM会忽略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-17' | B+树无法定位函数计算后的值区间 | 改为范围查询>=和< |
| 隐式类型转换 | WHERE user_id = 20240617(字段为VARCHAR) | 字段被转换后参与比较,等价于使用函数 | 参数显式加引号,保证类型一致 |
| 前导模糊匹配 | WHERE name LIKE '%张%' | 无法确定索引扫描起点 | 改前缀匹配,或换搜索方案 |
| OR连接多个条件 | WHERE status=1 OR user_id='U001' | 合并索引代价高,优化器弃用 | 拆分为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病例库",这才是最有价值的个人资产。