1. 全流程思路慢SQL从发现到根治到底该怎么走做了这么多年MySQL调优我发现很多同学一上来就盯着索引使劲或者看见慢查询日志就慌其实这是本末倒置。MySQL性能优化有一套完整的链路核心是先定位、再分析、后设计、终验证缺一步都容易白忙活。先说个常见场景某天早上你收到告警数据库CPU打满业务接口响应时间从50ms飙到3秒。这时候如果你直接去加索引大概率是盲人摸象——因为你不确定是哪些SQL在拖垮数据库也不知道它们为什么慢更不知道加了索引之后会不会引入新的问题。我在实际运维中反复踩过这种坑之后总结出一个相对稳妥的落地流程分享给你参考。整个全流程可以拆成四个环节慢查询定位、执行计划分析、索引设计优化、验证回归。慢查询定位解决的是哪些SQL有问题执行计划分析解决的是为什么有问题索引设计优化解决的是怎么改验证回归解决的是改了之后到底有没有效果、有没有副作用。这四个环节环环相扣缺了任何一个优化都可能变成一场赌博。我自己处理过的模拟项目X就是一个典型例子某后台管理系统的列表查询在数据量到200万行之后出现明显卡顿业务方天天催一开始怀疑是服务器配置不够后来发现CPU和内存其实都很闲真正的问题是某条关联查询走了全表扫描每次查询要扫描百万行数据。经过慢查询日志抓取、EXPLAIN分析、索引重构三步操作之后查询耗时从1.8秒降到30毫秒。这个过程就是上面说的全流程我下面把每个环节的实操细节都拆开讲一遍。为了让你对这个流程有一个整体认知我先用一张结构化的工作流清单把各阶段的关键动作列出来后续每一节会展开细说。阶段核心动作关键产出常见误区慢查询定位开启慢查询日志、抓取TOP SQL疑似问题SQL清单只看耗时不看扫描行数执行计划分析EXPLAIN逐列解读、关注type/key/rows/Extra性能瓶颈判定只盯type列忽略rows基数索引设计优化区分度计算、复合索引组合、覆盖索引设计索引调整方案无脑给所有字段加索引验证回归前后耗时对比、执行计划对比、线上灰度观察优化效果确认测试环境验证完直接上生产这套流程的核心价值在于每一步都有明确的输入和输出不会让你在优化过程中迷失方向。下面的篇幅我会按照这个流程一步步走每一个环节都会附上真实的命令、参数和踩坑经验。2. 慢查询定位实操三种抓取手法与效率对比2.1 慢查询日志最基础也最容易被忽略的配置慢查询日志是所有排查工作的起点。很多开发同学可能到现在都没开过这个开关或者在排查问题的时候才想起来临时开这其实已经错过了最佳时机。我建议任何线上MySQL实例从上线第一天就开启慢查询日志哪怕刚开始没有慢查询这些数据也是后续容量规划和性能分析的宝贵素材。开启方式很简单修改MySQL配置文件my.cnf或my.ini在[mysqld]段下追加以下几项slow_query_log ON slow_query_log_file /var/log/mysql/slow-query.log long_query_time 1 log_queries_not_using_indexes ON参数含义我逐个说明slow_query_log是总开关slow_query_log_file指定日志文件路径注意MySQL的运行用户需要对目录有写权限long_query_time表示超过多少秒算慢查询我把阈值设为1秒这是因为线上业务正常情况下查询都在毫秒级超过1秒的问题SQL已经有优化价值了没必要等到10秒才记录那时候业务早就被打挂了log_queries_not_using_indexes这是一个容易被忽略但是很有价值的参数它会把所有没有走索引的查询都记录下来不管耗时是否超过阈值。配置完成后重启MySQL生效。不过如果你是线上环境不方便重启也可以用SET GLOBAL动态调整SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; SET GLOBAL log_queries_not_using_indexes ON;注意long_query_time修改后对已经建立的连接不生效需要重新连接或者利用定时任务连接一次才会触发实际测试中你会发现新发起的查询才会按新阈值记录。日志文件会一直增长所以建议配置日志轮转。MySQL 5.7以上可以用SET GLOBAL slow_query_log OFF先关闭然后mv文件再重新开启或者直接用mysqladmin flush-logs刷新日志。Linux下也可以用logrotate按天切割这个很多文章都会写但真正执行的时候要小心权限和信号问题。2.2 用pt-query-digest把日志变成报表慢查询日志是纯文本格式如果SQL量大直接看根本看不过来。我常用的工具是Percona Toolkit里的pt-query-digest它能把慢查询日志汇总成一份按耗时和次数排序的报告瞬间定位到最值得优化的SQL。安装方式不赘述Debian系可以直接apt install percona-toolkit或者从官网下载对应发行版的包。分析命令很简单pt-query-digest /var/log/mysql/slow-query.log slow_report.txt生成的报告分为三部分总体统计、按查询维度分组统计、每个查询的详细样本。我最关注的是第二部分它把相同的SQL参数不一致但结构相同归为一组按总耗时排序。假设报告里有一个这样的条目# Query 3: 4.97k QPS, 1.24x卡片 # Time range: 2024-01-01T10:00:00 to 2024-01-01T11:00:00 # Attribute pct total min max avg 95% stddev median # # Count 3 145 # Exec time 78 647s 2s 8s 4s 6s 1s 3s # Lock time 6 97s 0 5s 1s 1s 1s 0 # Rows sent 1 2.15k 1 45 14.83 44.24 9.32 5.84 # Rows examine 75 1.01M 1.22k 796.14k 40.83k 81.22k 21.59k 21.73k注意看Rows examine和Rows sent的比值如果Rows examine远大于Rows sent说明这条SQL扫描了大量数据行却只返回很少的结果这是典型的索引缺失或索引设计不合理导致的假性能问题。这类SQL即使耗时没超过阈值也应该纳入优化队列因为它消耗了大量磁盘IO和CPU资源。2.3 实时定位SHOW PROCESSLIST与performance_schema慢查询日志是事后分析的利器但线上情况瞬息万变。有时候突发的慢查询会把数据库打到濒临崩溃这时候没时间去看日志文件需要在问题发生的当下立刻抓到正在执行的SQL。首选命令是SHOW FULL PROCESSLIST它能看到当前所有正在执行的连接和SQL语句SHOW FULL PROCESSLIST;输出的关键字段是Id连接ID、Time执行时间、State当前状态、InfoSQL内容。如果发现某个连接的Time特别大比如超过10秒那基本可以断定它有问题。这时候可以先KILL {Id}把连接干掉止损优先。不过SHOW FULL PROCESSLIST的缺点是它只能看到当前正在执行的SQL如果SQL执行太快你刷几遍都未必能抓到。更好的方案是使用performance_schema里的events_statements_current表SELECT THREAD_ID, EVENT_NAME, TIMER_WAIT/1000000000 AS wait_ms, SQL_TEXT, ROWS_EXAMINED, ROWS_AFFECTED FROM performance_schema.events_statements_current WHERE SQL_TEXT IS NOT NULL ORDER BY TIMER_WAIT DESC LIMIT 20;这个查询能实时抓取当前正在执行的SQL还附带扫描行数等关键指标。不过要注意performance_schema开关需要提前开启而且开启后有一定内存开销建议在低峰期开启并持续观察。还有一个实战技巧让慢查询日志记录所有未走索引的SQL也就是前面提到的log_queries_not_using_indexes然后在测试环境跑一遍核心业务用例日志里会立刻记录下所有索引失效的SQL这个策略在接手一个老项目时特别管用可以快速摸清系统的索引使用状况。3. EXPLAIN执行计划深读每一列都是体检报告3.1 从一次完整的EXPLAIN输出说起定位到问题SQL之后下一步就是用EXPLAIN分析执行计划。我拿一个模拟的真实场景举例某订单查询SQL关联了用户表和订单表EXPLAIN SELECT o.order_no, u.user_name FROM orders o INNER JOIN users u ON o.user_id u.id WHERE o.status 1 AND o.create_time 2024-01-01 ORDER BY o.pay_time DESC LIMIT 20;执行之后输出大概是这样的idselect_typetablepartitionstypepossible_keyskeykey_lenrefrowsfilteredExtra1SIMPLEoNULLALLidx_status_time,idx_user_idNULLNULLNULL19823445.26Using where; Using filesort1SIMPLEuNULLeq_refPRIMARYPRIMARY8test.o.user_id1100.00NULL这张表的信息量非常大。我一行一行说type列显示o表是ALL意味着全表扫描198万多行数据全部要过一遍u表是eq_ref走主键等值连接没问题。key列是NULL说明o表虽然存在idx_status_time和idx_user_id两个索引但优化器一个都没用上。Extra列出现了Using where; Using filesort文件排序意味着内存临时表或磁盘排序这也是性能杀手。最直观的性能瓶颈就在o表那一行全表扫描加上文件排序198万行数据做排序再取20条这种查询不慢才怪。任何执行计划分析的核心就是找到这种类型差、rows大、Extra异常的行然后针对性优化。3.2 type列的五级阶梯从ALL到const的性能递进type列是判断单表访问方式的重要指标它从差到优一般遵循这样一个递进关系ALL全表扫描→index全索引扫描→range索引范围扫描→ref非唯一索引等值匹配→eq_ref唯一索引等值匹配→const/system主键或唯一索引等值匹配。我把这几种类型整理成一个速查表方便你对照type值访问方式扫描范围性能评价常见场景ALL全表扫描整张表极差无可用索引或优化器放弃索引index全索引扫描整个索引树较差查询列都在索引中但无法条件过滤range索引范围扫描索引部分区间良好WHERE中有BETWEEN、、、IN等ref非唯一索引等值匹配匹配行集合良好WHERE中普通索引列等值条件eq_ref唯一索引等值匹配最多一行优秀JOIN中主键或唯一索引被等值匹配const主键等值匹配单行极优WHERE中主键常量一个比较反直觉的点type为index也不一定差。如果查询的列全部包含在某个覆盖索引中index扫描虽然遍历整个索引但走的是比数据行更紧凑的索引页在某些统计查询场景下反而比ALL快几个数量级。3.3 rows与filtered优化器眼中的成本账本rows列是优化器预估的需要扫描的行数它直接决定了SQL的大致耗时量级。一般来说rows从几十万降到几百查询耗时通常能从秒级降到毫秒级。但要注意这个值是估算值不是精确值有时候会因为统计信息不准确而出现偏差。filtered表示经过WHERE条件过滤后剩余行数的百分比。比如上面示例中o表的filtered是5.26%说明优化器预估扫描198万行后只有约10万行满足条件1982344 × 5.26%这个性价比极低因为大部分行都在WHERE判断中被丢弃。一个实用技巧当rows和filtered出现明显不合理的组合时先执行ANALYZE TABLE刷新统计信息再重新看EXPLAIN。比如某表有大量UPDATE操作统计信息长时间没更新优化器可能拿着旧数据做判断做出错误的执行计划选择。有时候你会发现刷新统计信息之后执行计划自己就变好了。3.4 Extra列的隐藏密码filesort、temporary、index conditionExtra列包含了很多优化器行为的补充信息其中三类最值得关注Using filesort代表排序无法利用索引顺序需要额外的排序步骤。这通常出现在ORDER BY列没有和索引匹配、或者没有建立合适的联合索引的场景。文件排序如果排序数据量超出sort_buffer_size会使用磁盘临时文件性能急剧下降。解决方法一般是把排序字段纳入索引设计。Using temporary代表查询使用了临时表常见于GROUP BY、DISTINCT、UNION操作。临时表在内存中还好数据量大时会落到磁盘性能非常差。解决思路是让GROUP BY的字段顺序匹配索引顺序或者改写SQL。Using index condition代表触发了索引条件下推Index Condition PushdownICP这是MySQL 5.6以后引入的重要优化。它允许存储引擎层在索引遍历时就过滤不符合条件的记录减少回表次数。看到这个字段说明索引利用得不错但如果你发现某些查询出现了Using where; Using index condition的组合可以进一步优化索引设计让过滤条件全部落到索引字段上从而消掉Using where。3.5 实操心得EXPLAIN不是万能的做了这么多年排查我得说EXPLAIN也有它的局限性。第一它只展示优化器选择的执行计划不展示所有可能选项所以有时候你看不到为什么没走索引A而走了索引B这时需要结合OPTIMIZER_TRACE查看详细决策过程。第二它不显示实际的耗时和扫描行数只能看预估值所以最终验证还是要靠真实执行时间。OPTIMIZER_TRACE的使用方法是在会话内打开开关SET optimizer_trace enabledon; SELECT ...; SELECT * FROM information_schema.OPTIMIZER_TRACE;输出的JSON内容非常详细包括优化器对各索引访问成本的估算、对表关联顺序的考量等。实际工作中我一般只在EXPLAIN结果和直觉不符时才用这个工具深挖日常优化用EXPLAIN已经够用了。4. 索引设计核心方法论从字段选择到组合艺术4.1 区分度决定一切为什么不能给低区分度字段建索引很多刚入门的人有一个误区觉得WHERE里出现的字段就应该建索引。我见过一个典型案例某表有一个status字段只有0和1两个值开发同学给这个字段建了索引结果查询计划还是走了全表扫描。原因是优化器计算后发现使用索引访问的话如果status1能匹配表中50%以上的行优化器认为直接全表扫描成本更低于是放弃索引。这其实是个聪明的选择对于区分度极低的字段索引扫描需要大量回表付出的磁盘IO可能比顺序读表还大。实际操作中怎么判断字段区分度SQL很简单SELECT COUNT(DISTINCT column_name) / COUNT(*) AS selectivity FROM table_name;这个比值就是区分度取值范围在0到1之间。一般来说区分度大于0.1的字段才值得建独立索引。比如一个订单表里有100万行order_no字段基本唯一区分度接近1建索引效果很好而status字段只有几个值区分度可能只有0.00001建索引意义不大。但对于这种低区分度字段也不是完全没办法解决方案是和其他高区分度字段一起建联合索引。比如查询是WHERE status 1 AND create_time 2024-01-01那么建一个(status, create_time)的联合索引是合理的虽然status区分度低但它可以在索引的最左列先过滤掉一半数据剩下的通过create_time继续缩小范围。4.2 联合索引的左前缀法则顺序决定生死联合索引是MySQL索引设计的核心而它的核心法则就是最左前缀原则。MySQL的联合索引本质上是按照索引定义中第一个字段、再第二个字段、再第三个字段的顺序建立的一棵B树。查询的过滤条件必须从索引的最左列开始并且连续匹配才能最大化利用索引。我举个例子。假设有一个orders表建立了联合索引idx_user_status_time(user_id, status, create_time)那么下面这些查询都能很好命中这个索引WHERE user_id 1001 -- 命中只用了最左列 WHERE user_id 1001 AND status 1 -- 命中用了前两列 WHERE user_id 1001 AND status 1 AND create_time 2024-01-01 -- 完整命中三列而下面的查询则无法使用完整索引WHERE status 1 -- 未从最左列开始无法使用索引 WHERE create_time 2024-01-01 AND user_id 1001 -- 条件顺序不影响但只有user_id能用到索引这里有一个容易踩的坑WHERE create_time 2024-01-01 AND user_id 1001虽然SQL里create_time写在前面但优化器会做条件重排把等值条件user_id 1001放到前面所以user_id仍然能命中最左列。真正决定能否用索引的是字段是否连续参与匹配不是SQL书写顺序。设计联合索引的顺序时通用的考量优先级是先等值条件字段再范围条件字段。因为在联合索引中一旦某个字段遇到范围查询、、BETWEEN后面的字段就无法利用索引顺序进行匹配了。比如(user_id, status, create_time)中把create_time放在最前面那status 1这个等值条件就只能靠回表过滤了损失惨重。4.3 覆盖索引把查询按死在索引里覆盖索引指的是查询的所有字段都包含在某个索引中查询时只需要扫描索引页不需要回表取数据行。这是提升查询性能最暴力也最有效的手段之一。举个例子常见的高频查询是SELECT id, order_no, status FROM orders WHERE user_id 1001 ORDER BY create_time DESC;如果只建了idx_user_id(user_id)索引那么查询时通过索引找到user_id匹配的所有主键再逐行回表取出order_no、status、create_time最后在内存排序。如果建了idx_user_orders(user_id, create_time, order_no, status)联合索引那么索引树本身包含了查询所需的所有字段优化器直接遍历索引就返回结果连排序都省了因为索引本身就是按user_id create_time排序的。这里有个细节值得注意不要为了追求覆盖索引而把所有字段都塞进索引。索引越多写入和更新成本越高。实际互联网场景中一般把高频查询返回的字段控制在4~6个以内超过这个数量就要权衡利弊或者考虑反范式设计。4.4 索引下推、MRR与排序优化让索引发挥余热MySQL 5.6之后引入的索引条件下推ICP允许在索引遍历过程中就过滤掉不符合WHERE条件的记录。比如联合索引(name, age)查询WHERE name LIKE 张% AND age 20在没有ICP的情况下只能通过name匹配到所有姓张的记录然后逐条回表判断age有了ICPage的判断在索引层就完成了回表次数大幅减少。MRRMulti-Range Read优化则把从辅助索引拿到的回表主键排序后再回表把随机IO变成相对顺序IO对机械硬盘时代效果显著对SSD也有一定收益。这个优化默认开启不用特别配置但你可以在EXPLAIN的Extra列看到Using MRR来确认。ORDER BY排序优化方面如果排序字段和联合索引顺序匹配就不会出现filesort。比如上面那个ORDER BY create_time DESC的例子如果联合索引是(user_id, create_time)排序字段恰好是索引的第二列同时WHERE的等值条件固定了user_id排序顺序其实已经由索引天然保证。这里注意只把user_id作为等值条件、create_time作为排序字段时是可以利用索引顺序的这里很多人会误以为联合索引的第二个字段不能用于排序其实只要第一个字段是等值条件第二个字段的排序还是能利用索引的。4.5 索引设计的取舍写放大与查询效率的平衡索引不是越多越好这是我在实际项目中反复被验证的一点。每增加一个索引INSERT、UPDATE、DELETE操作都需要同步维护对应的B树写放大效应十分明显。如果你的业务是低写入量、高查询量的读多写少场景可以适当多加索引但如果是高并发写入场景比如下单、日志采集、舆情监控数据落库每多一个索引都可能让TPS下跌好几个百分点。我的习惯做法是控制单表索引数量不超过5个超过就审视一下是不是有冗余索引或者低频索引。有些查询很少用到但索引却建立后一直在消耗写入性能这种就果断删掉。还有一个容易被忽略的点前缀索引。对于超长字符串字段比如URL、备注内容、长文本全字段建索引会导致索引体积巨大内存放不下性能反而差。这时可以只对前N个字符建索引ALTER TABLE articles ADD INDEX idx_url_prefix (url(64));前缀索引的代价是可能无法用于覆盖索引和ORDER BY但这个方案在处理大字段场景下非常值得考虑。前缀长度的选择可以用一个对比查询来辅助判断SELECT COUNT(DISTINCT LEFT(url, 64)) / COUNT(*) AS prefix64, COUNT(DISTINCT LEFT(url, 128)) / COUNT(*) AS prefix128, COUNT(DISTINCT url) / COUNT(*) AS full FROM articles;找到区分度损失最小的前缀长度这是一个可以量化的决策过程不要拍脑袋拍一个数字就上。5. 完整实操案例从秒级到毫秒级的优化全记录5.1 问题SQL还原与基础分析前阵子帮一个团队优化某跨平台系统的订单列表分页接口压测时发现一个分页查询在数据量约300万行的orders表上执行时间达到2.3秒。原始SQL长这样SELECT o.id, o.order_no, o.user_id, o.status, o.total_amount, u.nickname FROM orders o LEFT JOIN users u ON o.user_id u.id WHERE o.status IN (1, 2, 3) AND o.create_time 2024-06-01 ORDER BY o.id DESC LIMIT 20 OFFSET 200000;第一眼就能看出两个可疑点OFFSET 200000意味着要跳过20万行这是深分页问题status IN (1,2,3)范围很大区分度低。业务上又确实需要翻到很后面的页码不能简单限制页码。先用EXPLAIN确认执行计划EXPLAIN SELECT o.id, o.order_no, o.user_id, o.status, o.total_amount, u.nickname FROM orders o LEFT JOIN users u ON o.user_id u.id WHERE o.status IN (1, 2, 3) AND o.create_time 2024-06-01 ORDER BY o.id DESC LIMIT 20 OFFSET 200000;结果中orders表type是range使用了一个状态时间的联合索引rows预估43万Extra列还出现了Using filesort。深分页加文件排序这个查询注定慢索引没有完全适配排序需求。5.2 优化方案延迟关联加覆盖索引的组合拳我设计的方案是两步走。第一步改造SQL采用延迟关联延迟join的思路先通过覆盖索引快速拿到目标主键ID再回原表取完整数据第二步建立合适的覆盖索引支持第一步的快速定位。新SQL如下SELECT o.id, o.order_no, o.user_id, o.status, o.total_amount, u.nickname FROM ( SELECT id FROM orders WHERE status IN (1, 2, 3) AND create_time 2024-06-01 ORDER BY id DESC LIMIT 20 OFFSET 200000 ) tmp JOIN orders o ON tmp.id o.id LEFT JOIN users u ON o.user_id u.id ORDER BY o.id DESC;核心改动是先用子查询只取主键id子查询中可以完全利用覆盖索引避免回表减少排序的数据量然后再用主键去关联取完整行。users表本来就是根据主键关联性能开销不大。配套索引设计为idx_status_time_id(status, create_time, id)注意把id放进了索引末尾。这么设计的原因是WHERE条件用到了status和create_time过滤ORDER BY和深分页使用id排序id放入索引后子查询可以直接从索引中按序拿id连filesort都省了。这个索引同时是覆盖索引因为子查询只需要id列。5.3 优化前后的数据对比优化后我重新执行EXPLAIN验证子查询的执行计划已经不再出现filesorttype为rangerows大幅下降。真实执行测试指标优化前优化后提升比例单次查询耗时2.3秒47毫秒约49倍扫描行数预估43万5.6万87%下降排序方式filesort利用索引顺序无排序开销回表次数大量20次仅最终取数大幅减少这个案例的关键不在于SQL写得多炫技而在于每一步都基于执行计划中的实际指标做决策先发现全表扫描filesort再通过索引设计消除排序再用延迟关联减少无效IO。这套方法比盲目加索引或者改SQL写法要可靠得多。5.4 线上执行DDL的注意事项索引设计完成后线上执行ALTER TABLE也要讲究策略。在几百万行的大表上直接ALTER TABLE ADD INDEX会锁表虽然InnoDB支持在线DDL但具体能不能不阻塞DML还要看版本和操作类型。MySQL 5.6以上支持ALGORITHMINPLACE, LOCKNONE可以做到DDL期间允许并发读写ALTER TABLE orders ADD INDEX idx_status_time_id (status, create_time, id), ALGORITHMINPLACE, LOCKNONE;但即便支持在线DDL大表的索引构建仍会产生大量IO和主从延迟尤其在主从环境下备库产生的延迟可能让读流量感知到数据落后。实操中建议在业务低峰期执行并且先用pt-online-schema-change这类工具做预演或者干脆在备库先执行再切换这种方式更稳妥。我个人的经验是超过千万行的表宁可多花一个小时用工具编排也不要冒锁表的险。5.5 深分页的进一步优化思路上面的延迟关联方案已经能解决很大一部分深分页问题但OFFSET 200000本身还是不够优雅。如果业务场景允许更进一步的做法是改成键值分页keyset pagination也就是记录上一页最后一条记录的id下一页直接用WHERE id 上次最大id ORDER BY id DESC LIMIT 20。这种方式下不管翻到多少页扫描的数据量都是恒定的20条性能不会随着页码增长而恶化。但它牺牲了跳页能力用户不能直接点击第100页。对大多数C端业务来说无限制的跳页本身就不是刚需产品层面改成加载更多的交互模式对后端压力会小很多。这个思路我在给团队培训时经常强调性能优化不只是技术层面的调整也有产品交互层面的可商量空间。6. 常见问题排查实录索引失效场景与陷阱清单6.1 索引失效的常见场景盘点我整理了一个高频踩坑清单每一类都是实际项目中遇到过的问题类型示例写法索引失效原因推荐改写隐式类型转换WHERE phone 13800001111phone字段是VARCHAR数字常量触发转换索引失效WHERE phone 13800001111对索引列使用函数WHERE DATE(create_time) 2024-06-01函数操作破坏索引有序性WHERE create_time 2024-06-01 AND create_time 2024-06-02LIKE前置通配符WHERE name LIKE %张无法从索引定位起始位置改全文索引或考虑后置匹配OR条件有一侧无索引WHERE user_id 1 OR status 2status无索引导致全表扫描拆成UNION或给status建索引联合索引跳列索引(a,b,c)条件只用a,c中间列b缺失c无法利用索引顺序重排索引字段顺序或补中间列条件范围查询后接等值条件索引(a,b)条件a10且b1a范围查询后b无法利用索引顺序调整索引顺序为(b,a)或拆分查询这里面隐式类型转换是最隐蔽的。我遇到过一个案例某用户表phone字段是VARCHAR但代码里传参是Integer类型JDBC预编译时参数类型不匹配MySQL在比较时做了隐式转换导致索引完全失效。加了引号之后同样的SQL耗时从800ms降到60ms一行代码没改就是参数类型的问题。6.2 优化器没走索引的意外情况有时候索引设计没问题、SQL写法也正确但EXPLAIN显示还是全表扫描。最常见的原因是统计信息不准确。大量增删改之后information_schema.statistics里的基数cardinality可能长期未更新优化器基于过期的统计信息做了错误判断。解决办法就是执行ANALYZE TABLEANALYZE TABLE orders;这个操作会重新统计索引的区分度信息代价是会对表加读锁在线业务低峰期做比较合适。我之前遇到过一张500万行的表每次查询都在3秒以上EXPLAIN显示全表扫描但索引明明存在且区分度很高。执行ANALYZE之后执行计划自动改成走索引查询耗时直接降到50ms以下。这个案例给了我一个不小的教训建完索引不代表一劳永逸还得关注统计信息是否新鲜。还有一个容易被忽略的点是innodb_stats_on_metadata参数。默认情况下MySQL在访问information_schema或SHOW INDEX等元数据时会重新计算统计信息如果统计信息计算代价高且频繁触发反而会带来额外开销。生产环境可以关闭这个参数SET GLOBAL innodb_stats_on_metadata OFF改由定时的ANALYZE TABLE来维护统计信息。6.3 写在最后的一点心得MySQL性能优化这件事说到底是用数据驱动决策而不是凭感觉优化。慢查询日志给你线索EXPLAIN给你依据索引设计给你方案验证回归给你闭环。遇到任何性能问题先量化、再分析、后动手千万别上来就改配置加索引。我个人踩过最大的坑就是急于求成看到慢SQL直接加索引结果加完之后查询是快了但整体写入性能下降了又得花时间回滚。后来养成了先看执行计划、先算区分度、先评估影响面再动手的习惯返工率低了很多。希望这篇全流程笔记能帮你少走一些弯路。如果你们团队也有类似的慢查询治理需求建议先用慢查询日志跑一周收集数据把TOP 20的SQL拉出来做一次集中评审你会发现大部分问题都能通过索引设计解决真正需要改SQL逻辑的其实很少。