最近在帮一个团队处理线上MySQL性能问题,发现十次告警里有七次是SELECT语句拖垮了整个库。上周那个案例尤其典型:一个报表接口从平均200ms直接飙到6秒,数据库连接池被打满,前端跟着一片超时报错。翻出慢查询日志一看,罪魁祸首就是一条200万行级别的全表扫描SELECT,数据量一涨,执行计划就彻底崩了。这种问题在MySQL运维里太常见了,所以这篇就专门聊聊SELECT语句优化的完整思路——从怎么定位慢SQL,到执行计划怎么看,再到索引怎么建、写法怎么改,最后给一个真实线上案例的逐步优化过程。不管你是刚接手数据库的新手,还是已经写了几年SQL的老开发,只要你的业务跑在MySQL上,这套思路都能直接用。
1. 拿到慢SQL先别急着加索引,先搞清楚它为什么慢
处理慢SQL时,我第一件事不是看SQL本身,而是搞清楚这条SQL到底慢在哪一步。很多人一上来就"加个索引试试",这是最浪费时间的做法——SQL慢的原因可能根本不在索引,而在锁等待、临时表、大事务、甚至网络往返。方向错了,后面所有工作都是白费。
1.1 慢查询日志和全局状态的正确用法
生产环境建议长期开启慢查询日志。MySQL的慢查询日志默认是关闭的,需要在配置里打开:
slow_query_log = ON slow_query_log_file = /var/log/mysql/mysql-slow.log long_query_time = 1 log_queries_not_using_indexes = ONlong_query_time的官方单位是秒,我习惯在测试环境把它调到0.1秒,这样能捞出更多潜在问题;生产上建议1~3秒,否则日志量太大,一个高峰期下来能写几十GB。
除了慢日志本身,我还会顺手看几个全局状态值:
SHOW GLOBAL STATUS LIKE 'Select_full_join'; SHOW GLOBAL STATUS LIKE 'Select_scan'; SHOW GLOBAL STATUS LIKE 'Sort_merge_passes';Select_scan表示全表扫描的次数,Select_full_join表示没走索引的关联查询次数。这两个值如果持续上涨,说明库里存在批量性的坏SQL,光修一条没用,得按应用维度去排查。
1.2 先区分是"执行慢"还是"等待慢"
这是排查中最容易翻车的一步。一条SELECT的执行时间由两部分组成:真正干活的时间 + 排队等待的时间。等锁、等元数据锁、等buffer pool空间,都算在等待时间里。有时候SQL本身只要几十毫秒,但被一个长事务锁在前面,表现就是"执行了5秒"。
我在MySQL 8.0上一般这么看:
SET profiling = 1; SELECT * FROM orders WHERE status = 0 ORDER BY created_at DESC LIMIT 20; SHOW PROFILE FOR QUERY 1;SHOW PROFILE会列出这条语句在Sending data、Sorting result、Copying to tmp table、statistics等阶段的耗时分布。如果看到大量的"Waiting for table metadata lock",那就是有别的长事务没提交,跟SQL本身无关,直接去查information_schema.innodb_trx,把阻塞源处理掉,问题就消失了。
1.3 SELECT在事务里的位置比你想的重要
这里要提一下事务视角。InnoDB的普通SELECT是快照读,走MVCC,不加锁。但如果你在同一个事务里先SELECT,后面又执行UPDATE或SELECT FOR UPDATE,事务就会拉到当前读路径,事务开启时间越长,undo log越难清理,后面的快照读要回滚的版本链就越长,实际表现就是:SQL越跑越慢,而且你改索引根本没有用。
我踩过一个很经典的坑:业务代码里一个方法被@Transactional包着,里面先查了20条数据做校验,然后调远程接口超时等了30秒,事务一直没提交,之后所有查同一张表的连接都在等锁。这种问题要是不先看事务和锁,光优化SELECT写法是治不好的。所以拿到慢SQL,第一个要问的是:这条SELECT所在的连接,当前事务开了多久、有没有未提交的写操作。
2. 读懂EXPLAIN执行计划,纸上推演比盲目改写靠谱
定位完"确实是语句本身慢"之后,下一步就是把这条SQL的执行计划挖出来。MySQL里没有任何一个工具能像EXPLAIN这样直观地告诉你"这条SQL会不会快、快在哪里、差在哪里"。
2.1 type、key、rows、Extra这四列最重要
EXPLAIN的输出列很多,select_type、table、partitions这些我基本只看一眼,真正决定性能的是下面这四样:
| 列名 | 看什么 | 重点关注 |
|---|---|---|
| type | 访问类型 | 最好到最差:const/system > eq_ref > ref > range > index > ALL |
| key | 优化器选中的索引 | 如果为NULL,说明没走索引 |
| rows | 预计扫描行数 | 这个数字越大,扫描成本越高 |
| Extra | 额外信息 | 看到Using filesort、Using temporary就要警惕 |
其中访问类型是衡量扫描范围的直接指标。const表示通过主键或唯一索引查到最多一行,eq_ref表示关联查询中被驱动表每次最多读一行,ref表示用普通二级索引等值匹配,range表示走了范围扫描,index表示扫描了整棵索引树,ALL就是全表扫描。一条统计查询如果type=ALL且rows=1200万,那无论怎么写,物理上就是要扫全表,除非加索引。
我在EXPLAIN里最怕看到两个组合:rows很大 + Extra里有Using filesort或者Using temporary。这说明扫描大结果集的同时还要排序或建临时表,简直是把两种最贵的操作叠在一起。
2.2 Using filesort和Using temporary的背后逻辑
结合"mysql排序"这个话题来说,filesort不是磁盘排序,准确说它是"在内存或磁盘上为结果集额外排序"。一旦出现,MySQL就得把符合条件的行先捞出来,再按ORDER BY字段排序。没有索引可以利用时,这条成本几乎随行数线性上升。我见过一个真实案例:一张500万行的表,按创建时间排序查最新20条,因为没有走索引,每次都要把符合条件的50万行全捞出来排序,慢是真的慢。
Using temporary一般出现在GROUP BY、DISTINCT、UNION这类需要去重或聚合的语句里。MySQL如果判断无法通过索引直接完成分组,就会先把中间结果写进内存临时表,数据量超过tmp_table_size后还会落到磁盘临时表。看到Using temporary的时候,我会先检查GROUP BY字段和WHERE条件字段是否在同一联合索引里,这比盲目改SQL写法通常更靠谱。
2.3 别对rows和filtered太当真
EXPLAIN里rows是基于采样统计的估算值,不是精确值。MySQL统计信息默认是从存储引擎采样得到的,当数据分布变化剧烈但统计信息没更新时,rows可能和真实值差出十倍以上。
filtered表示经过过滤后剩余行的百分比,注意它在MySQL 8.0.17以后的EXPLAIN ANALYZE里才是实际值,普通EXPLAIN里依旧是估算。我一般把rows × filtered当作这条语句实际可能触碰的行数。如果500万行的表,rows=500万、filtered=1%,说明优化器认为最终只剩5万行,但前提是它能找到快速过滤的路径;如果它选择了全表扫描,那这5万行是扫完500万之后才剩下来的,代价一点没省。
2.4 另一个容易被忽视的列:possible_keys
possible_keys列出了优化器理论上可选的索引,key是它最终实际选中的索引。当两个索引都可用时,优化器会按成本模型选一个。了解这列的意义在于:如果possible_keys为空,说明你这张表压根没有能匹配的索引,这时再怎么改SQL写法都白搭;如果possible_keys有值但key是NULL,说明优化器评估后认为走索引还不如全表扫描——这种情况通常是因为你建的索引区分度太低,比如一个字段只有0/1两个值,优化器觉得扫全表比走索引再回表更划算。
3. 索引设计:SELECT优化的第一生产力
如果说执行计划是看病,那索引设计就是开药。绝大多数SELECT性能问题,最终的解法都落在"索引没建对"这四个字上。但索引不是越多越好,设计的关键是"让优化器有合适的路可走"。
3.1 联合索引字段顺序的决策逻辑
决策逻辑核心就三句话:等值条件放前面,区分度高的放前面,排序字段放在匹配条件后面。
拿一个反例说明。一张用户订单表,常见查询是:
SELECT * FROM orders WHERE status = 1 AND user_id = 123 ORDER BY created_at DESC;有人建了(status, user_id, created_at)联合索引,表面看三个字段都覆盖了。但WHERE里user_id传入的更多是随机值,而status的取值只有0/1/2,区分度极低。把区分度低的status放最前面,优化器一看这索引第一列只能过滤出三分之一的数据,走索引还需要回表,干脆不如全表扫。更合理的首字段是user_id,因为它在查询里是等值条件,而且区分度高。联合索引的顺序错了,后面几个字段写得再好,等于前面被WHERE条件拦截下来的范围没有缩小。
3.2 最左前缀法则和它的例外
联合索引的匹配规则是最左前缀,如果查询条件里的列不是从联合索引最左列开始,索引就用不上。所以建索引前要捋清楚常见查询里的字段组合,看哪几个字段能覆盖大部分条件,而不是每个查询都单独建索引。
例外情况是MySQL 8.0.13开始支持函数索引,以及SKIP SCAN优化——它允许优化器在某些"跳过最左列"的情况下使用索引,但性能不如正常前缀匹配。这个功能生效条件比较苛刻,我不建议把业务查询设计成依赖SKIP SCAN,老老实实把最左列放进WHERE更稳定。
3.3 覆盖索引:让回表彻底消失
回表这个词,指的是二级索引找到主键后,再到聚簇索引里面去取整行数据。如果查询需要的所有列都已经在索引树里,MySQL就能直接返回,不需要回表,EXPLAIN里会显示Using index。这就是覆盖索引的效果。
举个例子:
SELECT order_no, status FROM orders WHERE status = 1;如果只有(status)单列索引,MySQL用索引找到status=1的叶子节点后,每个节点里只有status和主键,想要order_no就得再回表查一次。但如果你建了(status, order_no)联合索引,叶子节点里已经带上了order_no,查询可以直接从索引返回,省掉大量随机IO。对于高频的小查询,覆盖索引往往是投入产出比最高的一招。
3.4 索引下推到底干了什么
索引下推(Index Condition Pushdown,简称ICP)是MySQL 5.6引入的优化,默认开启。它的意思是:把一部分WHERE条件判断提前到索引扫描阶段。以前没有ICP时,索引定位到主键后要回表取行,再在服务层判断条件;有了ICP,有些条件在索引树内部就能判断掉,减少回表次数。
举例说明。索引(a, b),查询WHERE a > 1 AND b = 2。没有ICP时,先按a的范围把一堆主键捞出来回表,再逐行过滤b=2;有ICP时,MySQL在索引遍历过程中直接就判断了b=2,回表行数大大减少。这也是为什么"建联合索引"的收益经常比想象中大——不仅仅是因为排序,是因为ICP放大了过滤能力。
3.5 索引不是堆得多,而是堆得准
结合"mysql创建索引"多说一句。我见过最夸张的表,单表挂了9个索引,其中8个都是单列索引,查询时优化器还经常选错。索引多了带来三个问题:一是B+树维护成本直线上升,INSERT/UPDATE/DELETE的写放大明显;二是索引占据大量存储空间,buffer pool里放得下数据就放不下索引;三是统计信息和优化器的决策空间变大,执行计划不稳定。
我在设计索引时有条粗线:单表单列索引不超过4个,联合索引不超过2个。而且每个索引都要能对应到具体SQL。如果一个索引在最近一个季度慢查询日志里从没被用到,那就是可以砍掉的候选。先砍再跑压测,看执行计划有没有变化,这种"瘦身"对写多的业务来说是实打实的收益。
4. 从SQL写法层面消除性能陷阱
索引建对了,很多慢SQL已经能解决。但还有一类问题,是SQL写法本身让索引发挥不出来。这层问题不解决,索引建得再好也是白搭。
4.1 SELECT *、隐式转换和函数包裹
排在第一的是SELECT *。这个坏习惯的代价有三层:网络传输的数据量大,连接层和buffer pool都被浪费;需要回表取所有列,索引覆盖失效;做排序、临时表、GROUP BY时,处理的字段越多,内存和磁盘的消耗越大。我一直建议:业务查询列,只写真正用到的字段。
第二是隐式类型转换。假设一个列是VARCHAR,你把它跟数字比较:
WHERE user_id = 123 -- 如果 user_id 是 varcharMySQL会尝试把列转成数字再比较,这一转,索引就失效了。字符集不一致也会出现隐式转换,最典型的坑是两表关联时一个utf8一个utf8mb4,关联字段没法直接用索引。所以不要在关联字段上混用字符集。
第三是函数包裹。最常见的写法是:
WHERE DATE(created_at) = '2024-11-20'DATE函数把created_at的索引列包住了,B+树无法按范围检索,只能全扫。改成:
WHERE created_at >= '2024-11-20 00:00:00' AND created_at < '2024-11-21 00:00:00'索引就能正常走起来。如果你确实天天要用日期函数查询,MySQL 8.0.13以后可以建函数索引,但这属于偏招,能用普通列范围解决的问题尽量不要用函数索引。
4.2 OR、IN、NOT IN、LIKE这些关系词的取舍
OR条件有个隐藏规则:MySQL只有确认OR两边都能走索引时,才会用索引合并取并集,否则就是全表扫描。比如:
WHERE status = 1 OR status = 2如果两个值都能用索引范围定位,还行;但如果一边能走索引一边不能,整个条件就会被优化器降级成全扫。我一般会让业务写IN而不是多个OR,改写成:
WHERE status IN (1, 2)执行路径更稳定。
LIKE的坑大家都熟,LIKE '%abc'和LIKE '%abc%'必然全扫,但LIKE 'abc%'是可以走索引的前缀匹配。业务里如果真需要中间或后缀模糊搜索,更靠谱的方案是上全文索引或者外部检索系统,而不是在MySQL里硬抗。NOT IN和NOT EXISTS这两个操作符也要慎用,它们经常让优化器放弃索引选择全表扫描。需要排除某个集合时,优先考虑LEFT JOIN + IS NULL写法,很多场景下执行计划明显更好。
4.3 深分页优化:延迟关联才是正解
分页是SELECT优化里绕不开的场景。很多业务一上来就写LIMIT 100000, 20,MySQL的LIMIT实现是"先扫够100020行,再把前100000行扔掉"。翻页越深,扔掉的越多,消耗就越大,而且这个过程还伴随着可能的filesort。
延迟关联是我处理深分页最常用的招:先在子查询里把符合条件的主键取出来,再回原表取完整行。例如:
SELECT o.* FROM orders o INNER JOIN ( SELECT id FROM orders WHERE status = 1 ORDER BY created_at DESC LIMIT 100000, 20 ) t ON o.id = t.id子查询里因为只需要主键和排序字段,走的索引树很轻,扫描大偏移量的成本远低于回表拿全行。
4.4 子查询改JOIN,但别踩去重的坑
子查询能不能改成JOIN,不能一刀切。MySQL 5.7以后对IN子查询有半连接优化,很多情况下子查询效率并不差。但相关子查询和派生表场景确实容易出问题:外层每查一行,内层子查询就执行一次,变成典型的嵌套循环,数据量一大就崩。
改写时要留意的一个坑是去重问题。WHERE id IN (SELECT xxx FROM t2)如果改成JOIN,t2里出现重复记录时结果集会变大,必须先GROUP BY或者用DISTINCT去重,否则业务数据就错了。我见过不止一个人把子查询改成JOIN后"性能变好了",但对账发现数据多出来几千行。
4.5 COUNT和排序优化里容易被忽略的点
COUNT(*)和COUNT(字段)是有区别的。InnoDB里COUNT(*)会找最小的索引来数,COUNT(字段)还要额外判断字段是否为NULL,反而更慢。大表计数想要秒级,要么走缓存,要么用汇总表定时累加,硬在线上跑COUNT对数据库压力很大。排序优化在前面索引章节提过,这里再补充一句:ORDER BY要利用索引,排序字段必须跟WHERE条件的等值字段在同一联合索引里,并且排序方向要一致。比如WHERE user_id = 1 ORDER BY created_at DESC,可以建(user_id, created_at)索引,但如果你要ASC又要DESC,8.0开始支持降序索引,可以直接建(user_id, created_at DESC)。
5. 事务和锁视角下的SELECT优化
SELECT优化不只是索引和SQL写法的事,事务和锁的影响比很多人想象中大得多。这一节我会把"mysql事务处理"和"mysql锁的分类"两个重点串起来讲。
5.1 快照读与当前读:普通SELECT为何不锁表
InnoDB默认隔离级别是REPEATABLE READ,这里普通SELECT走的是快照读。所谓快照读,就是基于MVCC的版本链,读取的是该事务开始时的一致性快照,不加任何锁。这也是为什么很多人说"MySQL的SELECT不会挡别人的SELECT",普通SELECT之间完全不互相阻塞。
但要注意两个例外:SELECT ... FOR UPDATE和SELECT ... LOCK IN SHARE MODE。这两个语句走的是当前读,会对命中的行加上排他锁或共享锁,并且会在记录之间的间隙加间隙锁,这正是并发场景下死锁和锁等待的主要来源之一。如果你在代码里习惯了用FOR UPDATE去解决并发问题,要非常小心它和批量更新之间的锁互斥,线上经常出现"一条SELECT卡死一片服务"的情况。
5.2 长事务为什么会拖垮SELECT
普通SELECT虽然不加锁,但它所在的读事务会一直持有自己的快照。REPEATABLE READ隔离级别下,只要事务不结束,这个快照就要保留,意味着事务开始之后的旧版本undo不能被清理,同时所有需要读取该行的其他事务都要沿着版本链回滚到对应时间点。事务拖得越长,版本链越长,每次查询的额外成本越高。
真实案例:一个服务方法用@Transactional包住,里面先SELECT,接着调外部接口等30秒,再UPDATE。这个事务开始到提交间隔了30秒以上,期间所有针对同一行数据的SELECT都要遍历几个版本的undo,SQL本身没变,但是越来越慢。我的排查习惯是直接看information_schema.innodb_trx表,按trx_started排序,把那些"只读却长时间不提交"的事务找出来,这个问题比索引失效隐蔽得多。
5.3 锁等待的快速定位方法
再回一下"mysql锁的分类"。InnoDB锁大致分三类:Record Lock记录锁锁单行;Gap Lock间隙锁锁索引记录的间隙;Next-Key Lock是前两者组合,锁住"索引记录+前面的间隙"。普通SELECT不涉及这些,但FOR UPDATE和写操作都会涉及。
当应用超时、数据库线程堆积时,我一般这样查:
SELECT * FROM performance_schema.data_lock_waits; SELECT * FROM sys.innodb_lock_waits;sys.innodb_lock_waits会直接给出被阻塞的事务、阻塞源事务、等待锁的SQL、阻塞事务的SQL。看到结果后,关键动作是查阻塞事务的trx_started和trx_rows_modified,判断它是一个写了很多行但迟迟不提交的"大胃王",还是一个查完忘提交的"慢吞吞"。大多数线上锁等待都是后者:事务开着不提交,前端的SELECT和更新全被卡住。这种问题靠优化SQL本身解决不了,必须从应用层的事务边界下手。
6. 一个完整案例:从2.3秒优化到12毫秒
理论讲了这么多,最后分享一个我实际处理的线上案例,把整套思路串一遍。这个案例比较典型,包含执行计划分析、索引调整、SQL改写三层优化。
6.1 业务背景与问题SQL
业务是一个订单列表接口,单表orders约1200万行,每天新增几万条,按状态和日期过滤。线上告警是接口响应变慢,从平均200ms飙到2秒以上。我拿到的慢SQL简化后长这样:
SELECT o.id, o.order_no, u.nickname, o.status, o.amount, o.created_at FROM orders o LEFT JOIN users u ON o.user_id = u.id WHERE o.status = 0 AND DATE(o.created_at) = '2024-11-20' ORDER BY o.created_at DESC LIMIT 20;一眼就能看出两个问题:DATE(o.created_at)让created_at索引失效;status=0的过滤条件加上ORDER BY created_at,顺序上很难利用索引。
6.2 执行计划暴露出的根因
EXPLAIN结果很直观:orders表的type是ALL,rows约1200万,Extra里有Using where和Using filesort。两条危险信号全中。users表虽然走的是主键,但orders这边就已经把全表扫了一遍,关联根本救不回来。
当时线上表有不少零散索引,但没有一个能同时服务status过滤和created_at排序。再加上DATE函数包裹,优化器连range扫描的机会都没有,只能扫全表。
6.3 三层优化与效果验证
第一层优化是去掉函数包裹,把日期条件改成范围:
WHERE o.status = 0 AND o.created_at >= '2024-11-20 00:00:00' AND o.created_at < '2024-11-21 00:00:00'只做这一步,执行时间从2.3秒降到800ms左右。因为created_at单列索引虽然能用,但要先按日期过滤,再回表过滤status,成本还是不小。
第二层是建联合索引(status, created_at),让WHERE和ORDER BY同时落在同一棵索引树上。执行时间降到280ms。这里要注意,status区分度低,但它是等值条件,放在联合索引最前面仍然能帮优化器快速锁定范围;created_at放在后面则刚好满足排序,避免filesort。
第三层是做覆盖索引。查询最终要返回的字段是id、order_no、amount、created_at、status,而WHERE定位和排序用的是status和created_at。我建了(status, created_at, order_no, amount),把回表也省掉。执行时间最终稳定在12ms左右,接口整体恢复到100ms内。
| 优化步骤 | 改动内容 | 执行时间 |
|---|---|---|
| 原SQL | 无 | 2.3s |
| 第一步 | 日期函数改范围条件 | 约800ms |
| 第二步 | 加联合索引(status, created_at) | 约280ms |
| 第三步 | 覆盖索引,去掉回表 | 约12ms |
6.4 经验沉淀:别在同一坑里摔两次
这个案例最值得记住的点是:三层优化的每一层都在解决不同问题。第一步是让优化器能走索引,第二步是让过滤和排序共用索引,第三步是消灭回表。它们不是互斥选择,而是层层叠加的关系。之后你再看到一条慢SELECT,就按这个顺序过一遍:能不能走索引、能不能索引覆盖、能不能少回表、能不能用范围代替函数计算。每过一层,用EXPLAIN和真实执行时间验证一次,慢SQL的优化就没那么玄学。
最后再分享一个实际操作中的小技巧:线上改动索引之前,我习惯先把表的统计信息刷一遍,ANALYZE TABLE orders,不然优化器手里的统计是过期的,你建好了索引它可能还是选原来的烂计划。另外,所有索引调整尽量在低峰期执行,用pt-online-schema-change这类工具做在线变更,避免直接ALTER长时间锁表。这套流程我跑过很多次,从定位到落地再到验证,基本能覆盖日常遇到的九成SELECT性能问题。