昨天刚帮同事解决了一个线上慢查询,一条原本要跑30多秒的订单统计SQL,被我改成了30毫秒不到。改完之后同事说了一句:这就是高级SQL吗?我说这还真不全是。MySQL里的高级SQL,核心不是花哨的写法堆砌,而是每一类写法背后对应的优化机制。今天就把这几年实战里踩过坑、试过水、最后沉淀下来的10种能真正让性能飞升的SQL写法,一次性整理出来。
这篇内容不是给刚会写SELECT的人扫盲,而是给那些已经能熟练写CRUD,但遇到亿级数据、复杂报表、批量更新就开始头疼的后端开发、数据分析师和数据运维看的。全文会讲清楚每一种写法解决什么问题、为什么比常规写法快、在什么场景下千万别用。如果你是那种想把自己SQL水平往“准DBA”方向推一把的人,这篇值得认真读两遍。
1. 性能优化思路先行:为什么简单的SQL会慢
在展开10种高级SQL之前,得先把一个底层逻辑掰开揉碎说清楚:很多慢查询,不是因为你写的SQL不高级,而是因为你压根没搞明白它是怎么执行的。
1.1 用户看到的慢,多半不是SQL问题的表象
举个例子。某电商项目里有一个订单明细查询,前端页面一打开就要等三四秒,用户疯狂投诉。开发第一反应是把查询条件加个索引,加了还是慢。后来我把执行计划拉出来一看,问题根本不在那个WHERE条件上,而是分页排序的字段和索引顺序对不上,导致每次翻页都要把所有命中的行拿去做文件排序,数据量一大就原地爆炸。
这就是我反复强调的:慢查询优化,第一步永远是搞清楚“慢在哪一步”,而不是埋头调SQL。MySQL执行一条SQL要经过解析、优化、执行三个阶段,其中执行阶段又可能涉及索引扫描、回表、临时表、排序、分组、连接等各个环节。任何一个环节变成瓶颈,整体就慢。高级SQL的价值,就是能在执行计划的层面减少这些成本,而不是只在语法层面显得高级。
1.2 性能优化的三个底层原则:Sargable、回表、物化
我优化SQL时脑子里始终绷着三根弦:
第一根弦叫Sargable,说人话就是“索引能否被有效利用”。WHERE条件里对索引列做函数运算、隐式类型转换,或者用了前导模糊查询,都会破坏索引的可搜索性,让MySQL只能全表扫。
第二根弦叫回表。InnoDB的二级索引存储的是索引列加主键值,如果你查询的列不在索引里,每命中一行都要拿着主键再去聚簇索引里捞一次完整数据。回表次数一多,性能就崩。覆盖索引就是专门来解决这个问题的。
第三根弦叫物化。MySQL 8.0之前,子查询很多会被物化成一张临时表再参与连接。临时表如果没索引,连接时就是嵌套循环全扫,慢到怀疑人生。所以有些看起来优雅的子查询,实质上是在给自己挖坑。
1.3 本文的10条进阶SQL,适合谁、不适合谁
我把这10种SQL分为四个层次:索引层面的覆盖索引、生成列索引;写法层面的窗口函数、CTE递归、JOIN LATERAL;更新层面的批量CASE WHEN、UPDATE JOIN、幂等插入;统计层面的条件聚合、EXPLAIN ANALYZE校准。
如果你是MySQL 5.7的老版本用户,先注意版本门槛。窗口函数、CTE递归、JOIN LATERAL、EXPLAIN ANALYZE全都要求MySQL 8.0及以上。如果公司还在用5.7,建议先推动版本升级,或者挑那些5.7也能用的技巧(覆盖索引、CASE WHEN批量更新、条件聚合、INSERT ON DUPLICATE KEY UPDATE)来落地。
2. 磨刀不误砍柴工:性能基线与原理解析
前面说了,直接改SQL容易翻车。真正稳的做法是:先量化现状,再定位瓶颈,最后才动SQL。这一节的几个工具和思路,会让后面10种高级SQL用起来事半功倍。
2.1 先量后调:EXPLAIN和SHOW PROFILE怎么配合
每次有人拿着一句“帮忙看看这条SQL为什么慢”来找我,我的固定动作就两步。
第一步,先看EXPLAIN输出。重点盯四个指标:
- type列:从好到差依次是 system、const、eq_ref、ref、range、index、ALL。看到ALL就要警惕,多半是全表扫描。
- key列:实际使用的索引是哪个。如果为 NULL,说明没走索引。
- rows列:预估扫描行数。这个数和实际差距很大时,说明统计信息可能过期,或者优化器选错了索引。
- Extra列:出现 Using filesort、Using temporary 都要警惕,这俩是性能杀手。
第二步,再用SHOW PROFILE看执行细节。做法是SET profiling = 1;执行一遍慢SQL,然后SHOW PROFILES;找到查询ID,再用SHOW PROFILE CPU, BLOCK IO FOR QUERY 2;看耗时到底花在 Sending data 还是 Sorting result 还是 Creating tmp table 上。
这套流程做完,你基本就能判断:问题到底在索引、在排序、在临时表,还是在连接顺序。
2.2 索引优先,还是SQL改写优先
坦白说,优化SQL的第一步永远是优化索引,第二步才是改写SQL。因为索引往往是一劳永逸的事,而SQL改写得小心谨慎,可能影响业务语义。
举例来说,一个报表查询 JOIN 三张表,慢在关联字段上没索引。你加一个索引可能直接从10秒降到0.2秒,这不需要改写任何SQL逻辑。反之,如果你不分析索引,上来就把子查询改成JOIN,把GROUP BY改成窗口函数,结果可能更慢。
所以我的优先级是:先查索引使用情况,再查执行计划,最后才考虑换写法。10种高级SQL里,有一部分本质上是让优化器更容易走索引,而不是绕开索引。
2.3 关于“高级SQL”的误区
有一种观点我觉得挺误导人:把SQL写得越复杂越“高级”,性能越好。真相恰恰相反,大多数情况下,SQL越简单、执行计划越直白,性能越可控。
高级SQL的价值不在于把十行功能压成一行,而在于用更少的扫描、更少的回表、更少的临时表、更少的网络往返,去完成同样的逻辑。举一个我在面试中经常出的题:用一条UPDATE批量修改一批订单的状态,很多候选人只会写循环一条一条更新。能用一条CASE WHEN批量更新的候选人,我就会觉得他对MySQL执行机制是真正有概念的。
所以,别看这10种SQL长得复杂,它们的终极目标是让整体执行成本更低,而不是表面炫技。
3. 10种高级SQL写法实战:每条都有优化点
下面进入本文的重头戏。我会按“这是什么、能解决什么、为什么快、有什么坑”的逻辑,把每条写法的实战细节摊开讲。
3.1 覆盖索引:最便宜的优化,没有之一
先看一个最常见场景:
SELECT order_id, user_name, order_amount, create_time FROM orders WHERE user_id = 733 AND create_time >= '2024-06-01' ORDER BY create_time DESC LIMIT 100;假设已有联合索引idx_user_time(user_id, create_time),这条SQL能快速定位到 userId=733 且 createTime 在范围内的记录。但问题来了:user_name和order_amount这两个字段并不在索引里,所以每命中一条记录,InnoDB都要拿着主键回聚簇索引捞一次完整行。如果命中了10万行,就是10万次随机回表,磁盘IO直接拉满。
解决办法是建立一个覆盖索引,把查询需要的列全部塞进去:
ALTER TABLE orders ADD INDEX idx_user_time_amount (user_id, create_time, order_amount, user_name);这样这条查询要的所有字段都在索引叶子上,InnoDB扫描索引页就能直接返回结果,一次回表都不需要。这个优化对高并发查询来说是立竿见影的。
坑在哪?覆盖索引不是把越多列塞进去越好。每增加一个索引列,写入时的维护成本都会上升,索引占用的磁盘空间也会变大。我见过有人为了“覆盖”,把七八个列全部放进索引,结果数据量一大,索引比数据本身还大,写入性能急剧下降。实战中覆盖索引列数控制在4个以内,而且优先覆盖“高频查询+高筛选度”的列,低频查询宁愿让它回表。
另外要注意:如果SQL里用了SELECT *,覆盖索引就没法发挥作用,因为*包含的列太多,索引根本盖不住。所以用覆盖索引的场景,SELECT列一定得精简。
3.2 窗口函数:替代自连接,清晰又高效
“取每个用户最近一笔订单”这种需求,我见过太多新人用自连接实现,然后被性能按在地上摩擦。老写法一般是:
SELECT a.user_id, a.order_id, a.amount, a.create_time FROM orders a JOIN ( SELECT user_id, MAX(create_time) AS max_time FROM orders GROUP BY user_id ) b ON a.user_id = b.user_id AND a.create_time = b.max_time;这个写法有几个问题:第一,子查询先GROUP BY再跟原表JOIN,会产生临时表;第二,如果同一用户同一时间有多笔订单,这种写法还会查出重复记录;第三,逻辑稍一变(比如要“每个用户金额最大的前三笔订单”),这种写法基本没法扩展。
MySQL 8.0的窗口函数可以一行解决:
SELECT user_id, order_id, amount, create_time FROM ( SELECT user_id, order_id, amount, create_time, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY create_time DESC) AS rn FROM orders ) t WHERE rn = 1;窗口函数在这里的价值不光是写起来短。它让排序逻辑清晰地作用在分区上,优化器可以更高效地计算排序,而不是先造一张临时表再JOIN。而且ROW_NUMBER()天然保证了每个分区内排名唯一,不会再出现重复行问题。
坑点:窗口函数在数据量特别大时,PARTITION BY加ORDER BY会触发排序操作,需要足够的排序缓冲区。如果内存不够,MySQL会把中间结果写到磁盘临时文件,整体可能比老的JOIN写法还慢。我的经验是:先用WHERE把数据范围圈到最小,再在上面套窗口函数,千万别对全表直接开窗。
3.3 递归CTE:组织架构和树形结构就该这么查
以前查树形数据是真的痛苦。我在某公司做权限系统时,需要拿到一个部门下面所有子部门,5.7时代只能用存储过程循环遍历,每次递归一层查一次数据库,复杂度感人。MySQL 8.0的递归CTE把这件事变成了标准SQL:
WITH RECURSIVE category_tree AS ( SELECT id, parent_id, name, 1 AS depth FROM category WHERE parent_id = 0 UNION ALL SELECT c.id, c.parent_id, c.name, ct.depth + 1 FROM category c JOIN category_tree ct ON c.parent_id = ct.id ) SELECT * FROM category_tree;这个SQL逻辑很好理解:先查根节点作为初始结果集,然后反复JOIN下级节点,直到没有新节点为止。相比存储过程,它没有额外的网络往返,也没有复杂的游标操作,代码可读性还高了一个档次。
坑有两个。第一个是递归深度默认受cte_max_recursion_depth限制,默认值是1000。对大多数树形结构够用,但如果分类层级特别深,或者代码有死循环风险,记得先设置会话级参数再跑。第二个是性能:递归CTE的每一层递归都会执行一次查询,如果树上节点很多,总开销并不低。我的经验是:如果树的深度稳定在3层以内,直接用一次JOIN把整棵浅树查出来,反而比递归CTE更快。
3.4 CASE WHEN批量更新:一条SQL搞定状态流转
先看一个高频业务:有一批订单需要根据当前状态做流转,比如“待支付”改成“已取消”,“已支付”改成“待发货”。新手做法是什么?查出来,循环,一条条UPDATE。如果有一万条订单,就是一万次网络往返,加上一万次事务提交,慢是必然的。
正确的做法是用 CASE WHEN 做批量更新:
UPDATE orders SET status = CASE status WHEN '待支付' THEN '已取消' WHEN '已支付' THEN '待发货' ELSE status END, updated_at = NOW() WHERE id IN (101, 102, 103, 104, 105);这一条SQL会原地震荡更新所有目标行,网络开销只有一次,事务只有一个。实测一万条级别的状态更新,从几十秒降到一两秒很正常。
为什么快?第一,减少网络往返;第二,减少日志写入量,单条SQL的redo log和undo log都合并成一次提交;第三,InnoDB执行UPDATE时,在一个事务里连续更新多行,只在提交时刷一次盘,比逐条提交省太多。
坑也要说清楚:如果一次更新的行数太多(比如几十万条),会导致单个事务执行时间过长,锁持有时间过长,可能拖垮主从同步。我的习惯是:每批控制在1000到5000行之间,分批执行。另外CASE WHEN里千万别漏掉 ELSE status,否则不在枚举里的行会被统一置为NULL,那事故级别就不是慢查询了。
3.5 JOIN LATERAL:每组Top N的最优解
“查每个项目最新3笔订单”这种需求,很多人的第一反应是窗口函数。窗口函数确实能做,但是遇到一个特殊场景——项目数量很多、每个项目下订单量很小——窗口函数会对全量数据做分区排序,浪费大量计算。这时候JOIN LATERAL更合适。
MySQL 8.0.14开始支持LATERAL派生表。写法是这样的:
SELECT p.project_id, p.project_name, t.order_id, t.amount FROM projects p LEFT JOIN LATERAL ( SELECT o.project_id, o.order_id, o.amount FROM orders o WHERE o.project_id = p.project_id ORDER BY o.create_time DESC LIMIT 3 ) t ON TRUE WHERE p.is_active = 1;逻辑很直观:对于projects里的每一行,执行一次括号里的子查询,取这个项目的最近3笔订单。也就是说,外层驱动表有多少行,子查询就执行多少次——这叫“相关子查询”。
所以它的快是有前提的:左表行数不能太大,右表关联字段必须有索引。我处理过的一个场景是“300个活跃项目,每个项目最多几十条订单”,JOIN LATERAL只需要做300次索引搜索,走索引分别取3条,可能比窗口函数对所有订单统一排序快得多。
坑就一个字:小心N+1。在项目数量巨大(比如百万级)且没有合适的索引时,JOIN LATERAL会变成灾难性循环查询。使用前务必先确认左表结果集大小,并且确认orders表上project_id有索引。
3.6 INSERT ON DUPLICATE KEY UPDATE:幂等写入的正确姿势
同步数据的经典场景:每天凌晨从上游拉取用户信息,更新到本地库。如果用户已存在就更新,不存在就插入。很多老代码是:先SELECT查一下,再决定INSERT还是UPDATE。这个方案有两个问题:第一,两次操作之间有竞态,并发场景可能插重复;第二,额外的一次查询本身就是浪费。
MySQL的INSERT ... ON DUPLICATE KEY UPDATE直接合并成一步:
INSERT INTO user_extra (user_id, nickname, age, update_time) VALUES (1001, '新昵称', 30, NOW()) AS new ON DUPLICATE KEY UPDATE nickname = new.nickname, age = new.age, update_time = new.update_time;这个语法的要求是:表上必须有主键或唯一索引,让MySQL能判断“重复”。它的性能优势是避免额外的SELECT,并且把插入和更新统一在一个语句里,自动完成“存在则更新、不存在则插入”的幂等逻辑。
这里必须提一个MySQL 8.0.20的改动:老代码里常见的VALUES(nickname)写法已经标记为弃用,新版推荐用上面的“别名+列名”方式,语义更清晰,也更安全。还有一个隐藏的坑:即使执行的是UPDATE分支,这张表的自增ID也会被消耗掉,因为InnoDB会先尝试插入、生成自增ID,再发现冲突转去更新。所以如果这张表同时有频繁的纯插入操作和这个语法,自增值会出现断层。业务上如果依赖ID连续,别用它。
3.7 UPDATE...JOIN:多表联动更新的利器
再举一个场景:订单主表需要定期同步子表汇总金额。常规思路是先把子表GROUP BY结果读到内存,再逐条UPDATE主表,又是N次更新。更好的方案是让UPDATE自己去JOIN:
UPDATE orders o JOIN ( SELECT order_id, SUM(amount) AS total_amount FROM order_items GROUP BY order_id ) i ON i.order_id = o.order_id SET o.total_amount = i.total_amount WHERE o.status = '待确认';这条SQL的含义是:先把子表按订单分组聚合,再关联主表更新。MySQL会先执行派生表生成聚合结果,再做更新操作,整个过程都在数据库内部完成,没有应用层的多次往返。
使用这个写法有个大坑:如果JOIN关系是一对多,比如派生表里同一个order_id有多行,那么主表那行会被更新多次,最终值取决于最后一次更新的结果,容易产生不确定性。所以一定要先在子查询里做GROUP BY,确保JOIN的键唯一。另一个坑是:UPDATE ... JOIN的WHERE条件只能放在最后,如果你固守“UPDATE后面不能JOIN”的老观念,很容易把条件写错位置。实际上MySQL是支持这种语法的,但它不像标准UPDATE那样可以在SET前直接写子查询条件,所以写之前先在原表上跑一遍SELECT验证影响行数,再改成UPDATE,这是最稳妥的。
3.8 条件聚合:一次扫描出多个业务指标
做运营报表的人最容易犯的错,是一个指标写一条SQL。比如:
- 查所有用户的总订单数
- 查已支付订单数
- 查最近30天支付金额
三条SQL分别执行,意味着对同一张表扫描三遍。数据量小没关系,到了上千万行,三次全表扫描就是三倍成本。其实完全可以压成一条SQL,用条件聚合一次扫完:
SELECT user_id, COUNT(*) AS total_order_count, SUM(CASE WHEN status = '已支付' THEN 1 ELSE 0 END) AS paid_order_count, SUM(CASE WHEN create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN order_amount ELSE 0 END) AS recent_paid_amount FROM orders WHERE create_time >= DATE_SUB(NOW(), INTERVAL 90 DAY) GROUP BY user_id;这里把三个条件下的指标揉进了同一个扫描过程,每行数据只用读一次,然后根据CASE条件分别累加。以前要三次全表扫描,现在一次搞定,性能是乘法级别的提升。
细节上注意两点。第一,SUM(CASE WHEN ... THEN 1 ELSE 0 END)里的 ELSE 0 一定要写,不写的话,不满足条件的行返回的是NULL,SUM会忽略NULL,效果一样,但语义上不如显式写0清晰。第二,WHERE范围依然要尽量收紧,条件聚合不是让你把整个历史全表都扫一遍的借口。比如上面这个SQL我只扫最近90天的数据,再用CASE判断30天内的部分,这样比扫全表快得多。
3.9 生成列加表达式索引:让JSON字段也能用上索引
JSON是MySQL 5.7开始支持的,很多人用得很爽,但有个老大难:JSON字段里的值不能直接建普通索引,导致每次按JSON里的某个字段过滤时都是全表扫描。
有人会问:那建一个“生成列”来索引行不行?答案是行,这就是正解。生成列的意思是把JSON里某个值提取出来,物化成一列,然后对这一列建索引,查询时直接走索引,对业务透明,不需要改SQL怎么写。
CREATE TABLE user_login_log ( id INT PRIMARY KEY, user_id INT, extra JSON, login_hour TINYINT AS (CAST(extra->>'$.hour' AS UNSIGNED)) STORED, INDEX idx_login_hour (login_hour) );以后查询:
SELECT * FROM user_login_log WHERE login_hour BETWEEN 8 AND 20;就能直接走idx_login_hour索引。这里的关键是CAST(... AS UNSIGNED)显式类型转换,如果不转,生成列是字符串类型的,查询条件里是数字类型,容易出隐式转换导致索引失效。
生成列有两种:STORED意味着物理存储该列,占用磁盘空间,但读取时不用计算;VIRTUAL则是在读取时实时计算,不占磁盘,但二级索引效率略低。我的实际经验是:如果这个列查询频次高,用STORED;如果只是偶尔过滤,VIRTUAL够用。注意,STORED列在INSERT和UPDATE时会额外计算并写入磁盘,所以高写入场景下别放太多这种列。
3.10 EXPLAIN ANALYZE:判断高级SQL好坏的标尺
最后这条严格说不是一种SQL写法,而是审阅一切高级SQL的“听诊器”。很多人写完SQL只看执行时间,但执行时间受系统负载影响很大,不能精确定位瓶颈。MySQL 8.0.18开始提供的EXPLAIN ANALYZE,会真实执行这条SQL,并告诉我们每一步操作的耗时、扫描行数、循环次数:
EXPLAIN ANALYZE SELECT user_id, COUNT(*) FROM orders WHERE create_time >= '2024-01-01' GROUP BY user_id;输出里你会看到类似:
-> GroupAggregate: (cost=...) -> Index Range Scan on orders using idx_create_time (cost=... rows=... actual time=... rows=... loops=...)它比普通EXPLAIN多出来的关键是actual time、actual rows和loops。比如你看到一个索引扫描实际扫出来的行数跟预估差了一个数量级,就知道统计信息过期了。看到loops很大,就知道这个节点反复执行了很多次,是典型的驱动方式有问题。
这招在日常优化里价值巨大。我的习惯是:写完一条复杂的SQL,先用普通EXPLAIN确认执行计划,再用EXPLAIN ANALYZE验证实际耗时分布。注意,EXPLAIN ANALYZE会真正执行SQL,如果这条SQL本身就慢得离谱,分析它也可能等很久。先在小数据量环境验,或者用LIMIT限制一下,不要直接在生产大表上盲跑。
4. 常见问题与排查技巧实录
别急着把上面10种技巧全铺到生产环境。我在引入这些写法时踩过不少坑,这里整理成速查表,方便你出问题时直接对号入座。
| 症状 | 可能原因 | 解决思路 |
|---|---|---|
| SQL加了索引但执行计划没走 | 隐式类型转换或函数包裹索引列 | 修改查询条件,或改用生成列 |
| 窗口函数一跑内存就爆 | 分区后排序数据量过大,中间结果落盘 | 先用WHERE缩小数据范围,再开窗 |
| 批量UPDATE执行很慢 | 一次更新的行数过多,锁等待严重 | 按主键范围分批,每批1000~5000行 |
| JOIN LATERAL查询奇慢 | 左表行数巨大,或子查询关联字段无索引 | 检查驱动表大小,确认关联列建索引 |
| INSERT ON DUPLICATE KEY UPDATE执行时报错 | 表上没有唯一键 | 必须依赖主键或唯一索引判断冲突 |
| CTE递归查询报错 | 递归深度超过默认限制 | 调cte_max_recursion_depth,并检查是否死循环 |
| EXPLAIN ANALYZE本身很慢 | 语句真实执行了,无法避免耗时 | 先EXPLAIN;小数据量验证后再全量执行 |
| GROUP BY报表很慢 | 扫描范围过大或没走索引 | 收紧WHERE范围,确认GROUP字段可走索引 |
除了表格里的问题,我再补两个约等于“保命经验”的细节。
第一,执行计划里的rows是预估值,别把它当真相。如果查询走了二级索引,但优化器预估的返回行数远大于实际,可能是因为ANALYZE TABLE很久没跑了。统计信息过期会让优化器选错索引,这时候一句ANALYZE TABLE orders;可能就救回来了。
第二,任何涉及大表结构的ALTER TABLE,比如加索引、加生成列,在线上执行时要评估锁影响。MySQL 8.0支持在线DDL,但大表加索引依然可能产生额外负载。我的稳妥方案是:低频业务时段执行,先ALGORITHM=INPLACE, LOCK=NONE试一次,遇到不支持时及时回滚。
5. 实战复盘:一个报表查询从3秒到30毫秒
把前面的技巧串起来,实战复盘一个我在模拟项目X里真实做完的优化,整个过程很有代表性。
5.1 原始场景:订单报表统计
业务方要一个报表:按用户统计最近90天的总订单数、支付订单数、支付总金额和首单时间。原始SQL长这样:
-- 第一遍扫:总订单数 SELECT user_id, COUNT(*) FROM orders WHERE create_time >= DATE_SUB(NOW(), INTERVAL 90 DAY) GROUP BY user_id; -- 第二遍扫:已支付订单数 SELECT user_id, COUNT(*) FROM orders WHERE create_time >= DATE_SUB(NOW(), INTERVAL 90 DAY) AND status = '已支付' GROUP BY user_id; -- 第三遍扫:支付总额 SELECT user_id, SUM(amount) FROM orders WHERE create_time >= DATE_SUB(NOW(), INTERVAL 90 DAY) AND status = '已支付' GROUP BY user_id;三遍全表扫描,每遍几百万行,加起来超过一秒钟。再加上报表页面还有别的查询,整体响应3秒起步。
5.2 优化思路和SQL对比
第一步,先看执行计划。orders表有idx_create_time(create_time)索引,但GROUP BY是user_id,排序和分组需要额外临时表。我用条件聚合压成一条SQL:
SELECT user_id, COUNT(*) AS total_order_count, SUM(CASE WHEN status = '已支付' THEN 1 ELSE 0 END) AS paid_order_count, SUM(CASE WHEN status = '已支付' THEN amount ELSE 0 END) AS paid_amount, MIN(create_time) AS first_order_time FROM orders WHERE create_time >= DATE_SUB(NOW(), INTERVAL 90 DAY) GROUP BY user_id;第二步,检查执行计划发现,虽然用上了idx_create_time范围扫描,但回表次数还是不少,因为要取status和amount。于是我建了一个覆盖索引:
ALTER TABLE orders ADD INDEX idx_time_status_amount (create_time, status, amount);然后执行:
EXPLAIN ANALYZE SELECT user_id, COUNT(*), SUM(CASE WHEN status = '已支付' THEN 1 ELSE 0 END), SUM(CASE WHEN status = '已支付' THEN amount ELSE 0 END), MIN(create_time) FROM orders WHERE create_time >= DATE_SUB(NOW(), INTERVAL 90 DAY) GROUP BY user_id;这次执行计划显示:走了覆盖索引的Index Range Scan,Using index,没有回表,没有 Using temporary,没有 Using filesort,实际扫描行数比原来少了近一半,因为索引页更紧凑。
5.3 优化效果与落地细节
优化后,同一份报表的耗时从3秒降到30毫秒左右。核心变化就两个:一是三条SQL合并为一条,扫描次数变成了原来的三分之一;二是覆盖索引消掉了回表,每次扫描读取的数据量大幅减少。
这里有个细节值得记一下:你别把这个覆盖索引一加就完事,写条件聚合的时候,WHERE范围依然只圈了90天。如果业务哪天要“查所有历史”,这个优化马上失效,得重新设计分区和索引。我在项目里额外做了一层限制:报表默认只允许查最近180天,避免用户一个手滑扫全表。
所以如果你也想复现这个优化,顺序应该是:先确认业务是否能压缩时间范围,再合并多条SQL为条件聚合,最后才评估是否要覆盖索引。别一上来就加一堆索引,数据还在持续写入的话,写性能会被拖累。
最后分享一个我自己的使用习惯:不管写完哪种“高级SQL”,我都会顺手执行一遍EXPLAIN,确认执行计划里的type、key、Extra三列符合预期,再交给业务发上线。这个习惯帮我拦下了无数个看起来没啥问题、一上线就崩的“优化方案”。
真正优秀的MySQL开发,不是背了多少种函数,而是对每一种写法在引擎层是怎么执行的,心里有数。今天这10种高级SQL,希望你看完之后是“原来如此”而不是“收藏吃灰”。挑一两个贴近你业务的场景,先在小表上验证执行计划,再逐步推到生产,你会感受到性能飞升带来的踏实感。