接口响应时间从 200ms 飙到 3s排查一圈发现是数据库在拖后腿这种场景在座各位后端估计都不陌生。最近团队在做接口性能治理我把线上几十条慢 SQL 逐个捞出来分析了一遍该加索引的加索引该改写语句的改写语句压测下去平均响应时间直接砍掉 70%。这篇文章就把我这次完整踩坑和优化的过程整理出来从慢 SQL 定位、执行计划解读、索引设计到常见的 SQL 写法陷阱一步一步手把手讲清楚。不管你是刚入行的后端新人还是已经写过几年 CRUD 想系统补一补数据库性能优化的同学照着这套思路去排查基本能覆盖线上 80% 的慢查询场景。老实说MySQL 优化这个话题网上资料一搜一大把但大部分要么只讲单点技巧要么直接丢一堆理论看完回到自己项目里还是不知道怎么下手。所以我换了个思路不跟你扯太多底层原理直接从一次真实的线上事故说起接口为什么会越跑越慢慢 SQL 是怎么被揪出来的每一类慢 SQL 又是怎么一步步优化到飞起的全程附带可以直接抄作业的 SQL 和配置你只要对着自己的库表改改表名就能用。1. 先定位慢SQL没有数据支撑的优化都是瞎调1.1 打开慢查询日志让数据库自己“检举揭发”很多同学一遇到接口变慢第一反应是去看应用日志、加缓存或者直接凭感觉给某张表加索引。我见过最离谱的一次同事觉得 WHERE 条件里的 status 字段用得频繁咔咔加了个单列索引结果 EXPLAIN 一看压根没走因为区分度太低优化器觉得全表扫描更快。所以优化的第一步永远是先让数据说话——哪些 SQL 慢、慢在哪里、执行了多少次这些都得先摸清楚。MySQL 里有个现成的工具就是慢查询日志Slow Query Log。默认它是关闭的需要通过参数打开。我个人建议在测试环境直接开线上如果担心性能影响可以只开一段时间做专项排查或者把阈值调高一点只记录真正有问题的语句。-- 查看当前慢查询日志状态 SHOW VARIABLES LIKE slow_query_log; SHOW VARIABLES LIKE long_query_time; SHOW VARIABLES LIKE slow_query_log_file; -- 开启慢查询日志动态生效重启后失效 SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; SET GLOBAL log_queries_not_using_indexes ON;这里有几个参数要解释一下。long_query_time单位是秒我习惯设为 1也就是超过 1 秒的 SQL 都会被记录下来。如果线上压力大可以放宽到 2 或 3否则日志量会特别恐怖。log_queries_not_using_indexes这个参数我建议也打开它能记录所有没走索引的查询——虽然会产生大量日志但对于排查“隐式索引失效”这类问题特别有用用的时候注意按需开启和关闭。慢查询日志文件通常是文本格式在数据目录下比如/var/lib/mysql/mysql-slow.log。直接tail看也能看但内容多的时候建议用官方的mysqldumpslow工具来做聚合统计。1.2 用 mysqldumpslow 和时间窗口分析定位高发SQL慢查询日志里每一行都记录了时间、用户、主机、执行耗时、锁等待时间、返回行数、扫描行数和 SQL 原文。但日志文件一旦大了肉眼根本看不过来。mysqldumpslow这个工具就是干这个的它能按执行次数、耗时等维度做汇总。# 按执行次数排序显示前10条 mysqldumpslow -s c -t 10 /var/lib/mysql/mysql-slow.log # 按平均耗时排序显示前10条 mysqldumpslow -s at -t 10 /var/lib/mysql/mysql-slow.log注意mysqldumpslow会把 SQL 里的数字、字符串参数替换成N和S所以相同的 SQL 模板会被聚合到一起这个设计非常贴心。比如同样的SELECT * FROM orders WHERE user_id 1001和WHERE user_id 1002会被聚合成一条带变量的模板这样就能看到哪一类 SQL 整体最慢、执行最频繁。我一般会结合时间窗口来分析。比如线上某次发版后接口变慢那就重点看发版前后半小时的慢日志对比慢 SQL 的数量和类型变化。另外观察慢日志里的Rows_examined和Rows_sent这两个值也很关键。如果扫描了十万行只返回 20 行那大概率是索引缺失或者 SQL 写法有严重问题如果两个值都很大可能是业务逻辑本身需要处理大量数据那就得从业务层面优化接口设计比如异步化或分批处理。1.3 顺藤摸瓜从接口链路反查SQL来源慢日志只能告诉你哪条 SQL 慢但很多时候一个接口背后有十几条 SQL你得知道这条慢 SQL 是哪个接口发出来的。这里我推荐两个思路。第一是在应用层加日志。用 Spring Boot 的话可以在 MyBatis 的 interceptor 或者 Druid 连接池那边加一个慢 SQL 监听。Druid 本身就支持slowSqlMillis参数超过阈值的 SQL 会自动输出到日志里还能带上执行时间。第二是用performance_schema来审计。MySQL 5.7 自带这个库里面记录了完整的语句执行历史包括语句文本、执行耗时、锁等待时间、扫描行数等。这里有个关键点我们可以通过thread_id把慢 SQL 和应用连接池里的连接对应起来。不过这个排查链路比较重一般适合事后分析。应用日志方案对大多数团队来说更实用毕竟定位到具体接口以后直接查代码就能看到是哪个方法执行了这条 SQL。我的建议是常规排查先把慢日志聚合结果 应用日志里最近几分钟的堆栈结合起来看90% 的情况都能快速锁定到具体接口。真遇到那种极端隐蔽的再用 performance_schema 深挖。2. EXPLAIN执行计划一封SQL的“体检报告”2.1 只看这几个字段就够了type、key、rows、Extra拿到慢 SQL 之后第一件事就是给它做个体检——执行EXPLAIN。这里要提醒一点EXPLAIN 只是预估执行计划不是真实执行结果但对优化决策来说已经足够了。EXPLAIN SELECT * FROM orders WHERE user_id 1001 AND status 1 ORDER BY create_time DESC LIMIT 20;看到的结果是一张表格字段很多但别慌我只重点关注四个type、key、rows、Extra。这四个字段基本就能判断一条 SQL 的健康状况。type是访问类型从好到差依次是system、const、eq_ref、ref、range、index、ALL。我对团队的要求是业务 SQL 最低不能低于rangeref是正常水平ALL和index必须列为重点优化对象不优化就要写说明。key表示实际选用的索引名。如果key为 NULL说明这条 SQL 没有命中任何索引大概率是全表扫描了。rows是优化器预估要扫描的行数这个数越小越好。Extra里如果出现Using filesort或者Using temporary通常意味着排序和分组没走索引开销会大不少。2.2 key_len索引到底吃掉了多少查询条件key_len这个字段容易被忽略但它很有价值。它表示 MySQL 在索引里使用的字节数可以用来判断联合索引里到底“吃到”了第几个字段。举个例子假设有一张订单表建了一个联合索引(user_id, status, create_time)三个字段分别是INT、TINYINT、DATETIME长度分别是 4、1、5DATETIME 在 MySQL 5.6 实际是 5 字节旧版是 8 字节。如果 EXPLAIN 结果显示key_len 4说明只用了user_id这一个字段如果是 5说明用到了user_id status如果是 10说明三个字段都用上了。这个判断有什么用最常见的就是定位“联合索引只走了一部分”的问题。比如你明明建了三列联合索引结果 EXPLAIN 显示key_len 4那就要回头检查 WHERE 条件里是不是跳过了中间的那个字段——联合索引遵循最左前缀原则一旦跳过后面的索引列基本就废了。下面这段是验证方法-- 假设索引 idx_user_status_time ON orders(user_id, status, create_time) EXPLAIN SELECT * FROM orders WHERE user_id 1001 AND create_time 2024-01-01; -- key_len 4说明只有 user_id 用上了索引create_time 虽然也在索引里但没被用到2.3 常见Extra信息背后的真实含义Extra里的信息是最容易被忽视又最关键的。我整理了日常优化中最高频出现的几类Using filesortMySQL 需要额外一次排序操作。常见于ORDER BY字段不在索引里或者排序字段没按照联合索引的顺序来。出现这个标记时性能损耗很大尤其是排序数据量大的时候。解决办法通常是调整索引让排序字段包含在联合索引里并且顺序与查询的排序方向一致。Using temporary使用了临时表。常见于GROUP BY或DISTINCT操作数据量一大临时表可能会落到磁盘上性能断崖式下跌。遇到这种情况优先思考能不能通过索引直接消除临时表。Using index覆盖索引扫描这是最理想的情况。代表查询所需字段都在索引里不需要回表查原表效率极高。Using where表示存储引擎层返回数据后server 层还要再过滤一遍。这个标记单独出现不算坏事但如果跟Using index一起出现说明索引覆盖了一部分条件但不完全。Using index condition表示走了索引下推ICPMySQL 5.6 的优化能减少回表次数通常是个好信号。Using MRR多范围读优化适用于 range 查询相对少见但出现时一般说明执行计划不赖。建议每个后端同学把下面这张表收藏起来分析执行计划时对照着看Extra信息含义影响优化方向Using filesort需要额外排序高让排序字段走索引Using temporary使用临时表高优化 GROUP BY / DISTINCT利用索引Using index覆盖索引无需回表极低保持Using where服务层额外过滤中考虑调整索引Using index condition索引下推低保持NULL普通查询视 rows 而定结合 rows 判断3. 索引优化实战建对索引查询快十倍3.1 索引为什么能快B树和回表很多人知道加索引能变快但没搞懂为什么结果用错了姿势。我尽量用大白话解释。MySQL InnoDB 的索引结构是 B 树这棵树的叶子节点存了整行数据聚簇索引或者索引列的值 主键二级索引。查询时通过 B 树能够在 O(log N) 的复杂度内快速定位到目标数据而不是傻乎乎地把整张表从头到尾扫一遍。上图中的两级索引就是关键如果是二级索引非主键索引叶子节点里只有索引字段本身的值和主键值。查询时如果 SELECT 的字段除了索引列和主键还有其他字段就得拿着主键去聚簇索引里再查一次完整记录这个过程叫回表。回表一次问题不大但如果是百万行级别的范围查询每一行都要回表一次性能就会急剧下降。理解了这一点很多优化思路就自然浮现了让查询尽量少回表最好一次索引扫描就拿到所有数据。这就是覆盖索引的由来。3.2 最左前缀原则组合索引的黄金法则组合索引要发挥作用必须遵守最左前缀原则。也就是说查询条件里必须包含组合索引最左边的列索引才会被用到跳过最左列索引直接失效。举个例子索引idx_user_status建立在(user_id, status)上-- 走索引 SELECT * FROM orders WHERE user_id 1001 AND status 1; SELECT * FROM orders WHERE user_id 1001; -- 不走索引 SELECT * FROM orders WHERE status 1;这个原则用生活里的字典查词来比喻特别贴切一部按拼音排的词典你要查“张”这个字必须先知道它首字母是 z再看第二个字母是 a一路顺着找下去。如果你只知道第二个字母是 a得翻遍整本词典的每一页才能找到——联合索引也是这个道理。所以我建组合索引时有个习惯把区分度最高的字段放在最左边把查询频率最高的字段放在最左边两者冲突时优先保障“等值查询”的字段放在“范围查询”字段前面。因为范围查询、、BETWEEN后面的索引列通常无法继续发挥作用必须排在等值字段后面。3.3 覆盖索引连回表都省掉覆盖索引是性能优化里性价比最高的一招。它的意思就是查询所需的所有字段恰好都存在于某一个索引中不用再回头去主键索引里捞数据。举个典型例子订单表里按用户查订单业务上只需要订单号和订单金额SQL 可以写成SELECT order_no, amount FROM orders WHERE user_id 1001;如果建了(user_id, order_no, amount)这个组合索引那么查询时B 树扫到叶子节点就直接拿到了order_no和amount不需要回表。EXPLAIN 里会出现Using index这种执行计划性能极好。我前后对比过两个方案的耗时。同样是百万级数据量普通二级索引 回表平均 180ms用覆盖索引平均 5ms。这种量级的差距是质的飞跃。所以在设计索引时我在满足 WHERE 条件之外还会顺手看一眼 SELECT 的字段列表尽量把高频查询的字段塞进索引里。不过也别贪多索引字段过多会导致索引体积膨胀写操作的成本也会增加要权衡。3.4 索引不是越多越好冗余索引和写入成本很多新手以为索引是万金油越多越好结果一张表建了十几个索引写操作越来越慢磁盘空间也蹭蹭涨。索引是要付出代价的每一次 INSERT、UPDATE、DELETEMySQL 都要同步维护这张表上的所有索引。索引越多写放大越严重。我见过最离谱的线上事故是一个订单流水表建了 12 个索引每天几百万行写入结果主库的 TPS 硬生生被拖到不足原来的三分之一。最后删掉了 6 个冗余索引写性能才恢复过来。所谓冗余索引就是多个索引之间存在前缀重叠。举个例子如果已经建了idx_user_status(user_id, status)同时又单独建了一个idx_user(user_id)那么idx_user就是冗余的因为idx_user_status的最左列就是user_id完全能覆盖idx_user的功能。冗余索引不仅浪费空间还拖慢写入速度是典型的负资产。所以我维护表结构时有个原则每次新增索引前先跑一下SHOW INDEX FROM table_name看看是否有可以被新索引替代的旧索引。宁可少建一个不可多建一个。4. SQL写成这样索引再好也得跪五大高频坑点4.1 隐式类型转换字段是varchar却传了数字这是线上最常见的索引失效场景而且特别隐蔽。表里的字段定义的是VARCHAR(20)代码里却直接传了一个数字类型进去比如-- user_phone 字段类型是 VARCHAR但这里传了数字 SELECT * FROM users WHERE user_phone 13812345678;MySQL 的优化器在面对字段类型和参数类型不一致时会自动做类型转换把两边统一之后再去匹配。问题就出在这个转换上当字段是 varchar、参数是 int 时MySQL 会尝试把字段值转换为数字再比较。结果就是字段值上的索引函数生效索引直接失效变成全表扫描。验证方法也很简单用EXPLAIN看type是不是变成了ALL或者key变为 NULL。修复姿势就是让类型一致SELECT * FROM users WHERE user_phone 13812345678;另外还有一种常见隐式转换是字段是utf8字符集而参数是utf8mb4字符集也会导致索引失效。这类问题在联表查询时尤其多发建议统一库表字符集能省掉很多隐形坑。4.2 深分页LIMIT 100000, 20 为什么越翻越慢分页查询越往后翻越慢这个现象后端同学应该都遇到过。原因要从 B 树的扫描方式说起。LIMIT 100000, 20在 MySQL 里不是直接定位到第 100000 行拿 20 行数据而是先把前 100000 行全部扫出来然后丢掉只要最后 20 行。这意味着页码越深扫描的数据量越大响应时间自然线性上升。我优化过最典型的一个案例一个列表接口翻到第 5000 页时SQL 执行耗时到了 4.2 秒。原始 SQL 长这样SELECT id, title, create_time FROM articles WHERE category_id 10 ORDER BY create_time DESC LIMIT 100000, 20;优化方案是改成“先定位到上一页最后一条数据的某个排序字段值再用这个值去过滤”。也就是业内常说的“键集分页”或“seek 分页”-- 上一页最后一条记录的 create_time 是 2024-06-01 12:00:00 SELECT id, title, create_time FROM articles WHERE category_id 10 AND create_time 2024-06-01 12:00:00 ORDER BY create_time DESC LIMIT 20;改造后这条查询能直接走(category_id, create_time)联合索引定位到目标位置附近再取 20 行扫描量从十万级直接降到几十行执行耗时从 4.2 秒降到 8ms。分页接口如果允许尽量用这种方案替代传统 LIMIT 深分页。4.3 COUNT(*) 的姿势问题COUNT(*)这个操作在 MyISAM 里是有缓存优化过的但在 InnoDB 里没有。InnoDB 需要扫描主键索引来逐行计数。数据量一大COUNT(*)就会成为接口的性能瓶颈。这里有个很反直觉的事实COUNT(*)并不比COUNT(1)慢因为 MySQL 对COUNT(*)做了专门优化不需要读取行的实际内容。真正有问题的是COUNT(某个字段)因为 MySQL 必须读取该字段的值来判断是否为 NULL。所以能用COUNT(*)就不要用COUNT(字段)。如果接口必须频繁统计总量常见方案是维护一个计数表在事务里对计数进行增删操作或者用 Redis 的INCR/DECR做计数缓存。注意 Redis 方案要注意数据一致性问题我的建议是对一致性要求不高、允许短暂延迟的场景用 Redis对计数必须严格的场景用事务内计数表更靠谱。4.4 OR条件与函数包裹导致索引失效先看两个反面例子-- 反面1OR 条件导致索引失效 SELECT * FROM orders WHERE user_id 1001 OR status 1; -- 反面2函数包裹导致索引失效 SELECT * FROM orders WHERE DATE(create_time) 2024-06-01;OR降低索引选择性的原因在于MySQL 需要分别评估user_id 1001和status 1两个条件此时如果status没有索引就可能退化为全表扫描即使两个条件都有索引优化器也需要做索引合并Index Merge性能也会打折。一个常见的改写方案是改成 UNION ALL-- 改写两个独立查询再合并 SELECT * FROM orders WHERE user_id 1001 UNION ALL SELECT * FROM orders WHERE status 1;但注意上面这个改写也有前提两个条件都分别有索引且两个集合不会出现大量重复行。否则 UNION ALL 反而更慢。在实际业务里我更推荐调整查询逻辑把 OR 条件拆成多个查询或用IN替代SELECT * FROM orders WHERE user_id IN (1001, 1002, 1003);IN列表在 MySQL 中是有优化的尤其是配合范围扫描时会比多个 OR 高效得多。函数包裹的问题更直白DATE(create_time)让列上发生了函数运算B 树里存的原始值根本没法直接参与查找必须逐行计算才能过滤索引自然失效。正确写法是SELECT * FROM orders WHERE create_time 2024-06-01 00:00:00 AND create_time 2024-06-02 00:00:00;范围查询能命中索引效果一样但一个是全表扫描一个是索引范围扫描性能差异巨大。4.5 JOIN与子查询的改写策略联表查询多了以后慢 SQL 的概率直线上升。这里要强调一个原则小表驱动大表。MySQL 的嵌套循环连接算法会对外表驱动表的每一行去内表查找匹配行。如果驱动表有 1 万行、内表有 10 万行循环次数就是 1 万次内表查找反过来驱动表 10 万行、内表 1 万行循环次数就变成 10 万次。所以在 JOIN 时尽量让数据量小的表作为驱动表并且保证被驱动表的关联列有索引。有时候用 EXISTS 和 IN 的子查询也能改写 JOIN以降低数据扫描量。例如原本是-- 关联了 10 万行的订单表和 1 万行的用户表 SELECT u.name, o.amount FROM users u JOIN orders o ON u.id o.user_id WHERE o.create_time 2024-01-01;如果业务只需要返回有订单的用户信息可以改成SELECT u.name, o.amount FROM orders o JOIN users u ON u.id o.user_id WHERE o.create_time 2024-01-01;调整了驱动表和连接顺序后配合(user_id, create_time)这类索引查询效率会好很多。但这涉及 MySQL 优化器的具体决策如果拿不准就老老实实跑EXPLAIN看执行计划用实际数据决策。5. 常见问题与排查技巧实录5.1 索引明明建了却不生效这是被问得最多的问题也是排查慢 SQL 时最让人脑壳疼的场景。我总结了几类“索引未生效”的高频原因做成速查表场景原因验证方式解决方案WHERE 字段类型与参数不一致隐式类型转换EXPLAIN 看 key 为 NULL改参数类型为字符串对字段使用函数或表达式索引列发生运算EXPLAIN 看 key 为 NULL改成范围查询使用 LIKE %关键词前置通配符无法走索引EXPLAIN 看 typeALL改成 关键词%OR 连接了未索引条件索引合并失败EXPLAIN 看 typeALL拆分查询或加索引联合索引跳过中间列最左前缀被破坏EXPLAIN 看 key_len调整 WHERE 顺序或重建索引优化器选择全表扫描索引区分度低优化器认为全表更快EXPLAIN 看 rows使用 FORCE INDEX 或优化 SQL 逻辑这里想特别解释一下为什么区分度低的索引比如 status 字段只有 0/1 两个值会出现“建了索引但没用”。B 树索引的核心价值在于快速缩小搜索范围但如果某个值的记录占了全表一半MySQL 觉得扫索引再回表还不如直接全表扫描快优化器就会放弃索引。所以建索引时优先选择区分度高的字段主键ID、订单号、手机号这类。区分度极低的字段更适合做组合索引的次位。5.2 CPU飙高、连接池打满慢SQL引发的连锁反应慢 SQL 不只是让单个接口慢严重时会把整个数据库拖垮。我经历过一次线上事故某列表接口一次查询扫描了 3000 万行数据执行耗时 8 秒QPS 一高数据库 CPU 直接跑到 100%连接池被打满所有依赖这个库的业务全部超时。这种连锁反应非常致命。排查思路一般是这样的先看数据库当前的活跃连接数和耗尽情况-- 查看当前所有连接状态 SHOW PROCESSLIST; -- 查看连接数统计 SHOW STATUS LIKE Threads_connected; SHOW STATUS LIKE Threads_running;如果Threads_running长期大于 CPU 核数基本可以判断有高消耗 SQL 在持续执行。SHOW PROCESSLIST里Time列特别大的连接就是慢 SQL 的元凶拿到对应的Id后可以用KILL id把卡死的会话干掉先止血。接下来要做的是“治根”把对应 SQL 捞出来优化。很多人到这一步就急着加索引其实不对。第一步应该是看这条 SQL 是不是必须的能不能从业务上减少调用频率。比如加一层 Redis 缓存、做结果缓存、限制单页数据量、加布隆过滤器先拦截无效查询——都能大幅降低数据库压力。我见过不少团队花大力气优化一条本可以不查的 SQL事后发现直接砍掉这个查询接口直接快了几十倍。5.3 参数调优tmp_table_size、join_buffer 这些参数别乱调排查慢 SQL 时大家很容易忽略几个会话级参数但它们在很多场景下能救命。tmp_table_size内存临时表的上限超过这个值临时表会落到磁盘。如果 EXPLAIN 里看到Using temporary而且Created_tmp_disk_tables这个状态值很高说明频繁在磁盘上建临时表可以适当调大这个参数。但注意别盲目调到 1GB内存也得够用才行。join_buffer_sizeJOIN 时如果被驱动表没有可用索引MySQL 会使用块嵌套循环连接这个参数控制 JOIN buffer 的大小。调大它可以在某些场景下显著减少扫描次数但同样有内存风险。我的建议是先看SHOW STATUS LIKE Select_full_join如果这个值长期不为 0说明有查询因为没有索引在做全表 JOIN优先加索引而不是调 buffer。sort_buffer_size排序缓冲区。遇到Using filesort且数据量大的时候这个参数值得关注。但每个连接都会分配独立的 sort buffer连接数多时内存消耗会成倍放大调的时候要谨慎。一句话总结参数调优要基于实际 SQL 的执行状态来调不能闷头乱调。看不出来问题在哪就先回第 2 节看 EXPLAIN。5.4 缓存层兜底Redis不是万能的但确实该用索引优化做到位、SQL 也改写清楚了但线上热数据的高频查询依然会打穿数据库。这时合理的缓存策略能起到“最后一层兜底”的作用。我自己在做接口性能优化时会在数据库层之上设计两层缓存第一层是本地缓存Caffeine适合单机热数据读取是纳秒级第二层是 Redis 分布式缓存适合集群共享的热数据。缓存穿透、缓存击穿、缓存雪崩这三个经典问题处理不好缓存不仅不救火反而会放大问题。我这里给一个比较稳健的组合方案缓存空值并设置极短 TTL解决缓存穿透单个热点 key 过期时用分布式锁或互斥重建缓存解决缓存击穿缓存过期时间加随机值避免大量 key 同时过期压垮数据库解决缓存雪崩。这套组合在大流量接口上我验证过多次效果很稳定。但缓存毕竟是在 SQL 优化之后的“第二道防线”优先把慢 SQL 本身治好了再上缓存否则数据不一致问题会让你更痛苦。结尾聊聊我个人对这些优化手段的体会做慢 SQL 优化这些年我有一个很深的感触很多性能问题不是技术难度高而是排查思路不对。拿到一条慢 SQL不要急着改索引先去理解它为什么慢是全表扫描、排序开销大、临时表落盘还是优化器选错了索引。EXPLAIN 永远是第一步执行计划骗不了人。这次优化项目里我最后还留了一条经验给团队慢查询日志和生产监控面板要常态化开着每周看一次 Top 慢 SQL发现趋势性上升的 SQL 提前处理别等接口真的卡到用户投诉了才动手。性能优化是个持续迭代的过程不是一次大扫除就完事了。你的库里总有新业务、新数据、新索引SQL 性能也会随之波动。另外一个实用技巧每次上线涉及 SQL 变更时都顺手把变更前后的 EXPLAIN 对比记录贴到发布备注里哪怕只有自己看。时间久了你对自己库的执行计划会形成一种直觉看到一条 SQL 大概就能猜出它慢在哪里。这种直觉是任何工具都替代不了的经验资产。