接手线上业务系统这几年,最让我头疼的问题之一就是MySQL慢查询。明明功能开发完了、测试也过了,一到流量高峰期,几个核心接口突然就卡了几秒,数据库CPU飙高,紧接着就是连接数打满、应用报错。排查下来,十有八九是SQL走了全表扫描,或者索引没建到位。说穿了,MySQL性能优化核心就两件事:把慢SQL捞出来,把索引设计好。这条链路我踩了不少坑,今天把从慢查询定位到索引设计的完整实操流程整理出来,希望能帮同行少走弯路。
这篇文章适合谁看?一种是刚接手项目的后端开发者,线上出现慢查询但不知道从哪下手;另一种是已经知道EXPLAIN和索引概念,但遇到真实场景还是不知道怎么组合使用的人。我会把每个环节的原理、操作、踩坑点都说透,能直接套用到自己的项目里。
1. 慢查询日志:把“案发现场”先抓出来
1.1 开启慢查询日志的正确姿势
很多人的第一反应是,慢SQL不是直接在应用日志里能看到吗?还真不一定。应用层能看到的是接口超时、报错StackTrace、数据库连接异常,但具体是哪条SQL把数据库拖垮的,通常要等DBA拉数据库侧日志才能看到。而慢查询日志就是我们最直接的“案发现场”。
在MySQL里开启慢查询日志并不复杂,但有个前提:你要知道当前实例的参数是什么状态。我用得最多的一条语句是:
SHOW VARIABLES LIKE '%slow%';重点看三个参数:slow_query_log、slow_query_log_file、long_query_time。默认情况下,MySQL的慢查询日志开关是关闭的,你光改long_query_time是不生效的,必须把slow_query_log置为ON。
线上环境我建议这样配置(在my.cnf的[mysqld]段下修改或启动参数追加):
slow_query_log = ON slow_query_log_file = /var/log/mysql/mysql-slow.log long_query_time = 1 log_queries_not_using_indexes = ON log_throttle_queries_not_using_indexes = 10long_query_time设成1秒,不是0.5秒也不是5秒,这是我实践下来的经验值。设成0.5秒会把大量正常查询都记录下来,日志量膨胀得很快,真正问题反而被淹没;设成5秒又太迟钝,很多隐患其实是查询从1秒涨到3秒、5秒,等你看到5秒的慢查询时,业务其实早就受影响了一轮。1秒是个折中值,既能抓到大部分异常查询,又不会制造太多日志噪音。
这里有个细节容易被忽略:long_query_time是精确到微秒的,配置文件里写整数1,表示1秒整;你也可以写成0.5表示500毫秒。但我在生产环境很少用小数,一是日志噪音大,二是团队其他成员对“什么样的SQL算慢”的认知会变得模糊。
log_queries_not_using_indexes这个参数,意思是“即使查询没超过long_query_time,但只要没走索引,也记录到慢日志里”。这个参数非常有用,因为很多性能隐患不是query耗时已经很长,而是已经开始出现全表扫描的苗头了。配合log_throttle_queries_not_using_indexes做限流,能防止这类“无索引查询”日志刷屏。
1.2 慢查询日志长什么样
我截取一条线上真实的慢日志片段(脱敏处理过),说明一下怎么看:
# Time: 2025-01-15T10:23:45.123456Z # User@Host: app_user[app_user] @ [192.168.1.108] # Query_time: 5.234567 Lock_time: 0.000123 Rows_sent: 25 Rows_examined: 1048576 SET timestamp=1736922225; SELECT order_id, user_id, total_amount, status FROM orders WHERE create_time BETWEEN '2025-01-14 00:00:00' AND '2025-01-14 23:59:59' AND status = 1 ORDER BY create_time DESC LIMIT 25;先看Query_time: 5.23,说明这条查询总共花了5.23秒。再看Rows_examined: 1048576,扫描了104万行,但只返回25行。什么叫低效?这就是典型例子——为了拿25条数据翻了100万行。再看Lock_time只有0.000123秒,说明没有长时间锁等待,纯粹是查询本身慢,排除了锁竞争的因素。
这几组数字组合起来,能帮我们快速判断问题的大方向:
- 高
Query_time+ 高Rows_examined:典型扫描行数过大,优先考虑索引优化。 - 高
Query_time+ 低Rows_examined+ 高Lock_time:可能有锁等待,需要排查并发事务。 - 高
Query_time+ 高Rows_sent:可能是数据批量导出或报表类查询,未必是索引问题。
1.3 日志分析工具:从海量日志里捞重点
线上跑了几天后,慢日志文件可能已经几百MB了。你不可能一条一条用眼睛看,得用工具归类。我常用的两个工具:
mysqldumpslow是MySQL自带的,简单直接。我最常跑的这几条命令:
# 按平均查询时间排序,查看Top 10 mysqldumpslow -t 10 /var/log/mysql/mysql-slow.log # 按总查询时间排序,适合查看哪类SQL消耗的数据库时间最多 mysqldumpslow -s at /var/log/mysql/mysql-slow.log # 按扫描行数排序,看哪些SQL在扫描大量数据 mysqldumpslow -s ar /var/log/mysql/mysql-slow.log但要注意,mysqldumpslow默认会把SQL中的具体值替换掉,比如订单号、用户ID都会变成N。这有好处也有坏处:好处是能把同一类SQL聚合起来看,坏处是你无法直接拿到某条真实SQL的完整参数值。线上排查时通常需要上下文,我会用mysqldumpslow快速定位到某个模板SQL类型后,再去对应时间段的应用日志里找具体参数。
如果你的环境允许安装Percona Toolkit,pt-query-digest是更专业的工具。它的报告会按查询消耗总时间排序,还会给出每一类SQL的响应时间占比、历史波动情况,甚至能告诉你哪些查询是“单次快但次数极多”,这类问题在慢日志里按单次耗时看不出来,但按总消耗排就能暴露出真相。我经常遇到的情况是:某条SQL单次只要80毫秒,不算慢,但每秒要被调用几百次,累积下来的数据库压力比一条3秒的慢查询还可怕。pt-query-digest对这种“高频微慢”型查询的识别能力是很强的。
2. 用EXPLAIN读懂SQL的执行路径
2.1 EXPLAIN到底在说什么
抓出了慢SQL,下一步就是对它执行计划。EXPLAIN的输出列,每一个都值钱。我按自己的理解把核心字段解释一遍。
id:查询编号。通常一个查询只有一个id,但如果SQL里有多表连接、子查询,就可能出现多行记录。id越大越先执行,这是理解多表查询顺序的关键。
select_type:查询类型。最常用于判断是否出现了子查询或UNION。
table:这行输出对应哪张表。
type:这是所有字段里最重要的,直接决定了访问方式。从上到下效率递减:
system > const > eq_ref > ref > range > index > ALL我简单说明一下:system和const是走了主键或唯一索引的等值查询,这是效率最高的,MySQL直接把结果当成常量处理;eq_ref是连接查询中被驱动表走主键或唯一索引,每个驱动表行最多匹配一行;ref是非唯一索引的等值匹配,可以匹配多行;range是索引范围扫描,典型的就是BETWEEN、>、<、IN;index听起来像走索引,但实际上是把整个索引树扫了一遍,比全表扫描好一点但也是扫描全部;ALL就是全表扫描,基本是性能问题的头号嫌疑。
看到ALL,大概率就要警觉。但别看到ALL就急着加索引,还要结合rows字段看。
rows:预估扫描行数。这个数字和实际执行的行数可能不完全一致,但可以用来比较不同执行计划的优劣。
filtered:预估被条件过滤的比例。rows乘以filtered大致就是实际返回行数。
Extra:这一列信息量也很大。我挑几个常见的说:
Using index condition 使用索引下推,通常和二级索引配合 Using where 存储引擎返回后在Server层过滤 Using index 使用覆盖索引,不回表 Using temporary 用临时表,常见于GROUP BY或去重 Using filesort 文件排序,通常需要优化Using temporary和Using filesort是两个需要注意的信号,尤其是Using temporary,往往意味着MySQL需要把中间结果暂存到临时表,一旦数据量大,磁盘I/O就是把性能拖垮的元凶。
2.2 用EXPLAIN实测:发现索引失效的瞬间
来看一条线上出过问题的SQL:
EXPLAIN SELECT * FROM orders WHERE order_no = 202501150001231;这个表order_no字段其实建了唯一索引,但执行计划显示type为ALL,rows到了几十万。为什么有索引还全表扫?这里要警惕:order_no字段可能是VARCHAR类型,而我传的参数是数值类型,MySQL会做隐式类型转换,先把字段值转成数值再比较,索引就失效了。
遇见这种情况,我会再多跑一条SQL确认:
EXPLAIN SELECT * FROM orders WHERE order_no = '202501150001231';这次type变成了const,走了唯一索引。一对比,问题原因就非常清楚了。隐式类型转换是索引失效的高频原因,而且往往专门坑老手——因为看起来SQL写得没问题,参数类型也没人深究。排查慢查询时,把常用查询条件字段的类型和应用传入类型逐一对齐,是我给自己定的硬规矩。
2.3 对执行计划快速判断的流程
拿到一个执行计划,我习惯按下面的顺序过一遍:
- 先看
type是不是ALL或index。如果是,基本可以确认索引设计有问题。 - 再看
Extra里有没有Using temporary或Using filesort。 - 然后看
rows,结合表总行数估算扫描比例。如果扫描比例超过5%-10%,即使走了索引,也可能需要考虑优化SQL逻辑,因为从索引中读取5%的数据,优化器往往会判断为不如全表扫。 - 最后看
key列,确认实际用的索引是哪条。有时候你建了索引,但优化器选了另一条,也常见。
这套四步流程走完,慢SQL的病根基本能定位个七八成。
3. 索引设计:从B+树原理到落地策略
3.1 为什么索引能提速,以及它的代价
理解了慢查询和EXPLAIN,还只是解决了“发现问题”这一步。真正难的是“如何设计索引”。要设计好索引,得先理解InnoDB索引底层的B+树结构。
一句话概括:B+树是一种多路平衡查找树,数据只存在叶子节点,非叶子节点只存索引键值。叶子节点之间用双向链表连接。这样的结构带来的好处是:按索引键查找时,从根节点到叶子节点的路径非常短,三层B+树就能支撑上千万行数据,也就是查询一次最多读三个节点,再加上叶子节点链表对范围查询的天然支持,效率远高于二分查找或全表扫描。
但索引不是免费的。每一次插入、更新、删除,都要额外维护索引结构,写放大是不可避免的。一张表如果建了太多索引,写入性能会明显下降,磁盘空间占用上涨。这就是为什么“索引建得越多越好”是错误观念。核心原则是:优先保证查询频次高、区分度好的字段走索引,同时控制索引总数。
3.2 单列索引还是组合索引
大多数优化课题都会走到组合索引这一步。组合索引的核心逻辑是“最左前缀原则”。一个组合索引(a, b, c)存储时是先按a排序,a相同再按b排序,b相同再按c排序。查询时,MySQL可以利用到a、a,b、a,b,c,但直接用b或c作为条件就命不中这个索引的左前缀规则。
就拿我遇到过的一个实际例子来说,某张业务表经常有这样的查询:
SELECT * FROM user_order WHERE user_id = 1001 AND status = 1 AND create_time >= '2025-01-01' ORDER BY create_time DESC;如果分别建单列索引(user_id)、(status)、(create_time),MySQL一次查询只能选择其中一个索引作为主驱动。这会导致其他条件在返回后过滤,扫描行数多,排序也要额外处理。而正确的做法是建组合索引(user_id, status, create_time),让三个条件都尽可能走索引,同时ORDER BY create_time也能直接用索引的有序性完成排序,减少Using filesort的概率。
组合索引的列顺序,总体原则是把区分度高的列放前面。区分度可以理解为某个列值的不重复比例,比如user_id通常就是高区分度字段,status如果只有两个枚举值,区分度就很低。把高区分度列放在组合索引前面,能更快地缩小扫描范围。
需要强调,最左前缀原则并不是说组合索引必须覆盖所有查询条件才能用,只要查询条件符合最左前缀的顺序,哪怕只有第一个字段等值匹配,索引也能生效。但如果是第一个字段的范围查询,后面字段的排序利用就会受限,这是写SQL时要注意的细节。
3.3 覆盖索引:少查一次回表的收益
InnoDB的二级索引叶子节点存的是索引列+主键值,通过二级索引查到主键后,再去聚簇索引取完整行数据,这个动作叫回表。回表次数越多,I/O开销越大,尤其是遇到大量随机主键时,性能损失非常明显。
覆盖索引的意思是:查询需要的所有列都在同一个二级索引里,不需要回表。举个例子,表结构有字段order_id、user_id、status、create_time,原本查询是:
SELECT order_id, user_id, status FROM orders WHERE user_id = 1001;如果建立了组合索引(user_id, order_id, status),那么索引里就包含user_id、order_id、status三列,查询完全可以只扫描索引就返回结果,Extra里会出现Using index标记,斜体提示就是这意思。覆盖索引的优化效果很直观,能明显降低磁盘I/O,但代价是索引本身占用更多空间,所以只推荐在核心高频查询上这么做。
3.4 索引设计要不要管写入性能
这是个容易走极端的问题。我见过一些开发同学,为了让查询“飞起来”,一口气建了七八个索引,结果写入变慢、表体积膨胀,甚至用户体验更差了。索引一定是有代价的,这个代价在写入密集的表上感受得特别明显。实际项目中,尤其是订单、日志、流水这类有持续写入压力的表,每加一个索引,写入时就要多维护一棵B+树,锁竞争和redo日志都会变大。
我的做法:先统计表中的高频查询条件,再统计低频查询条件,优先级排序后设计索引。凡不属于核心路径的查询,宁愿让它慢一点,也不让索引数量失控。表行数超过百万、核心查询又有强排序、强过滤需求的,有策略性的冗余字段设计,往往是比死磕索引更划算的方向。
4. 完整优化实战:从诊断到落地的全流程
4.1 案例一:深分页查询慢得离谱
某日线上有个接口,前端要支持翻页到第100页往后,某同事反馈数据一多就超时。慢日志找到的SQL是这样:
SELECT id, order_no, user_id, status, create_time FROM orders WHERE user_id = 10086 ORDER BY create_time DESC LIMIT 5000, 20;这个SQL的问题在于LIMIT 5000, 20,MySQL并不是“跳过5000行再取20行”,而是先按条件扫描出前5020行,把前5000行扔掉,只返回最后20行。用户ID过滤后基线本来就有几十万行,每次翻到靠后的页码,前面所有扫描都成了无用功。
优化方法之一是用“延迟关联”:
SELECT o.id, o.order_no, o.user_id, o.status, o.create_time FROM ( SELECT id FROM orders WHERE user_id = 10086 ORDER BY create_time DESC LIMIT 5000, 20 ) tmp JOIN orders o ON tmp.id = o.id;核心逻辑是让子查询先用索引走最窄的列(只查主键ID),把分页的行数扫描降到最小,然后再回原表取完整行数据。实际测试中,同样的翻页深度,这个写法能把查询从原来的3秒多压到0.2秒以内,效果立竿见影。
4.2 案例二:IN+ 子查询的深坑
有个报表需求,要从两张表里查数据,SQL长这样:
SELECT * FROM user_order WHERE user_id IN ( SELECT user_id FROM user_vip WHERE vip_level = 3 );慢日志显示执行计划走了全表扫描,user_order表上其实有user_id的索引,但子查询导致优化器可能选择了不合适的执行策略。我的排查思路是:第一步,先单独执行子查询,确认返回行数;第二步,手动改写为JOIN,测试执行计划差异。
改写为:
SELECT o.* FROM user_order o JOIN user_vip v ON o.user_id = v.user_id WHERE v.vip_level = 3;改写后,优化器能更好地判断驱动表和被驱动表,配合索引效果明显。需要提醒的是,不是所有IN子查询都必须改写为JOIN,在子查询结果集很小、外层表很大的场景下,IN的写法也可能走很好的索引。关键是要用EXPLAIN去验证,让我特别无语的是,有的人拿着优化手段当教条,不看执行计划就一律改写成JOIN,结果把正常的执行计划改坏了,性能反而更差。
4.3 案例三:ORDER BY的Filesort怎么消除
我们系统里有个查询榜单的需求,按点击量降序取Top N,原SQL:
SELECT article_id, title, click_count FROM article WHERE status = 1 ORDER BY click_count DESC LIMIT 100;执行计划里出现Using filesort。如果能在(status, click_count)上建立组合索引,因为status是等值条件、click_count又是排序列,且组合索引天然有序,MySQL可以避免额外排序。但这里有个经典的坑:如果status不是等值过滤,而是范围条件,比如status > 0,组合索引的排序能力就会被范围条件破坏,因为范围条件下相同status内无法保证按click_count有序。真正的解法是缩小过滤范围,或者明确让order by字段成为组合索引最后一列。
4.4 案例四:大表统计类的查询优化
有一次报表需求要从一个月的数据里做聚合统计,SQL写法:
SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, COUNT(*) AS cnt, SUM(total_amount) AS amount FROM orders WHERE create_time >= '2025-01-01' AND create_time < '2025-02-01' GROUP BY day;这种按时间范围做GROUP BY的查询,在create_time上建索引是基础,但真正让这个大查询变快的还能用上“时间分区表”或者“预聚合表”。
当时因为表本身已经有create_time索引,方案上是先把范围查询的扫描行数降到最低,利用索引下推把create_time过滤在引擎层完成。但如果只是临时跑报表,更简单的方式是让分区表按月分,group by就直接落在各自分区上,互不干扰。小项目不建议一上来就分区,但如果确实每月有几十万行增长,尽早设计分区是有远见的。
5. 高频问题快查与排查方法论
5.1 慢查询问题速查表
| 现象 | 可能原因 | 优先排查方向 |
|---|---|---|
| type=ALL、Rows_examined很大 | 无索引或索引失效 | 看WHERE条件字段是否有索引 |
| SQL有索引但type=range | 范围条件或隐式转换 | 检查参数类型、范围过滤占比 |
| Extra=Using filesort | ORDER BY未走索引 | 检查排序列是否在索引后段 |
| Extra=Using temporary | GROUP BY或DISTINCT内存不足 | 看临时表是否下到了磁盘 |
| 高频微慢查询 | N+1查询或CPU单点压力 | 应用侧合并查询、缓存热点数据 |
| 锁等待导致慢查询 | 事务并发冲突 | 查看INNODB_TRX和锁等待状态 |
5.2 排查方法论:先看趋势,再看单条
慢查询排查,我习惯分为三步:第一步,用mysqldumpslow或pt-query-digest做整体趋势分析,选定最耗资源的一类SQL;第二步,定位到具体SQL后,用EXPLAIN看执行计划,逐步拆解问题;第三步,在测试环境复现并对比优化前后效果,注意不要在线上直接改索引,尤其是删除旧索引,一定要先验证再执行。
很多人在第一步就卡住了,因为慢日志里的SQL太多,不知道该先处理哪条。我给个经验法则:先处理“总耗时高”的SQL,再处理“单条耗时高”的SQL。业务高峰期哪怕单条查询只要200毫秒,只要调用量足够大,对数据库的压力也远超一条偶尔出现的2秒慢查询。
5.3 监控和预防机制
优化完成之后,不要觉得就万事大吉了。数据库的查询模式会随着业务版本的迭代持续变化,可能下个月新上线一个功能,就带来了新的慢查询。建议把以下几个环节固化到日常流程里:
- 持续开启慢查询日志,最少保留30天。日志轮转要配置好,避免磁盘被打爆。
- 每天或每周自动分析一次慢日志,Top 10的SQL变化趋势做成报表,能及时发现性能波动。
- 关键表的索引变更走review流程,不要随便加也不要随便删。
- 大版本发布前,先对比慢日志基线,确认新查询语句的执行计划符合预期。
另外说一个运维上的技巧:对线上慢日志文件的命名,建议按天切割,比如mysql-slow-2025-01-15.log。方便后续按月归档、删除,也方便回溯某个活动日当天数据库的表现。
5.4 几个容易踩的坑
遇到过有人为了让一条SQL走索引,在WHERE条件里对索引字段做函数运算,比如DATE(create_time) = '2025-01-15',这会让create_time上的索引失效,因为MySQL要先把每一行的字段都应用一遍DATE函数才能进行比较,索引排序规则被破坏了。正确写法是create_time >= '2025-01-15 00:00:00' AND create_time < '2025-01-16 00:00:00'。这个坑藏得很深,因为结果可能没错,但性能差了一两个数量级。
还有一件事是关于NULL值的。字段上有索引,但NULL在B+树中如何存储,不同版本、不同引擎有细微差异。经验上,如果查询经常用IS NULL或IS NOT NULL,就不要在可空字段上指望索引能很好地工作,尽量设计成NOT NULL DEFAULT,用0或者空字符串替代,这在索引层面更友好。
再来说说索引维护。线上大表的索引,经过长期高并发写入后,B+树页分裂会造成碎片,查询性能会缓慢下降。有定期维护需求的话,可以执行ALTER TABLE table_name ENGINE=InnoDB来进行碎片整理,但注意这个操作会锁表,大表要谨慎,建议在维护窗口执行。
写在最后的一个小技巧
优化MySQL性能,我觉得最关键的能力不是会背多少命令,而是能快速判断“是什么在拖慢数据库”。慢查询日志定位到SQL,EXPLAIN拆解执行计划,索引设计解决访问路径,这套流程跑熟之后,大部分性能问题都能在半小时内定位到根因。最后分享一个我自己养成的小习惯:每次新交付的查询SQL,我都会顺手跑一次EXPLAIN,确认type不是ALL、Extra里没有Using filesort,再合入代码。这个习惯帮我拦住了非常多线上问题,成本几乎为零,收益却极大。