ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

慢SQL优化实战:一条统计SQL从8小时到1毫秒

慢SQL优化实战:一条统计SQL从8小时到1毫秒 1. 性能噩梦的现场一个统计查询如何拖垮整个库先交代一下背景这个案例来自一个真实的电商订单中心。当时线上反馈“每日销售报表”打开极其缓慢运营同事点一次查询运气好等五六分钟运气不好直接超时报错。后台DBA一查慢查询日志发现一条统计SQL的执行时间定格在30248秒——超过8个小时。这个数字出现在数据库里意味着什么意味着这条SQL基本已经“跑不完”了每次执行都在消耗巨大的CPU和I/O资源而且查询期间还会持有锁连带影响同表的其他正常业务。这条SQL本身其实很常见语义就是“按商品分类、按日期统计订单量和销售额”。问题出在它的写法上——多张表做关联再加一堆子查询嵌套。最初的版本长这样做了脱敏简化SELECT c.category_name, DATE_FORMAT(o.order_time, %Y-%m-%d) AS order_date, SUM(oi.quantity) AS total_quantity, SUM(oi.amount) AS total_amount FROM category c LEFT JOIN product p ON c.id p.category_id LEFT JOIN order_item oi ON p.id oi.product_id LEFT JOIN orders o ON oi.order_id o.id WHERE o.order_time 2024-01-01 AND o.order_time 2024-07-01 AND oi.is_valid 1 GROUP BY c.category_name, DATE_FORMAT(o.order_time, %Y-%m-%d) ORDER BY c.category_name, order_date;单看这段SQL可能很多人觉得“没什么大问题”无非就是四张表关联加条件过滤。但就是这段看似“正常”的SQL在实际数据集上跑出了八小时级别的执行时间。性能瓶颈不是某一行代码的语法错误而是整条SQL的数据访问路径出了大问题该走索引的没走索引该缩小数据量的没缩小中间过程产生的临时结果集大得离谱。在分析优化方案之前先给一个预判如果一条SQL的执行时间超过几秒基本可以断定执行计划已经偏离了最优路径这和服务器配置的关系不大主要就是表结构、索引、SQL写法三者的匹配出了问题。后面所有的优化动作都是围绕这三者逐一排查和修正的。这个案例适合所有正在和慢查询搏斗的开发人员、DBA、运维工程师。不管你是刚接触SQL优化的新手还是已经写过不少业务查询的熟练工这条SQL的优化过程都值得完整看一遍——因为里面涉及的思路和方法是可以直接复制到你自己项目里的。2. 瓶颈定位EXPLAIN和慢查询日志里的关键信号2.1 不要猜先看执行计划拿到一条慢SQL最忌讳的就是靠直觉“猜哪里慢”然后到处乱加索引。我见过太多同行在慢SQL面前的第一反应是“给关联字段全部建上索引”结果有时有用有时毫无变化——因为索引不是万能的它会改变执行路径但不保证改变正确。正确做法是直接把这条SQL丢到数据库里执行EXPLAIN看它是怎么规划执行路径的。针对上面那条统计SQL优化前执行计划里的几个关键信号如下字段观察值风险解读typeALL全表扫描category表小全表可以忍但orders和order_item全表扫描就是灾难possible_keysNULL关联字段上根本没有索引可用rowscategory: 40条product: 20万条order_item: 1800万条orders: 800万条估算扫描行数巨大Using temporary / Using filesort出现GROUP BY和ORDER BY需要额外的临时表和排序步骤ExtraUsing where后回表过滤条件无法直接利用索引定位这里最刺眼的不是出现filesort或temporary毕竟带GROUP BY的查询多少都会有点临时表操作真正的罪魁祸首是order_item表1800万行全表扫描。这意味着每处理一个product分类都要把order_item表完整扫一遍再和orders表做匹配。这种关联顺序叠加全表扫描产生的笛卡尔积式扫描量直接把数据库打爆了。2.2 慢的原因到底出在哪几个环节进一步分析这条SQL慢的本质可以拆成三块来看。第一块是关联字段缺索引。order_item表上的product_id、order_idorders表上的id这仨字段在初始表结构里全都没有索引。导致优化器在选择执行路径时对大表只能一条条扫完全没有“先定位再取数”的空间。第二块是过滤条件后移。WHERE条件全部针对orders表理论上应该先把orders表在半年内的时间段过滤缩小再去关联明细但实际执行计划里优化器基于成本估算选择了从order_item先全表扫描、再嵌套关联的路径——这等于先用最大的表做驱动表。第三块是GROUP BY和ORDER BY的临时表。MySQL 5.7和8.0在遇到GROUP BY时如果字段不在索引里必然产生临时表和filesort数据量大时这一步会二次放大I/O开销。2.3 为什么“多表关联统计”最容易踩坑这条SQL属于典型的OLAP轻量级分析场景。日常业务中所有“汇总统计类”查询几乎都要面对一个大表和若干个维表做关联。大表动辄百万、千万行维表可能就几十条、几百条。如果不刻意引导执行顺序优化器很容易被统计信息误导选错驱动表。在多表关联和聚合统计同时存在时SQL优化的核心不是“功能正确”而是“数据大幅度提前过滤”。提前过滤做得越好后期压力越小。后面每一步优化实际上都是在贯彻这一条原则。3. 优化方案落地从三招到执行时间断崖式下降3.1 第一招补齐关联字段索引消除全表扫描最基础、也是最重要的一步是检查并补齐所有JOIN和WHERE条件涉及的字段索引。针对这个场景我建的索引清单如下ALTER TABLE order_item ADD INDEX idx_product_id (product_id); ALTER TABLE order_item ADD INDEX idx_order_id (order_id); ALTER TABLE order_item ADD INDEX idx_is_valid (is_valid); ALTER TABLE orders ADD INDEX idx_order_time (order_time); -- 如果product表数据量也大product表的category_id也建议加索引 ALTER TABLE product ADD INDEX idx_category_id (category_id);之所以给order_time单独建索引是因为这个字段是主要过滤条件半年时间段的过滤如果能走索引数据量可以从800万直接压到几十万级别。给order_item的is_valid建索引也是同理——虽然这个字段的区分度不高但在这个SQL里它参与了过滤索引至少能减少一部分无效行的读取成本。实测执行时间从30248秒降到了约1200秒左右虽然还是很慢但已经能看到方向是对的。执行计划里全表扫描的“ALL”变成了“ref”或者“range”——这一步的意义在于证明不是SQL写错了是索引缺失导致数据库在错误的路上狂奔。3.2 第二招重写SQL把过滤提前、把聚合拆分索引补齐后执行时间降到了20分钟级别改善很明显但离“能用”还差得远。下一步是对SQL本身动刀。原始写法最大的问题在于它让数据库先做多张大表关联再聚合再排序——中间结果集可能是几百万行的临时表。优化思路是把“先关联再过滤”改成“先过滤再关联”把“一锅炖”改成“分层处理”。核心做法是先把orders表在时间范围内的订单ID集合缩到最小再把这个集合去关联order_item同时category和product这两个小维表先做一次预关联得到商品ID和分类名称的映射再去连接订单明细。重写后的SQLSELECT pc.category_name, order_date, SUM(oi.quantity) AS total_quantity, SUM(oi.amount) AS total_amount FROM ( -- 先缩小订单范围 SELECT id, order_time, DATE_FORMAT(order_time, %Y-%m-%d) AS order_date FROM orders WHERE order_time 2024-01-01 AND order_time 2024-07-01 ) o INNER JOIN order_item oi ON oi.order_id o.id INNER JOIN ( -- 小维表预关联得到商品分类映射 SELECT p.id AS product_id, c.category_name FROM product p LEFT JOIN category c ON c.id p.category_id ) pc ON pc.product_id oi.product_id WHERE oi.is_valid 1 GROUP BY pc.category_name, o.order_date ORDER BY pc.category_name, o.order_date;这一步带来的提升是决定性的执行计划里rows估算从千万级降到了几十万级临时表开销也大幅缩小。执行时间从1200秒降到了大约2秒。到这一步这条SQL已经可以正常上线给业务使用了。但作为追求极致的人我仍然觉得2秒对于“一张报表”来说还有优化空间。毕竟报表查询通常不是只跑一次每天运营要刷很多次要是晚上夜维再跑一遍还是会给主库增加压力。3.3 第三招覆盖索引生成列改写把2秒推进到毫秒级想要继续压时间核心思路是让数据库在索引层面就能完成查询不需要回表读取原始行。覆盖索引就是干这个的——把SELECT里用到的字段全部放进索引里面。这样InnoDB只需要扫描索引结构就能拿到全部数据省掉大量的随机I/O。先为order_item设计一个宽索引ALTER TABLE order_item ADD INDEX idx_order_prod_valid (order_id, is_valid, quantity, amount);这个索引覆盖了order_item表在当前SQL里的全部需求等值过滤order_id、is_valid、聚合计算quantity、amount。同理orders表也需要一个覆盖性更好的索引ALTER TABLE orders ADD INDEX idx_time_id (order_time, id);当查询请求的是order_time范围内的id时索引扫描直接给出结果不回表。这两条宽索引加上后执行计划里的Extra字段会出现“Using index”——这就是覆盖索引生效的标志。实测执行时间从2秒左右掉到了0.001s即1毫秒级别。为什么提升能这么夸张核心在于数据库从“扫描几百万行、每次回表读数据”变成了“在索引B树里顺序扫描几十万条有序数据直接遍历叶子节点完成聚合”。索引其实就是一种空间换时间的预排序结构把随机I/O变成顺序I/O这比任何SQL写法上的小技巧都本质。3.4 第四招进阶并行SQL优化思路上面三步做完单条SQL已经是毫秒级性能瓶颈可以说彻底解决。但如果你在一个大型数据仓库环境里工作可能还需要考虑更进一步的手段——并行SQL优化。MySQL 8.0原生的并行查询能力比较弱真正能做并行的是TiDB、ClickHouse、以及Oracle里的Parallel Execution。如果数据量进一步膨胀到几亿行单条SQL怎么写都很难在秒级内完成这时候思路要切换到把一个大SQL拆成多个小SQL并行执行或者利用分区表把数据的扫描范围切成多段由多个线程同时处理。举个例子如果orders表按月做了分区那么上面这条查询就可以被数据库自动裁剪到6个分区配合并行扫描线程理论上执行时间还能再压缩一半。我在实际项目里就见过一条跑在分区表上的统计SQL从12秒压到3秒——前提是查询条件必须强约束在分区键上否则分区不但没帮助还会增加元数据开销。所以并行和分区都是“数据规模再上一档”时的备选方案现阶段这个案例用前三招就够了。4. 每一步优化后的对比数据与执行计划变化4.1 关键指标对照表为了更直观看到每一步的作用我把四个阶段的执行时间、扫描行数、执行计划特征整理成一个对照表方便大家做同类问题时的参考基准阶段执行时间扫描行数估算执行计划特征原始版本30248秒order_item 1800万全表、orders 800万全表typeALLUsing temporaryUsing filesort加入基础索引约1200秒order_item仍全表扫描orders按时间索引缩减typeref/range但仍有临时表和回表重写SQL过滤提前约2秒驱动结果集降到几十万行子查询先缩小结果无笛卡尔积式扫描覆盖索引新写SQL0.001秒索引扫描内完成rows几万Using index无回表无filesort可以看到性能提升的主要贡献节点在于“重写SQL”和“覆盖索引”这两步而不是第一步加索引。但不要本末倒置——前两步是“地基”没有基础索引后面重写SQL也发挥不了全部潜力。整个优化过程是阶梯式推进每一步都在为下一步铺路。4.2 为什么第一个索引没有“一步到位”有读者可能会问既然最终要靠覆盖索引解决为什么不一开始直接建宽索引原因有二。第一宽索引的维护成本高一个索引包含四个字段每次DML操作都要同步更新在业务写入频繁的订单表上盲目加宽索引反而可能拖垮TPS第二步的基础索引产品/订单/分类字段本来就需要属于合理投入。第二SQL写法不优化时宽索引的收益有限——原始SQL的JOIN顺序和子查询嵌套会导致优化器选择不合理的路径哪怕有覆盖索引也未必走得上。所以更稳妥的顺序是“先改写SQL、再设计索引”而不是一上来就堆索引。4.3 执行计划里值得留意的几个“危险信号”做SQL优化的人一定要练出一双快速扫描EXPLAIN输出并定位问题的眼睛。除了上面提到的typeALL和Using temporary/filessort还有几个信号值得警惕Using join buffer说明有一个表在关联时没有被索引覆盖需要额外的内存缓冲区来缓存中间结果数据量大时直接内存溢出。possible_keys为NULL说明这个表上任何可用的索引都不存在优化器只能全表扫。rows估算远超实际结果集比如估算80万行最终结果集只有几百行说明统计信息不准确或关联顺序不合理。Extra里出现“recursive”这是MySQL 8.0的递归CTE标记除非你确实在写递归查询否则不会出现但如果出现且你不需要递归语义说明执行计划出问题了。这些信号看到任何一个都不应该视而不见。慢SQL的本质就是执行计划中的某一步在数据库的“舒适区”之外运行。5. 常见问题与思考误区我踩过的坑都帮你列好了5.1 加了索引但没有生效为什么这是最常被问到的问题之一。明明补了索引EXPLAIN显示还是全表扫描。在我这个案例里也出现过这个情况——第一次给order_item建完单列索引后执行计划还是走了全表扫。原因一般是这几个第一数据分布导致优化器认为“全表扫描更快”如果一张千万级表里满足过滤条件的行占了30%以上优化器确实会放弃索引而选择扫全表第二索引列上做了函数转换比如WHERE DATE(order_time) 2024-01-01这种写法会让索引失效第三隐式类型转换如果字段是varchar你传了数字MySQL会把列上做转换导致索引失效。建议确认SQL写法没有包裹函数然后用FORCE INDEX强制索引测试一下如果走了索引依旧变慢那就是索引本身设计不对。5.2 覆盖索引是不是越多越好不是。覆盖索引在查询侧是好东西在写入侧是负担。每个索引都是B树每次INSERT、UPDATE、DELETE都要同步维护。一张表上有三个宽索引和八个单列索引写入性能可能下降20%~30%磁盘占用也会成倍增加。更合理的做法是针对高频慢SQL设计1~2个覆盖索引其余的单列索引只保留真正被过滤条件用到的。平时可以用performance_schema的索引统计信息查一下哪些索引从来没被使用过确认后直接删掉。5.3 子查询到底能不能用在这个案例里我用子查询做了“预缩小”并且效果好得惊人。但我在很多地方也看到过“子查询性能差、要改成JOIN”的说法这其实是片面的。子查询在MySQL 5.7之后实现机制发生了变化——很多派生表会被自动物化且物化后可能无法使用索引但如果派生表的数据量本来就很小物化的成本可忽略不计。所以判断标准不是“用不用子查询”而是“子查询结果集是否足够小”。结果集大子查询就拉胯结果集小子查询就是神器。对于订单表的过滤半年数据可能还有几十万行这个量级做物化也不慢所以放心用。如果数据量到上亿级别那就要评估是不是该换ClickHouse这类分析引擎了这不是SQL写法能解决的。5.4 30秒和0.001秒的差距本质是什么很多人看到“30248s到0.001s”会觉得不可思议怀疑是不是数据量很小或者测试环境特殊。其实这里面的原理可以用一个例子来说明假设你要在一本一万页的电话簿里查“姓张的人”逐页翻过去找是B树全表扫描先翻到“张”字开头的页码再在那一小段里找是索引定位。30248秒相当于你把电话簿从头到尾翻了几百遍0.001秒相当于直接翻到对应页。数据库的B树索引天然就是为了避免“逐页翻找”而设计的SQL优化就是想办法让数据库的每一步操作都落在“翻页定位”而不是“全程扫描”上。6. 慢SQL优化工程化的实战心得与排查口诀6.1 一条SQL优化之外的体系建设建议单一SQL的优化解决的是单点问题但如果一个系统频繁出现慢SQL说明背后缺的不是一两个索引而是SQL上线前的审查机制。我现在的团队里每个SQL要上线前都会强制过三层检查第一层SQL语法和逻辑评审重点看大表关联、过滤条件位置、聚合排序第二层EXPLAIN执行计划评审凡是typeALL、rows估算超过十万的都要说明原因第三层慢查询日志监控和定期巡检用自动化脚本晚跑一次慢SQL第二天早会过一遍。这套机制上线后线上慢SQL的数量降了90%以上效果比单纯靠DBA救火好太多。6.2 排查慢SQL时我会用的“三板斧”第一板斧先拿到慢SQL本身。通过慢查询日志把执行时间超过阈值比如1秒的SQL捞出来按执行次数和总耗时排序优先修“跑得久”且“跑得频繁”的。第二板斧EXPLAIN看执行计划。重点关注type、rows、Extra三列任何不理想的信号都值得深挖。第三板斧做针对性优化。先看SQL写法过滤条件前置关联条件明确避免函数包裹再看索引设计该补的补该删的删最后看数据量和业务模式如果SQL已经写得很规范、索引也合理但还是慢可能就是数据量到了该上汇总表、缓存或者换引擎的程度。6.3 复盘这个案例里最值得你记住的三条经验第一条SQL优化的本质是减少数据扫描量而不是“调参数”。很多人第一反应是调buffer pool、调连接数这些配置优化在某些场景有效但在这个案例里完全不解决根本问题。第二条优化要分层做每一步验证后再走下一步。补索引、改SQL、建覆盖索引这三步如果在同一次变更里全做完出了问题根本不知道是哪一步造成的而且很难说服别人信任你的优化结果。第三条任何优化方案要带回退预案。我在生产库上做变更之前会先把原SQL的完整文本和执行计划保存下来后面如果新版本在某类数据下表现异常可以立即切回旧写法而不是在故障现场手忙脚乱地找历史版本。最后再分享一个小技巧在排查SQL性能问题时花时间把慢SQL按“消耗总时长”排序比按“单次执行时间”排序更有意义。一条单次执行10秒但每天只跑2次的SQL危害远远小于一条单次1.5秒但每天跑一万次的SQL。优化收益应该关注“总时间节省”而不是单纯盯着最高耗时的个案。这也是我这些年做SQL优化总结出来最重要的一条经验——性能优化的最终目标是降低系统的整体负载而不是追求单条查询的数字好看。
返回列表