
二线电商项目的核心库压测不过开发着急上线DBA把慢查询日志甩我脸上一张三千万行的订单明细表简单按用户查订单都要三秒以上。后来花了两周把这本MySQL慢查询到极致性能的书从读题到交卷完整走了一遍写下来算是对自己踩坑经历的一次沉淀。这篇更适合手里已经有业务在跑、被慢SQL折磨过、准备系统做一轮优化的同学读不太适合纯零基础的朋友但如果你连慢查询日志都还没开过从第一节看也能少走大半年弯路。我尽量用真实处理过的那张表做线索来讲涉及的命令和参数都是线上打磨过的可以直接抄作业。优化这事里面重要的往往不是某一个奇技淫巧而是你有没有一套从上到下的排查顺序顺序对了慢查询就是一道送分题。1. 慢查询日志第一步永远是定位不是瞎猜1.1 慢查询日志的正确打开方式我见过太多人上来就EXPLAIN一条SQL然后对着执行计划改索引改了半天发现根本不走他新加的索引然后又去查缓存、调参数折腾一整天最后发现是凌晨的统计任务把CPU打满了。所以优化前第一件事永远是打开慢查询日志让数据库告诉你它哪里不舒服。线上MySQL默认long_query_time是10秒这个阈值对现代业务来说形同虚设。想象一个用户点击查询要等10秒才被记录这页面早被用户关掉了。我一般上线第一件事就把它改成1秒业务高峰期如果慢SQL太多再临时改成0.5秒抓一次全量样本。注意这个参数是支持动态修改的不用重启SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; SET GLOBAL log_queries_not_using_indexes ON;第三行log_queries_not_using_indexes容易被忽略它的意思是哪怕一条SQL执行只要0.01秒但只要没走索引也记进慢查询日志。这个开关在优化初期非常有用很多全表扫描就是被它揪出来的。不过生产环境不建议长期打开因为一些本该扫描小表的正确SQL也会被记录日志量会暴涨。慢查询日志文件的位置可以通过SHOW VARIABLES LIKE slow_query_log_file查看。很多人这时候会直接用vim打开日志文件看几十MB的日志翻起来想哭而且格式不友好。我的习惯是把日志导出来后先用mysqldumpslow做聚合统计mysqldumpslow -s c -t 10 /var/lib/mysql/slow.log-s c表示按次数排序-t 10取前十条。这个工具会智能地把SQL里的数字参数替换成N、字符串替换成S从而把同模板的SQL聚合到一行输出结果类似下面这样Count: 323 Time2.36s (762s) Lock0.00s (0s) Rows_sent1.0 (323) SELECT * FROM order_detail WHERE user_id N ORDER BY create_time DESC LIMIT N,N看到这个输出你应该马上意识到同一类SQL跑了323次每次都超过2秒这才是痛点比一条偶发SQL重要得多。1.2 从日志里读懂隐藏信息拿到聚合结果后先别急着改SQL。慢查询日志里的信息量很大我教你把它当成体检报告来读。Time2.36s (762s)单次平均耗时和总耗时。总耗时才是对业务伤害的累积排障优先级按总耗时从高往低排。Rows_sent1.0这里有个陷阱如果输出中的Rows_sent很小但Time很大基本可以判断问题是出在扫描行数上后面执行计划里的rows会验证这一点。Lock0.00s锁等待时间如果是0说明没有锁竞争问题纯粹在查询本身如果Lock占比奇高那就得转去查事务和锁别在索引上浪费精力。另一个容易踩的坑是慢查询日志本身的写盘机制。默认所有慢SQL会先写进日志缓冲日志文件到一定大小后做轮转。我曾经遇到一个场景单条SQL巨慢无比但慢查询日志里就是找不到排查半天才发现是log_output默认值是FILE而磁盘IO因为别的原因出现瓶颈日志根本写不进去。如果你发现慢查询日志数据和你观察到的现象对不上可以暂时把log_output切到TABLE直接查mysql.slow_log表排查效率会高很多。提示long_query_time只记录超过阈值的语句但一个事务里的多条语句是分别计时的别把事务总耗时和单条SQL耗时混为一谈。想分析事务级耗时光靠慢查询日志不够得上performance_schema。2. 执行计划和优化器对话的艺术2.1 读懂EXPLAIN的每一列定位到具体SQL之后下一步就是对SQL执行EXPLAIN。这一步看起来简单但大多数人对执行计划的阅读停留在有没有Using filesort是不是ALL这种二极管理解这远远不够。EXPLAIN的输出是一个由优化器生成的执行计划表每一列都值得细抠。我用那张订单明细表举例EXPLAIN SELECT * FROM order_detail WHERE user_id 10086 AND status 1 ORDER BY create_time DESC LIMIT 10;一个典型的执行计划输出可能长这样idselect_typetabletypepossible_keyskeykey_lenrefrowsExtra1SIMPLEorder_detailrefidx_user_statusidx_user_status8const,const34510Using index condition; Using filesort大部分人看完只注意到type是ref觉得走了索引就万事大吉。但你要再看rows这一列优化器估计要扫描34510行因为ORDER BY create_time DESC需要先按user_id和status筛出3万多行再在内存里排序取前10条。这3万行回表、排序的成本就是慢的根源。key_len也是常被忽略的关键信息。它是索引使用的字节数通过它可以反推索引到底用到了联合索引的第几列。user_id是BIGINT占8字节status是TINYINT走过索引字段可能排序后key_len会是9或者更多。如果走联合索引时key_len只显示8那说明status条件没用上索引索引设计有问题。2.2 type列与访问路径评级type列反映了MySQL如何访问表数据按性能从高到低大致是system表只有一行系统表基本见不到。const主键或唯一索引等值匹配最多一行。eq_ref被驱动表通过主键或唯一索引等值匹配多表JOIN时最理想。ref普通索引等值匹配。range索引范围扫描比如BETWEEN、、、IN列表性能可接受。index扫了整个索引树索引全扫描比全表好点但通常不理想。ALL全表扫描这是咱们主要要消灭的对象。你会发现这里面有个信息密度很高的细节从range开始向下查询的成本曲线是陡增的。尤其index这个类型最迷惑人它虽然走了索引但由于没有条件过滤等于把整个索引从头到尾读一遍索引太大时比全表扫描还慢。2.3 这几种执行计划最值得警惕结合我这几年看执行计划的经验有几种情况值得重点说第一种叫做Using filesort。注意这个filesort不是指磁盘文件它可能发生在内存中也可能真的落到磁盘。只要排序的数据量超过sort_buffer_size就会使用磁盘临时文件性能急剧下降。如果EXPLAIN里出现Using filesort且rows很大优先考虑通过索引消除排序排序字段加进联合索引并放到条件字段之后。第二种是Using temporary常见于GROUP BY和DISTINCT操作。它意味着MySQL要建立临时表来去重或分组如果分组字段没有索引临时表会先建立再通过内循环扫描很伤。避免办法是让分组字段成为索引前缀。第三种是Using index condition这是5.6以后引入的索引下推ICP。它表示存储引擎在索引层就先过滤了一遍减少回表次数。很多人看到这个Extra就以为万事大吉但实际上ICP的过滤是逐行判断过滤比例不高的场景收益有限。还有种情况要特别警惕possible_keys为空但key里有值。这说明优化器没发现可用的候选索引但最终它自己选了一个覆盖索引来顶。这通常意味着你的WHERE条件里的谓词完全没被索引覆盖SQL写法和索引设计之间存在严重脱节。注意EXPLAIN众所周知只输出估计值不真实执行。对于数据分布极度不均的字段估算行数会和实际差好几倍。必要时用EXPLAIN ANALYZEMySQL 8.0拿到真实执行时间和实际行数避免被估算值带偏。3. 索引设计从可用到高效的距离3.1 联合索引的最左前缀原则回到我处理的订单明细表原表上有好几个单列索引user_id、merchant_id、status各建了一个。这种无脑给每个查询条件建单列索引的做法是很多开发同学的肌肉记忆。它的问题在于MySQL一条SQL最多只能选一个索引做主要访问路径8.0虽然支持索引跳跃扫描但适用场景有限三个单列索引对联合条件查询基本只能用一个剩下条件全靠回表过滤。把三个单列索引合并成一个联合索引是优化这类查询的标准答案。但联合索引有个躲不开的规则最左前缀原则它不是概念而是索引树在BTree里的存储方式决定的。联合索引(a, b, c)在物理上是一棵索引树先按a排序a相同再按b排序b相同再按c排序。所以查询条件里如果只出现bMySQL没法在索引树上快速定位因为b的有序性是局部于a值的。实际设计联合索引时我会按等值条件优先、排序字段其次、范围条件放最后的顺序来安排字段。拿这条SQL举例WHERE user_id 10086 AND status 1 ORDER BY create_time DESCuser_id和status都是等值条件谁放前面都可以结合业务区分度建议把区分度高的放前面但这也不是绝对的下一节会说道理。然后create_time是排序字段放在等值条件后面这样索引天然按照user_idstatus过滤后的create_time有序排序直接被索引消除Extra里的Using filesort就消失了。我实际建的索引是idx_user_status_time(user_id, status, create_time)。建完后那条SQL的查询时间从2.3秒降到了30毫秒附近这就是索引设计从可用到高效的最大一步。注意一个反直觉的细节范围查询字段后面接的索引列在范围条件下是失效的。比如WHERE user_id10086 AND create_time 2024-01-01 AND status1如果索引设计的是(user_id, create_time, status)那status条件就没法走索引因为create_time的范围切断了后续有序性。所以设计索引时要反复检查条件的类型是等值还是范围范围字段后面尽量别放还有等值条件的字段。3.2 索引失效的常见场景很多文章喜欢列一堆索引失效场景我在这边基于自己调优的经历挑几个真正高频且容易误判的展开函数操作导致失效。索引列上套函数比如WHERE DATE(create_time) 2024-05-01MySQL没法对create_time索引做区间定位。正确写法是WHERE create_time 2024-05-01 AND create_time 2024-05-02。注意8.0支持函数索引了但如果能用范围改写还是优先改写函数索引写多了维护成本很高。隐式类型转换。WHERE user_id 10086如果user_id是BIGINT传入字符串时MySQL会把字符串转成数字这个转换排查起来很隐蔽因为它不报错。我的排查技巧是看执行计划里key是否为空但possible_keys却有索引基本就是隐式转换惹的祸。前导模糊匹配。LIKE %abc因为无法利用BTree有序性必然失效这个属于没得救真要支持这类搜索更靠谱的方案是引入全文检索或者外置搜索引擎。LIKE abc%则能走索引。OR连接导致失效。WHERE user_id10086 OR status1除非两个字段都有独立索引且优化器选择走index_merge合并策略否则很容易退化成全表扫描。改写建议是拆成两条SQL用UNION ALL合并或者重建联合索引让两个条件都能覆盖。IN值过多。WHERE user_id IN (很多很多值)优化器会做成本评估超过阈值后可能放弃索引改走全表扫描。这不是失效是优化器的成本选择。遇到这种情况可以分段查询每次IN几十个值配合循环查询在业务层聚合结果。注意8.0里的索引跳跃扫描可以在查询条件不包含联合索引最左列时让优化器自动去遍历左列每个值。所以我前面说最左前缀原则不可绕过在8.0里要打个折扣但跳跃扫描只对左列值较少的情况友好不要指望它能解决所有问题。3.3 覆盖索引与索引下推消除回表是提升查询性能的重中之重。回表是指通过二级索引拿到主键后再到聚簇索引主键索引去取整行数据。二级索引叶子节点只包含索引列和主键如果查询的字段全都在二级索引里就不用回表了这就是覆盖索引。最经典的场景是把SELECT *改成SELECT 需要的字段并让这些字段全部出现在索引中。比如SELECT order_no, amount, status FROM order_detail WHERE user_id 10086 AND create_time 2024-05-01这时索引设计成(user_id, create_time, order_no, amount)查询就能完全覆盖Extra会显示Using index。在覆盖索引下即使索引本身不小性能也非常好因为不需要访问主键那棵更大的BTree。这里要提一嘴索引下推ICP它在Extra里显示为Using index condition作用是存储引擎在读取索引记录时对索引列做条件过滤过滤通过后才回表。比如(user_id, status)联合索引执行WHERE user_id100 AND status1如果没有ICPMySQL需要把所有user_id100的记录都回表再逐行判断status有了ICPstatus条件能在索引层先过滤一部分回表量大幅减少。所以在MySQL 5.6以上版本里联合索引里放范围查询后面的字段也是有价值的因为ICP把这部分条件下推到了存储引擎这推翻了我前面说范围后面索引失效的绝对论断实际收益视过滤比例而定。不过要提醒一句覆盖索引不是列越多越好。索引列越多BTree越宽写入和更新的成本越高内存占用也越夸张。理想设计是索引只包含WHERE、ORDER BY、GROUP BY以及SELECT中极少数高频字段对于SELECT *这种请求老老实实回表可能反而更优。我一直和开发同学说一切优化都建立在最小代价上覆盖索引换性能也换了写放大。4. SQL写法与改写实战4.1 分页查询深翻页的坑很多系统的慢SQL清单里分页查询占据半壁江山尤其深翻页场景问题几乎无解。看这条典型写法SELECT * FROM order_detail WHERE user_id 10086 ORDER BY create_time DESC LIMIT 100000, 20;LIMIT的语义是跳过前100000行然后取20行。MySQL虽然用索引解决了排序但索引上定位到前100000个匹配记录后还是需要读取这10万条记录并丢弃只保留最后20条。翻页越深丢弃的行越多性能自然衰减。我见过一张千万级表深翻页到几十万的查询直接把人查到怀疑人生。推荐的改写方案是利用覆盖索引先取主键ID再回表取完整行SELECT * FROM order_detail t1 INNER JOIN ( SELECT id FROM order_detail WHERE user_id 10086 ORDER BY create_time DESC LIMIT 100000, 20 ) t2 ON t1.id t2.id;这个写法的精髓在于子查询只访问二级索引不需要回表读取10万行完整记录只取主键id代价小得多。然后再JOIN回原表因为id已经在内存里了回表次数只有20次。这个改写我实测在深翻页场景下能带来数量级的提升翻页深度越大收益越明显。如果业务允许更激进的方案是键集分页也就是记住上一页的最后一条ID下一页直接用WHERE id 上一页最大id ORDER BY id LIMIT 20彻底跳过OFFSET。这个方案成本最低但需要前端配合传游标过来不能像普通分页那样随意跳页。4.2 隐式转换与函数陷阱隐式类型转换的问题可以再展开一点。MySQL中字符串和数字比较时如果两边类型不一致通常是把字符串转换成数字注意是字符串转数字不是数字转字符串。这意味着WHERE order_no 123456789假设order_no列是VARCHAR类型MySQL会对列本身做转换导致索引失效。这类问题的隐蔽之处在于测试环境数据量小全表扫描也能秒回开发很难察觉等上了生产数据量一上来这个查询就成了定时炸弹。我习惯在code review阶段就盯住所有字符串类型的订单号、手机号、状态码是否被参数成数字类型传入Java里尤其容易因为Long类型参数没转String就传进去引发此问题。函数陷阱的另一面是日期时间函数。很多人统计当天数据喜欢写WHERE DATE(create_time) CURDATE()这在MySQL里意味着对create_time这个索引列调用DATE函数索引失效。正确写法是WHERE create_time CURDATE() AND create_time CURDATE() INTERVAL 1 DAY这里的关键是让索引列的取值保持原样只在传入参数上做计算。这条原则对所有索引列上的函数都适用。4.3 用临时表与派生表的注意事项业务里还经常出现一些复杂的聚合查询比如先对子集算平均值再和明细做对比。这种查询经常隐式创建临时表一旦结果集大性能就很难看。GROUP BY字段如果没有索引MySQL会使用内部临时表来分组。更糟的是如果分组字段是VARCHAR且排序需求存在临时表可能还会变成磁盘临时表。我遇到一个经典案例一条按merchant_id统计当日订单金额的SQLmerchant_id没索引每天定时任务要跑40多秒。建了索引之后Group By直接从临时表算法转变成了索引扫描有序分组耗时降到1秒内。派生表from子句里的子查询也要注意5.6之前MySQL会把派生表物化5.6之后优化器会尝试把派生表合并到外层查询。但并不是所有派生表都能被合并带LIMIT的派生表通常会被物化。物化派生表意味着子查询结果要先落临时表再参与外层JOIN成本不小。遇到这种情况我的经验是能写JOIN的别写IN子查询能写EXISTS的别写IN但要注意EXISTS和IN在数据量不同时各有优势不能一刀切。具体的判断规则是外层表大、内层表小用IN外层表小、内层表大用EXISTS。IN会把内层结果集放到内存哈希表里EXISTS是逐条驱动外层查询看内层有没有匹配。理解了底层机制就不会人云亦云说EXISTS永远比IN快了。5. 配置参数与架构层面的优化5.1 关键参数调优innodb_buffer_pool_size是重中之重SQL和索引都优化得差不多了如果数据库还是慢这时才轮到参数调优。注意这个顺序很重要很多同学一上来就调参数SQL本身一塌糊涂buffer pool再大也白搭。innodb_buffer_pool_size决定InnoDB缓存数据和索引的最大内存池它的命中率直接影响查询性能。默认值是128M对任何有真实业务量的库来说都太小。我的调整经验是取物理内存的60%~75%并且要保证分配给整体MySQL的总内存包括连接线程、临时表、排序缓冲等不超过物理内存的80%否则操作系统会频繁swap比buffer pool不命中还可怕。调大buffer pool之后不要忘记看命中率SHOW GLOBAL STATUS LIKE Innodb_buffer_pool_read%;重点关注Innodb_buffer_pool_read_requests和Innodb_buffer_pool_reads。命中率 1 - reads / read_requests长期低于99%都说明内存池偏小或索引设计不合理。但也要知道如果一条SQL压根没走索引buffer pool再大也同样全表扫描完全没有优化空间。MySQL 8.0里buffer pool是支持动态调整的早期版本需要改配置文件重启。重启倒不怕怕的是你改完没看SHOW VARIABLES确认生效改错变量名导致静默失败。我曾经就吃过这个亏改成了innodb_buffer_pool_size的别名参数结果服务起来后发现配置没对齐排查了一天才发现。5.2 连接数、线程池与超时设置连接数配置是另一个高频翻车点。max_connections默认是151对很多应用来说够用但如果你的应用没有正确管理连接池在高并发下很容易打满此时MySQL会直接报Too many connections错误客户端看到一堆连接异常层层重试数据库压力更大。我看连接数的习惯是查两个值SHOW GLOBAL STATUS LIKE Max_used_connections; SHOW GLOBAL STATUS LIKE Threads_connected;Max_used_connections是历史最大连接数和max_connections的比值才是运营状态。如果比值长期超过80%要么调大连接上限要么减少应用侧无效连接。我个人更推荐后者因为无脑调大连接数会让MySQL为每个连接分配线程和内存反而加剧CPU和内存竞争。wait_timeout和interactive_timeout的配置也有讲究。默认8小时太长睡死的连接占着连接数不放调成60秒左右可以快速释放闲置连接但也不能太短否则业务里长事务还没跑完连接就被杀了。更精细的做法是让应用连接池自己管理空闲连接的生命周期数据库层面只兜底。注意8.0里如果用了连接池每个物理连接在MySQL侧都对应一个线程线程切换开销在高并发下非常可观。MySQL 8.0.27以后引入了thread pool插件但我实测下来在一些OLTP负载上收益并不稳定更建议先从连接数管理入手别急着上插件。5.3 读写分离与缓存策略单库单表优化到极致之后如果要上更高并发就得往架构层面想。最成熟的路子是读写分离和加一层缓存。这里我提醒一句做架构拆分前先把单机优化做完。很多团队拿着慢SQL不管直接上MyCat分库分表结果只是把一个库的慢查询变成了多个库的慢查询问题没根治复杂度倒是翻了倍。读写分离的核心是把主库的压力分出去但它的隐性成本是做数据一致性取舍。业务里读多写少、允许秒级延迟的场景最适合读写分离。架构上建议用中间层路由而不是在应用里写死两个数据源否则主从切换时应用要改配置重启。缓存层的话我通常建议只缓存两类数据一类是热点极高且极少变化的配置数据另一类是统计结果或者报表数据。千万不要把每个订单详情都塞进缓存缓存击穿、缓存穿透问题会让你疲于奔命。缓存穿透的兜底方案是缓存空值缓存击穿则靠互斥锁或逻辑过期来扛。这些都是在缓存层本职工作之外的重要细节。另外如果你们团队考虑引入Redis做缓存我的忠告是先量化缓存命中率和缓存成本。有些场景查询本身只要20ms缓存的序列化反序列化可能还要5ms却把一致性复杂度抬上来了没必要。6. 常见问题排查实录6.1 慢查询排查速查表顺手整理一个排查速查表是我自己排查慢SQL时反复使用的检查顺序贡献给大家可以直接打印贴工位现象优先排查方向关键操作单条SQL偶发变慢统计信息过期、缓存失效ANALYZE TABLE刷新优化器统计信息数据库整体变慢慢查询日志、系统负载、锁等待看SHOW PROCESSLIST抓锁等待和长事务大批量SQL同时变慢慢查询日志聚合、索引缺失mysqldumpslow -s c -t 20按次数找共性EXPLAIN走索引但实际慢回表过多、ICP失效、rows估算偏差EXPLAIN ANALYZE看实际行数翻页越深越慢深翻页方案问题键集分页 / 子查询覆盖索引夜间批量任务慢锁竞争、长事务查information_schema.innodb_trx寻找阻塞源这个表的核心思路是先判断是个别SQL还是全局性能然后决定从索引层还是从系统层下手别一上来就改配置。6.2 几个经典场景复盘场景一某统计报表SQLLEFT JOIN三张表每张表条件过滤后都上万行执行计划显示驱动表全表扫描耗时12秒。我通过EXPLAIN发现驱动表选错了优化器按错误估算选了小表做驱动实际上小表经过WHERE过滤后剩下很少应当做被驱动表。解决办法是改造SQL里ON条件的字段类型让两边字符集一致再在关联字段上补索引最终耗时从12秒降到800毫秒。注意JOIN驱动顺序不是我们在SQL里写的顺序而是优化器自己选的可以通过STRAIGHT_JOIN强制指定但这只是权宜之计根治还是得让统计信息更准、索引更全。场景二一条UPDATE语句在高峰期把订单状态改回待支付单次执行300ms平时没事一到0点批量催付任务就跑不完了。排查后发现该表主键是UUID二级索引回表时随机IO太严重更新要锁的行分散在整个表空间。这个案例给我的教训是主键设计会影响所有二级索引的性能生产环境主键尽量不要用UUID。我当时临时把批量任务改成按主键范围分段UPDATE代价是应用复杂度增加但先把故障解决了。后期做了主键改造才根治。场景三业务突然报某接口超时但慢查询日志里没有超过1秒的SQL。查了SHOW PROCESSLIST发现大量线程处于Waiting for table metadata lock状态根源是有个长事务一直没提交占用了表的元数据锁后续所有DDL和查询都被阻塞。这提醒我们慢查询日志只是切面之一进程列表、锁等待监控、长事务告警要配套建设才能真正监控住数据库的体感性能。6.3 测试环境复现不出来的原因优化效果总得先在测试环境验证。但测试环境和生产环境的数据量、数据分布、硬件配置差异巨大很多时候你自信满满地改完索引测试环境怎么跑都快一上生产又打回原形。我遇到过三种最常见的差异。一个是数据分布测试环境表里几千行生产表里三千万行优化器在测试环境里认为走全表扫描最低成本生产里也这么选结果就炸了。这类问题在优化器选择索引时尤其明显因为统计信息和实际数据规模差了一个量级。另一个是并发环境测试环境只有你一个人跑SQL生产环境几百个连接同时压锁等待、CPU竞争、IO带宽都被放大单条SQL的执行计划即使一样线上耗时也可能比测试多好几倍。所以高并发优化建议直接在压测环境验证压测数据量要和生产接近。还有一个很容易忽略的字符集和排序规则不一致。表A和表B的join字段一个用utf8mb4_0900_ai_ci一个用utf8mb4_general_ciMySQL没法直接使用索引做关联得先做隐式转换测试环境数据量小看不出来生产数据一大就卡死。所以上线前检查每个表的字符集一致性几乎零成本却非常有价值。结尾优化是一次隔离实验做完整轮优化回头看MySQL性能优化之所以让人头疼是因为它横跨SQL写法、索引设计、执行计划、配置参数、架构取舍好几个层面任何一个环节出现问题都可能让整体性能崩塌。但只要你养成了先定位再动手、先单点再全局、先验证再上线的排查习惯绝大多数慢查询都有明确的解法。我个人这几年最大的体会是改索引永远比改SQL更持久改SQL永远比改配置更可靠改架构永远是最后的手段。三个层面依次推进每推一层都要用慢查询日志和执行计划验证效果像做隔离实验一样一次只改一个变量这样出了问题也能精准回退不会陷入改了A又改B最后不知道哪个起作用的混沌状态。优化这件事没有终点业务量在涨数据量在涨今天的最优解可能过两个月就是瓶颈。但只要排查路径和方法论对了再出现新问题你大概也清楚该去哪里找它。