MySQL慢SQL调优实战:执行计划、索引优化与案例复盘
让慢 SQL 从“病入膏肓”到“活蹦乱跳”MySQL 调优实战笔记做 MySQL 调优的时间越长越觉得这活儿像老中医看病。你看到的从来不是“SQL 写得丑不丑”这一个问题而是索引使用、优化器选择、表结构设计、服务器负载、甚至并发压力共同作用的结果。今天不聊那些玄乎的架构级优化就老老实实讲讲 SQL 调优这件事本身——从定位慢 SQL、读懂执行计划到索引优化、常见场景改写再到真实案例复盘把我踩过的坑和验证过有效的方法一次说清楚。如果你正在为一条跑了几秒钟的查询发愁或者刚接手一个慢 SQL 频发的库这篇笔记应该能帮你省下不少熬夜排查的时间。先说清楚一个我的核心观点SQL 调优不是把 SQL 改得好看而是让 MySQL 优化器能用上更优的数据访问路径。很多时候一条语句性能问题的根源不在语句本身而在表结构、索引设计、统计信息甚至是你查询的方式根本没法利用索引。1. 调优之前先搞懂这几件事1.1 先看成本模型再看执行计划MySQL 8.0 的优化器在生成执行计划时依赖的是成本模型。它会估算全表扫描要读多少页、走某个索引要回表多少次、内存排序大概要消耗多少临时空间然后选它认为成本最低的方案。但成本模型依赖的是统计信息不是真实数据的实时分布。这就是为什么明明某条 SQL 走了 A 索引更快优化器却偏偏选了 B 索引。我习惯把优化器当成一个近视眼的资深 DBA它经验丰富但需要戴对眼镜——统计信息就是那副眼镜。所以在开始任何调优前先确认表的统计信息是否新鲜太旧了就执行ANALYZE TABLE。有一个真实的例子某业务表数据量从 100 万涨到了 5000 万由于打开了innodb_stats_auto_recalc但阈值配置不合理优化器一直按 100 万的基数选索引结果全表扫了一次。手动分析之后同样的 SQL 从 3 秒降到了 200 毫秒。1.2 调优目标不是单条 SQL而是整体吞吐我对调优的定义和别人可能不太一样不是把某一条慢 SQL 压到极致而是在不引入新问题的前提下让系统整体吞吐变好。比如有人为了让一条分组查询更快强行在 ORDER BY 字段上建了索引结果每次写入都要多维护一棵 B 树把写放大直接拉满。这种调优是负优化。更好的做法是先看这条 SQL 的执行频率。一天跑一次的报表查询跑到 2 秒其实可接受一秒执行上百次的线上接口一次慢查询就可能拖垮连接池。所以调优之前先确认自己优化的是哪个层级的语句配套做压力测试别只看单条语句的耗时变化。1.3 没有执行计划的调优等于盲人摸象我见过太多人上来就问这条 SQL 为什么慢但完全不看EXPLAIN的结果。不看执行计划你根本不知道这条查询走的是全表扫描、索引范围扫描还是索引全扫描后回表。执行计划是调优的切入点后续所有结论都建立在它之上。另外提醒一句线上环境别直接在业务库跑EXPLAIN ANALYZEMySQL 8.0.18 支持它会真实执行语句。对大数据量、高并发的表最好在压测环境或只读从库上分析。分析执行计划虽然只做成本估算不真正执行但EXPLAIN ANALYZE是会真实执行并输出耗时分布的。2. 定位慢 SQL慢查询日志与 EXPLAIN 的正确打开方式2.1 慢查询日志别大开大合要精准采集慢查询日志是最基础的排查入口但很多人设置得太粗暴。比如全局long_query_time 1结果日志里全是辅助查询和定时任务的影子淹没了真正需要关注的业务 SQL。我的习惯是分三层配置全局阈值设得宽松一点比如long_query_time 2作为兜底。针对核心业务库单独设置更严格的阈值比如long_query_time 0.5。用log_queries_not_using_indexes ON揪出那些全表扫描的查询这不是为了干掉所有不带索引的查询而是为了定期审视你是不是漏建了索引。我常用的动态配置方式SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 2; SET GLOBAL log_queries_not_using_indexes ON; SHOW VARIABLES LIKE slow_query_log_file;注意long_query_time的单位是秒支持小数。但每次修改只对后续新连接生效排查问题前记得确认当前会话的变量值别改完发现当前连接还是旧值。2.2 慢查询日志里哪些信息最值得看慢查询日志除了记录执行时间还会记录Rows_examined扫描行数和Rows_sent返回行数。这两个数字的比值是最直接的信号如果扫描 500 万行才返回 10 行这条 SQL 大概率存在索引缺失或者查询条件无法下推的问题。举个例子我曾经在日志里看到一条UPDATE语句执行了 4 秒Rows_examined高达 80 万但只更新了 5 行。原因很典型——UPDATE 的 WHERE 条件里对索引字段做了运算UPDATE order_detail SET status 1 WHERE DATE(create_time) CURDATE();create_time上明明有索引但DATE()函数导致索引失效优化器只能全表扫。改成范围条件后扫描行数从 80 万降到了 3000UPDATE order_detail SET status 1 WHERE create_time 2024-01-01 00:00:00 AND create_time 2024-01-02 00:00:00;关于Rows_examined多说一句它和实际扫描的物理行数不是一回事但已经足够用来定位扫描放大的趋势。当你把它和慢日志里的耗时放在一起看时能看到一个很直观的曲线——如果扫描行数没变耗时却翻倍那问题多半不在 SQL 本身而在服务器负载、锁等待或者 IO 抖动。2.3 读懂 EXPLAIN 的关键字段别被 type 骗了EXPLAIN 输出的列很多初看会发懵。我平时只盯着几个关键字段字段重点关注什么type从好到差大致是system const eq_ref ref range index ALL。看到 ALL 或 index 就要警惕key实际使用的索引。如果是 NULL说明没走索引rows优化器估算需要读取的行数不是精确值但能反映量级filtered经过条件过滤后剩余比例值越低说明扫描后丢弃的行越多Extra出现 Using filesort、Using temporary 时通常意味着性能隐患很多人看到 type 是 range 就放心了其实不然。range 也有讲究如果是range访问且 rows 估算值接近全表那这个范围扫描退化成全表扫描只是时间问题。另外要区分Using index覆盖索引不需要回表和Using index condition索引下推前者快得多。提示Using filesort不等于磁盘文件排序它只是表示 MySQL 需要额外的排序步骤可能是内存排序也可能是磁盘排序。看到这个标记优先检查能不能利用有序索引避免排序。2.4 一个附加技巧EXPLAIN 的 FORMATTREE 和 FORMATJSONMySQL 8.0 支持EXPLAIN FORMATTREE输出树状执行计划结构清晰得多能看出访问顺序。线上踩过坑之后我越来越习惯用 JSON 格式EXPLAIN FORMATJSON SELECT ...JSON 输出里包含cost_info可以看优化器对每一步的成本估算。比如read_cost、eval_cost、prefix_cost这些字段虽然不能完全当真但能帮你理解为什么优化器在两条路径里选了这条。3. 索引优化90% 的调优问题都出在这里3.1 索引失效的几类经典场景每一个都是我真实踩过的场景一对索引列使用函数或表达式上面那个DATE(create_time)就是典型。类似的还有WHERE YEAR(create_time) 2024、WHERE price * 0.9 100。只要对索引列做了运算MySQL 就无法直接定位到索引键优化器会放弃索引。解决办法不是逼优化器走索引而是把表达式改造成可比较的常量范围。场景二隐式类型转换字符类型的索引列查询条件用了数字类型或者反过来都会导致无法命中索引。有一个经典案例SELECT * FROM user WHERE phone 13800138000;phone字段定义为VARCHAR(20)但查询条件传的是数字13800138000。MySQL 会把字符串列隐式转成数字来比较索引直接失效。虽然结果集看起来一样但执行计划已经是全表扫。手机上最常见的做法是让代码层统一传入字符串或者干脆在 SQL 里写死引号SELECT * FROM user WHERE phone 13800138000;场景三前导模糊查询LIKE %keyword这种查询B 树索引是按最左前缀排序的前导模糊意味着无法从索引树的某个确定位置开始扫描。如果只是偶尔跑一次的管理报表可以接受全表扫如果是高频查询大概率要引入搜索引擎或者用反向字段设计比如存储倒排后的关键词。这个没有银弹。场景四索引列参与运算的另一个亲戚——NOT IN 和 很多人不知道NOT IN、!、在某些情况下也会让优化器放弃索引。本质上是因为这类条件的选择性往往很低——如果 90% 的行都满足不等于某个值走索引成本反而高。优化器可能主动选择全表扫。这不能说绝对失效但你要有预期。遇到这种查询真正想优化通常得改业务逻辑或者拆解成多个查询用 UNION 合并。3.2 覆盖索引带来的收益比你想的大覆盖索引指的是查询的所有字段都在索引树里不需要回表查聚簇索引。它的收益不只是省一次回表而是让 InnoDB 可以直接在索引页上完成查询IO 次数大幅降低。一个印象很深的优化案例SELECT id, order_no, amount FROM payment WHERE user_id 12345 ORDER BY create_time DESC LIMIT 20;原本只有主键和user_id单列索引查询要把命中的几千行全部拿到聚簇索引里查一遍再排序。后来建了联合索引(user_id, create_time, id, order_no, amount)执行计划显示Using index整个查询从 100ms 降到 5ms。因为排序也不需要做了B 树本来就是按(user_id, create_time)有序的。不过覆盖索引也不是越多越好。索引字段越多写入时维护成本越高尤其是 UPDATE 频繁的表索引更新是实打实的负担。设计时要有取舍。3.3 最左前缀原则到底在说什么最左前缀原则几乎是 MySQL 面试必考题但很多人只背了结论不理解原理。联合索引(a, b, c)的物理存储顺序是先按 a 排a 相同再按 b 排b 相同再按 c 排。所以它能高效支持的查询条件是等值查询WHERE a ?范围查询WHERE a ? AND b ?排序ORDER BY a, b但如果你的查询条件是WHERE b ?索引树里 b 是有序的只是它的全局有序性被打断了——a 不同时b 的无序性让优化器没法利用。这不是说索引完全不能用而是用不上它的有序性可能要扫大片索引页再过滤。举一个调优时最典型的决策点(user_id, create_time)和(create_time, user_id)应该选哪个要看实际 SQL 的等值条件和排序需求。最常见的场景是查某个用户的最近记录等值条件是 user_id排序字段是 create_time那前者就是对的。3.4 索引设计的两个实用策略策略一利用索引下推MySQL 5.6 引入的索引下推ICP允许在索引遍历过程中先过滤一部分条件减少回表次数。它的前提是索引里包含被过滤的字段。所以设计联合索引时可以把 WHERE 里的等值条件字段和部分过滤字段一起设计进去。策略二短索引与冗余索引的取舍长字符串列如 URL做索引时可以只对前缀建索引比如INDEX idx_url_prefix (url(50))。但前缀索引有一个代价无法用于覆盖索引也无法做精确排序。所以在设计时务必要权衡不要把TEXT、BLOG类型的完整内容建索引InnoDB 对长字符串索引还有长度限制。还有个我反复强调的坑不要迷信能覆盖尽量覆盖。一个表上不要有大量冗余联合索引比如已经有了(a, b, c)又建了(a)和(a, b)这三者高度重叠前面几个基本白占空间。初始设计时可以把可能的查询模式列出来再反推索引别一股脑全建上。4. 高频 SQL 场景的实战调优4.1 避免 SELECT * 不只是减少网络传输很多人说不要 SELECT *是为了省网络带宽这话没错但只对了一半。对于 InnoDB 来说SELECT *意味着几乎必然回表。如果索引覆盖不了所有列优化器就只能先找到主键再一条条去聚簇索引里捞数据。这个回表的代价在高频查询下会被放大得非常明显。一个真实的对比实验同一张宽表字段数约 40 个数据量 300 万。-- 全字段查询 SELECT * FROM big_table WHERE type 2 AND status 1 ORDER BY create_time DESC LIMIT 20; -- 只查必要字段 SELECT id, title, create_time FROM big_table WHERE type 2 AND status 1 ORDER BY create_time DESC LIMIT 20;前一条平均耗时 180ms后一条在覆盖索引能命中时平均耗时 8ms。差别不是网络传输而是回表的次数和内存占用。所以设计查询时用到的字段越少覆盖索引越容易发挥价值。但这有一个度的问题不要为了少字段而把所有查询字段都拼进索引那样索引就太胖了。合理做法是只保证高频查询的核心字段被覆盖。4.2 深分页的痛你们公司迟早要遇到LIMIT 100000, 20这种写法是典型的时间杀手。MySQL 执行深分页时前面的 10 万行都得完整扫描并丢弃才能拿到最后 20 行。这在数据量小的时候不明显数据量上一百万就非常疼。我的优化方案基本就两种方案一基于游标的分页推荐把LIMIT offset, size改成记录上一页最后一条记录的位置-- 第一页 SELECT id, title FROM article WHERE status 1 ORDER BY id DESC LIMIT 20; -- 第二页记录上一页最后一条的 id 19999 SELECT id, title FROM article WHERE status 1 AND id 19999 ORDER BY id DESC LIMIT 20;原理很简单利用主键索引有序性每次只从确定的位置往后扫 20 条。这个方案有一个前提排序条件要能被索引支持且你有唯一的排序键通常是主键。如果排序字段不是唯一的要额外加一个唯一列做 tie-breaker。方案二延迟关联如果业务层确实需要 deep offset比如用户手动翻到第 5000 页可以用延迟关联SELECT a.id, a.title, a.content FROM article a INNER JOIN ( SELECT id FROM article WHERE status 1 ORDER BY id DESC LIMIT 100000, 20 ) t ON a.id t.id;内层只查询主键扫描代价小捞出 20 个 id 后再回表拿到完整数据。实测在很多场景下比直接LIMIT快一个数量级。这个方案的核心思路是先把代价大的查询收敛到主键上。4.3 JOIN 优化让小表驱动大表JOIN 是另一个高频问题区。MySQL 的嵌套循环连接NLJ在大多数场景下都是小表驱动大表更优。虽然 8.0 的优化器会自动选择驱动表但有些复杂 SQL 依然会跑偏。我的调优经验是连接条件字段必须有索引。没有索引驱动表每匹配一行都要去被驱动表做一次全表扫描这是最典型的 JOIN 慢症结。被驱动表的连接字段最好用主键或唯一索引这样每次匹配都是等值查询能直接定位到唯一记录。GROUP BY、DISTINCT 这类操作尽量在 JOIN 之前缩小数据量。最好是在子查询里先过滤再 JOIN 聚合结果。举个例子之前遇到过一条 SQLSELECT u.name, SUM(o.amount) FROM user u LEFT JOIN orders o ON u.id o.user_id WHERE o.status 1 GROUP BY u.id, u.name;这条语句的问题在于LEFT JOIN带来的语义和实际需求并不匹配——需求其实只是查所有有有效订单的用户。改成 INNER JOIN加上o.user_id上的索引后执行时间从 2.4 秒降到了 300ms。如果 JOIN 的表多、条件复杂还有一个思路拆开执行。先查出小表的结果集再分段查询。应用层多一次 I/O但每个查询都更可预测、更可控。4.4 GROUP BY 与 ORDER BY 的内存排序陷阱Using temporary; Using filesort这两个标记出现在 EXPLAIN 里意味着 MySQL 可能要在内存或磁盘建立临时表通常伴随巨大的性能损耗。处理思路GROUP BY 的字段顺序尽量和索引一致。比如索引是(city_id, age)那么GROUP BY city_id, age就能利用索引有序性避免额外排序。ORDER BY 的排序字段和 WHERE 的等值字段设计成联合索引。如果结果集本来就不大别过度设计索引MySQL 8.0 在内存排序上表现不差为了一条低频 SQL 建索引是浪费。一个实际案例业务需要按品类统计 7 天内的销量排行原本GROUP BY category_id ORDER BY sales DESC造成临时表文件排序。后来建立了(category_id, order_date, sales)联合索引GROUP BY 直接走索引有序路径临时表消失查询时间从 700ms 降到 60ms。4.5 关于子查询与 CTE 的一个提醒MySQL 8.0 对子查询的优化改进明显但仍旧建议在写复杂查询时多用 CTE 让 SQL 可读性更强同时便于分析哪一步是性能瓶颈。CTE 不是性能优化工具但它强制你把逻辑拆分成明确步骤更容易通过EXPLAIN定位问题。一个我之前处理过的案例SELECT * FROM orders WHERE user_id IN ( SELECT id FROM users WHERE vip_level 3 ) ORDER BY create_time DESC;在 MySQL 5.7 下这条 SQL 会物化子查询会先把 vip_level 3 的用户查出来放在临时表再和 orders 做半连接。表面看只扫了 VIP 用户但临时表没有索引orders 表连接时反而容易全表扫。改成 JOIN 后用vip_level和user_id上的索引效果立刻不一样。5. 优化器不听话统计信息与改写策略5.1 统计信息过期再好的 SQL 也白搭上文已经提到统计信息的重要性。此处再补充几个实用的命令-- 查看表的统计信息 SHOW TABLE STATUS LIKE orders; SHOW INDEX FROM orders; -- 强制重新计算统计信息 ANALYZE TABLE orders;在开启innodb_stats_auto_recalc时InnoDB 会在表发生变化后自动重新计算统计信息但它有个阈值并不是每次 DML 都触发。如果表数据发生剧烈变化比如一次大量导入、大批量 DELETE一定要手动ANALYZE TABLE。我的习惯是在每次大任务跑完后都手动分析一次相关表这成本很低但能避免很多诡异问题。5.2 优化器选错索引怎么纠正现实中确实有统计信息新鲜但优化器还是选错索引的情况尤其是当数据分布极度倾斜的时候。比如订单表里商家 A 有 80% 的行商家 B 只有几十行。WHERE merchant_id B用merchant_id索引很好但优化器估算成本时可能因为基数问题仍然选择全表扫。遇到这种情况我的偏好顺序是先ANALYZE TABLE排除统计信息问题。再尝试改写 SQL让写法更贴合索引语义比如去掉外层函数、调整 JOIN 顺序。最后才考虑FORCE INDEX或者USE INDEX。这类 hint 在生产脚本里要写清楚原因和日期方便后续 DBA 排查。举一个例子SELECT * FROM orders FORCE INDEX (idx_user_id) WHERE user_id 10086 ORDER BY create_time DESC LIMIT 10;FORCE INDEX不是万能药。它会让优化器放弃对其他索引的评估。如果后续数据分布变了这个 hint 可能变成负优化。所以凡是加 hint 的 SQL我都会写注释并设置定期复查提醒。5.3 有一条 SQL 用了三个 OR绝对是个重灾区OR 条件的优化是另一个经典问题。WHERE a 1 OR b 2这种写法MySQL 8.0 在复杂 OR 场景下可能存在索引合并Index Merge但合并本身有额外成本。最简单的改写方案是拆成两个查询用 UNION DISTINCT 或 UNION ALL 合并-- 原始写法 SELECT * FROM t WHERE a 1 OR b 2; -- 改写为 TWO-PASS 写法 SELECT * FROM t WHERE a 1 UNION SELECT * FROM t WHERE b 2;注意UNION会做去重如果业务上确认两侧不会重复优先用UNION ALL省掉一次排序和去重开销。改写之后两侧各自能走 a 和 b 上已有的索引。6. 真实调优案例复盘6.1 案例一一条 1 亿行的模糊查询从 8 秒到 100ms这个案例来自一个模拟项目X的订单流水表数据量接近 1 亿行。原始需求是查询某用户在某个时间段内的支付流水SQL 大概长这样SELECT * FROM payment_flow WHERE user_id 10010 AND pay_time BETWEEN 2024-01-01 00:00:00 AND 2024-01-31 23:59:59 ORDER BY pay_time DESC;表面上条件简单user_id有索引但执行了 8 秒。打开执行计划一看key 是idx_user_idrows 估算 120 万行发生了大量回表。原因很简单user_id单列索引只能定位到用户的所有流水一百多万行都要在聚簇索引里按pay_time排序然后才返回最新 20 条。压根没用上pay_time的有序性。优化方案把索引改成(user_id, pay_time)联合索引。这样索引树本身已经按用户和时间排好序查询直接定位到目标位置往前扫 20 条就行。最终执行计划显示Using index condition耗时从 8 秒降到 100ms 级别。复盘一下为什么当初只建了单列索引因为设计表时以为 user_id 选择性足够没考虑排序问题是回表的代价值。这就是从查询模式反推索引的重要性。6.2 案例二一个 JOIN 引发的总额翻倍另一个印象深刻的案例不是性能问题而是逻辑正确性问题但排查手段和 SQL 优化完全一致。业务报表需要统计每个店铺的有效订单总额原本写法SELECT store_id, SUM(amount) FROM orders o JOIN order_item i ON o.order_id i.order_id WHERE o.status 1 GROUP BY store_id;每条订单有多个商品明细JOIN 之后一行订单就变成了 N 行商品行SUM(amount)会把订单金额重复加 N 次。这类问题在排查慢 SQL 时特别容易暴露——因为 JOIN 以后行数膨胀扫描行数暴增执行计划看着就异常。纠正方法先按订单维度聚合再 JOIN 店铺信息SELECT o.store_id, t.total_amount FROM ( SELECT order_id, SUM(amount) AS total_amount FROM orders WHERE status 1 GROUP BY order_id ) t JOIN orders o ON o.order_id t.order_id;SQL 调优不只是让查询变快还要保证逻辑不被 JOIN 破坏。这也是我每次看完执行计划后会再核对一遍返回行数和结果明细的原因。6.3 案例三记录一下加了索引反而变慢的真实经历有次给项目的日志表加了一个(level, create_time)联合索引期望是加速WHERE level ERROR AND create_time ...的查询。但上线后这个查询变慢了。问题出在数据分布level ERROR的日志占比超过 60%。走索引意味着 60% 的行都要走索引扫描回表而全表扫是顺序读成本模型下优化器选择全表扫反而更快。后来把索引改成(level, app_id, create_time)把等值选择性更强的 app_id 加进去才真正发挥作用。这个案例说明索引设计不能只看有没有索引还要想这个索引能不能显著缩小范围。选择性太差的索引不如没有。7. 常见问题与排查技巧实录7.1 排查慢 SQL 的步骤清单我把实际工作中固定的排查链路整理如下按顺序走基本不会漏打开慢查询日志按耗时排序找到目标 SQL。用EXPLAIN分析执行计划记录 type、key、rows、Extra。对比Rows_examined和Rows_sent确认是否存在扫描放大。检查表数据量级和统计信息是否需要ANALYZE TABLE。查看表结构和索引集合反推查询模式是否存在可优化的索引路径。陆续尝试 SQL 改写、索引调整每步用 EXPLAIN 验证执行计划变化。在压测环境回放真实请求确认性能提升的同时没有逻辑偏差。7.2 一个速查表常见慢 SQL 症状对应方案症状可能原因优化思路EXPLAIN 显示 typeALL无可用索引或索引失效检查 WHERE 条件字段确认索引存在且无函数/隐式转换Extra 里有 Using temporaryGROUP BY / DISTINCT 无法利用索引调整索引顺序让 GROUP BY 字段与索引前缀一致Extra 里有 Using filesortORDER BY 无法利用索引让排序字段纳入索引或减小结果集深分页慢offset 过大导致扫描大量行改为游标分页或延迟关联JOIN 慢连接字段无索引或被驱动表全表扫给连接字段加索引小表驱动大表加了索引仍慢选择性差或统计信息过期ANALYZE TABLE或调整索引字段组合查询结果小但 rows 估算巨大预估与实际数据分布不一致手动更新统计信息必要时用 ANALYZE TABLE 修正7.3 经验之外的经验备份先做、变更要小步走调优过程中最怕的是为了验证一个猜想在线上直接建索引、删索引。我吃过亏也见过同事吃更大的亏。我的操作习惯是永远在压测环境先验证再上生产。变更索引时先加新索引等查询验证完毕再考虑删除旧索引。对 SQL 本身做改写时先用小数据量验证结果一致再做全量压测。所有 DDL 操作放在低峰期执行必要时并行度调低。7.4 聊聊 MySQL 8.0 新特性对调优的帮助如果有人还在用 5.7我会认真建议升级到 8.0除了安全性和功能增强对 SQL 调优也有实打实的帮助EXPLAIN ANALYZE直接输出每个环节的实际耗时与行数比只看估算值直观得多。优化器引入了更细的成本模型在某些 JOIN 场景下选择更合理。窗口函数让复杂的排序、分组分析 SQL 不再依赖一堆临时表和子查询。不过 8.0 也有个坑默认字符集是utf8mb4_0900_ai_ci如果从 5.7 迁移过来要注意排序规则改变可能导致索引失效或行为变化。之前就有人从 5.7 迁移到 8.0 后一条 SQL 从快变慢排查半天发现是utf8mb4_general_ci和utf8mb4_0900_ai_ci的隐式转换导致索引失效。这类细节通常在帮助文档里不会多说但实际运维中特别磨人。我的建议是迁移前先比对所有表字段的字符集与排序规则防止隐性类型转换。8. 最后分享两个压箱底的小技巧再分享两个未必能写进报告、但实操中非常有用的技巧。技巧一给 EXPLAIN 结果加上行数对比很多人看 EXPLAIN 只看 rows但这个 rows 是优化器的估算值跟实际扫描行数经常差得离谱。我的做法是把慢日志里的Rows_examined和EXPLAIN里的rows放在一起对比如果二者差异超过一个数量级说明统计信息失真比较严重先去ANALYZE TABLE再看执行计划。技巧二用 PERFORMANCE_SCHEMA 排查锁等待慢 SQL 不一定都是索引问题也有可能是锁等待。SHOW PROCESSLIST里能看到State字段如果卡在Waiting for table metadata lock或Updating说明可能是 DDL 或大事务阻塞。此时我会查performance_schema.data_lock_waits这类表找到锁的持有方。锁问题的排查路径和 SQL 调优不同但前期定位证据是相同的先看慢日志再分析具体是 CPU 高、IO 慢、还是锁阻塞。别一上来就改 SQL可能白忙一场。继续深入的话SQL 调优还有很多可以聊的比如分区表、读写分离、查询缓存8.0 已经移除、并行查询与并行复制等等。但对绝大多数线上问题来说把上面这些基本功打扎实就已经能解决八成的性能顽疾。调优不是一招鲜而是靠定位 - 分析 - 验证 - 复盘的循环反复打磨。我见过很多团队在最基础的问题上浪费了最多时间——明明表上没索引还在聊什么优化器逻辑。先把地基打好很多疑难杂症会半路自动消失。