先说一个让我印象特别深的线上事故。那天晚上快十点,运营同事在群里喊:订单查询页面打不开了,一直在转圈。我打开监控一看,有点懵——那台数据库服务器是刚申请来的顶配,64核、512G内存、NVMe固态阵列,CPU占用不到10%,内存剩一半,磁盘IO几乎是一条直线。可那条SQL就是实实在在的慢查询,跑了40秒,把页面拖到超时。当时我脑子里冒出一句话:这就像开着法拉利送外卖——车是好车,马力够大,可要是路线规划一塌糊涂,红绿灯一个接一个,目的地绕上三圈,照样比电瓶车慢。
这个场景在数据库运维和后台开发里太常见了。慢查询的根子,绝大多数时候不在硬件,而在数据库执行“路线”的选择:扫描行数过多、排序没有索引、锁等待、回表频繁……把SQL执行计划理清楚,比盲目堆配置有用得多。这篇东西我就按一次完整的慢查询排查思路来写,从开启慢查询日志、看执行计划,到索引设计和分页、锁问题,最后聊一聊怎么把慢查询治理变成日常习惯。无论你是后端开发、DBA,还是刚接触数据库优化的学生,照着这个流程走,基本能解决大部分慢查询。
1. 法拉利车主的自省:慢查询到底慢在哪
1.1 加硬件解决不了的那部分问题
先别急着谈优化手段,我们得先搞明白:一台配置拉满的服务器,为什么会被一条SQL难住?
数据库执行一条查询,本质上是在做“扫描-过滤-计算”这三件事。硬件强,确实能让这三件事做得更快:CPU快了,字符串比较和哈希计算就快;内存大了,InnoDB缓冲池能缓存更多页面,磁盘读的机会就少;NVMe固态盘,能让随机读比老式机械盘快几个数量级。但问题在于,如果这条SQL本身要扫描的行数多到离谱,比如一张一千万行的表,因为没走索引,全部读一遍,那就算用上超跑级别的硬件也只是把“一千万次比较”从一秒钟缩短到零点几秒,整体还是要花很久。
更麻烦的是,很多慢查询的瓶颈根本不在CPU或磁盘上,而在数据库内部的等待机制上。比如锁等待:事务A锁住了一行数据一直不提交,事务B想更新同一行,就只能傻等。这时候你去看服务器指标,CPU不高、磁盘不忙、内存充足,一切风平浪静,但查询就是卡在那儿。这种场景,你换再贵的机器都没有意义,因为问题出在并发控制上,不是算力不够。
所以遇到慢查询,第一反应不应该是“加配置”,而是先回答三个问题:
- 这条SQL扫描了多少行数据?
- 它有没有使用索引,索引用得好不好?
- 它是不是在等什么资源?
把这三个问题答清楚了,慢查询的原因基本就浮出水面了。
1.2 一张风平浪静的监控面板是怎么骗人的
我在排查上面那个订单查询事故的时候,监控面板上所有指标都正常,这反而让我提高了警惕——它说明瓶颈不在物理资源上,而在数据库的执行逻辑里。
这类“假健康”现象有几个常见来源:
- CPU不高,因为数据库在等锁或等IO。InnoDB的线程大部分时间处于sleep状态,CPU自然是闲的。
- 内存充足,因为问题SQL扫描了大量冷数据,缓冲池命中率不高,但内存整体占用率被其他服务拉平了。
- 磁盘IO不高,因为顺序扫描的页面在InnoDB缓冲池里还没被淘汰——但这条SQL本身要读的行数依然很多。
换句话说,监控面板告诉你“车没坏”,但没告诉你“路线是错的”。
所以我后来养成了一个习惯:看监控之前,先去看这条慢SQL的执行计划。执行计划才是数据库真正的“心里话”——它告诉你数据库打算怎么执行这条语句,扫描多少行,用哪个索引,要不要排序,要不要建临时表。这些信息,比看一万遍CPU曲线都直接。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 慢查询日志:别等用户开骂了才想起开它
2.1 一条配置打开慢查询的“黑匣子”
很多团队的生产库都没有开启慢查询日志,原因五花八门:怕日志占磁盘、怕影响性能,或者干脆不知道有这功能。其实MySQL慢查询日志的开销极小,它只是在SQL执行结束后判断一下执行时间,超过阈值就记一条日志,对正常业务几乎无感。
我建议至少把下面几项配上:
ini复制[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
min_examined_row_limit = 100
逐个说下这几个参数:
slow_query_log:开关,布尔值,ON就是开启。slow_query_log_file:日志文件路径。注意MySQL进程要有写这个目录的权限,否则日志起不来。long_query_time:阈值,单位秒,支持小数。1秒意味着执行时间超过1秒的SQL会被记录。如果业务量很大,可以设成0.5或0.1;如果只是想抓最严重的几条,设成2或3也行。我自己的习惯是先设1,跑一段时间看看量,再做调整。log_queries_not_using_indexes:把没走索引的SQL也记录下来,即使它执行得很快。这个参数在生产环境要谨慎——它可能记录下海量的“全表扫描但表很小”的SQL,把日志文件撑爆。我建议先开着观察一天,如果日志量异常大,再配合min_examined_row_limit过滤掉那些本来就只扫描几行的小查询。min_examined_row_limit:只记录扫描行数超过该值的SQL。上面说了,它和log_queries_not_using_indexes配合使用,可以过滤掉“小表全扫”的噪音。
如果你不想改配置文件重启MySQL,也可以在线设置,比如:
sql复制SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL log_queries_not_using_indexes = 'ON';
注意long_query_time在线修改后,对已经建立的连接不生效,新连接才生效,所以最好还是在配置文件里改好再重启,或者改完后确认连接都重连了。
2.2 从慢日志里捞出“真凶”:mysqldumpslow的使用
日志开了之后,过几个小时再看,文件里可能已经堆了几百条记录。逐条看肯定不现实,这时候就要用MySQL自带的mysqldumpslow工具来汇总。
它的用法很简单:
bash复制mysqldumpslow -s t -t 10 /var/log/mysql/mysql-slow.log
-s t表示按查询总耗时排序,-t 10表示只输出前10条。它会把类似的SQL归并成一条,把具体数值抽象成N和S,方便你看出模式。还有一种我更常用的姿势:
bash复制mysqldumpslow -s at -t 20 /var/log/mysql/mysql-slow.log
-s at表示按平均耗时的降序排列,因为总耗时高的SQL不一定是单次执行慢,可能是被调用了上万次,每一次都只慢一点点。对于“频繁调用的小慢SQL”,平均耗时排序更直观。
如果你追求更强的分析能力,可以用Percona Toolkit里的pt-query-digest,它能按时间段统计慢SQL的分布、生成HTML报告,还能把SQL指纹聚合得更好看。不过部署它会多一个依赖,个人建议先掌握mysqldumpslow就够用了。
2.3 用慢日志最容易踩的坑
慢日志这东西,开起来容易,用起来有几个坑值得先说清楚:
第一,日志文件会无限增长。如果不做轮转,几个月之后就能把磁盘塞满。我一般用logrotate做日志轮转,甚至写个简单的crontab脚本,每天凌晨把前一天的日志重命名归档,再清空当前日志,保留30天就够了。排查问题只需要近期的记录,没必要留一年。
第二,log_queries_not_using_indexes在生产环境慎开。有一次我在一个日活很高的业务库上开了这个参数,第二天早上发现慢日志文件已经涨到十几个GB,全是各种几行十几行的小表全表扫描。这些SQL执行时间都很短,完全不影响业务,但日志量直接失控。后来加上了min_examined_row_limit = 100,情况才好转。
第三,慢日志只记录执行时间超过阈值的SQL,不记录锁等待时间很长但实际执行时间很短的SQL。MySQL的long_query_time记的是查询的执行时间,锁等待算不算在内,不同版本行为不一样。所以如果你遇到“应用卡死但慢日志里没东西”的情况,别死磕慢日志,去查锁等待和事务列表。
3. EXPLAIN执行计划:让数据库把“心里话”说出来
3.1 从执行计划看“数据库选择的路”
拿到一条慢SQL之后,第一步永远是看它的执行计划。在MySQL里,一条EXPLAIN就能做到:
sql复制EXPLAIN SELECT * FROM orders
WHERE status = 'PAID'
ORDER BY create_time DESC
LIMIT 10;
执行计划里最关键的几个字段,我整理成了一张表:
| 字段 | 含义 | 重点关注 |
|---|---|---|
| type | 访问类型 | ALL最差,const最好;range/ref都可以接受 |
| rows | 预估扫描行数 | 越大越危险,和真实行数差别过大要警惕 |
| possible_keys | 可能用到的索引 | 不用慌,只是候选 |
| key | 实际用到的索引 | NULL说明没走索引 |
| key_len | 用到的索引长度 | 越短越好,太短可能只用到了联合索引的前缀 |
| Extra | 额外信息 | Using filesort、Using temporary都值得警惕 |
其中type是很多新手的“劝退点”,我用一个生活化的类比来解释:type相当于你搜索一本书的方式。const是直接翻到确定页码,ref是查目录定位到某一章,range是从目录里锁定一页范围,index是把整本书的索引页从头翻到尾,ALL是把整本书从第一页翻到最后一页。数据库也是这么干的,ALL就是全表扫描。
3.2 一个典型慢SQL的逐行破解
拿我开头提到的那个订单查询为例,原始的慢SQL长这样:
sql复制SELECT * FROM orders
WHERE status = 'PAID'
ORDER BY create_time DESC
LIMIT 10;
EXPLAIN的结果大概是:
| id | select_type | table | type | possible_keys | key | rows | Extra |
|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | ALL | NULL | NULL | 10374620 | Using where; Using filesort |
这条计划的三个信号都在亮红灯:
type=ALL:全表扫描一千多万行。rows=10374620:预估扫描行数超过一千万。Extra=Using filesort:因为ORDER BY create_time DESC,MySQL需要对扫描出来的结果做一次额外的排序,这个操作在数据量大时会非常吃内存和CPU。
为什么没走索引?orders表上其实有status字段的索引,但status的区分度太低了,只有几个值(PAID、UNPAID、CANCELLED等),MySQL的优化器觉得走这个索引命中率太高,还不如全表扫,于是直接把索引放弃了——这个判断很多时候是对的,但配套的排序和回表代价,优化器不一定算得准。
怎么改?我的做法是给(status, create_time)建一个联合索引:
sql复制ALTER TABLE orders ADD INDEX idx_status_create_time (status, create_time);
建完再看EXPLAIN:
| id | select_type | table | type | possible_keys | key | rows | Extra |
|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | ref | idx_status_create_time | idx_status_create_time | 87150 | Using where; Using index condition |
rows从一千万降到了八万多,Using filesort也消失了。这条SQL的执行时间从40秒降到几十毫秒,提升可以说是指数级的。
3.3 EXPLAIN ANALYZE:让MySQL真的跑一遍给你看
普通的EXPLAIN是“估算”,它基于统计信息给出一个计划,但不一定准确。MySQL 8.0.18之后提供了EXPLAIN ANALYZE,它真的会执行这条SQL,并把每一步的实际耗时、实际扫描行数、每次迭代的成本都打印出来。
sql复制EXPLAIN ANALYZE
SELECT * FROM orders
WHERE status = 'PAID'
ORDER BY create_time DESC
LIMIT 10;
输出里你会看到类似actual time=0.123..12.456 rows=1000 loops=1这样的信息,这里的rows=1000是实际扫描的每一行结果,比EXPLAIN的预估准确得多。
我一般在两种场景下用EXPLAIN ANALYZE:
- 建了索引之后,想看下真实执行时间是不是如预期那样降下来了。
- 优化器的预估
rows和实际相差很大时,排查是不是统计信息过期了。
注意一点,EXPLAIN ANALYZE会真实执行SQL,对线上库来说,如果是写操作或重查询,要谨慎使用,别一条EXPLAIN ANALYZE下去又把库打挂了。
4. 索引设计:慢查询优化的主战场
4.1 联合索引的最左前缀原则:选路不迷路
索引设计是慢查询优化的重头戏,但很多人设计联合索引的时候,仅仅是把WHERE条件里的字段一股脑塞进去,结果性能并没有提升,甚至更差了。
这里必须先讲清楚最左前缀原则。联合索引(a, b, c)实际上相当于创建了三个索引:(a)、(a, b)、(a, b, c)。查询条件里只有从a开始连续匹配时,索引才会被完整利用。
我常用一张表来帮助团队理解:
| WHERE条件 | 是否能走(a,b,c)联合索引 |
原因 |
|---|---|---|
| a = ? | 能 | 从最左列开始 |
| a = ? AND b = ? | 能 | 连续匹配a和b |
| a = ? AND b = ? AND c = ? | 能 | 完整利用 |
| b = ? | 不能 | 缺少a,无法从最左列开始 |
| a = ? AND c = ? | 只能用到a | b不参与,c无法跳过b |
| a > ? AND b = ? | 只能用a | 范围查询之后的b无法用于排序和查找 |
回到上面那个例子,为什么是(status, create_time)而不是(create_time, status)?因为SQL里status = 'PAID'是等值条件,create_time是排序条件。等值条件放前面,可以让MySQL用索引精确定位到status='PAID'的记录,再利用索引天然有序的特性,直接从create_time DESC方向读取前10条,省掉一次filesort。如果你把顺序反过来,create_time在前面,那status的过滤就只能在索引里“碰运气”,效率会差很多。
4.2 隐式转换和函数操作:索引失效的重灾区
索引建得好好的,执行计划里却显示不走索引,这种情况最常见的原因有两个:隐式转换和函数操作。
先说隐式转换。比如用户表里的手机号字段是VARCHAR(20),查询时写了:
sql复制SELECT * FROM user WHERE mobile = 13800138000;
这里mobile是字符串类型,右侧却是个数字,MySQL会把左侧的字符串转换成数字再比较,导致字段上的索引失效,变成全表扫描。正确的写法是加上引号:
sql复制SELECT * FROM user WHERE mobile = '13800138000';
这类问题在代码里很难一眼发现,因为查询结果可能没问题,只是性能悄悄变差了。
再说函数操作。比如:
sql复制SELECT * FROM orders WHERE DATE(create_time) = '2024-06-01';
虽然create_time有索引,但MySQL需要对每一行的create_time都执行DATE()函数计算,索引根本没法直接匹配。推荐改成范围查询:
sql复制SELECT * FROM orders
WHERE create_time >= '2024-06-01 00:00:00'
AND create_time < '2024-06-02 00:00:00';
还有一个容易踩的坑是字符集不一致。关联查询时,如果两张表的字段一个用utf8,一个用utf8mb4,连接时会发生字符集转换,索引同样可能失效。这个不太好排查,因为EXPLAIN里不会直接告诉你字符集问题,只能看key字段是NULL但possible_keys里明明有索引,然后去检查字段定义。
4.3 覆盖索引和索引下推:让数据库少回几次表
在索引设计里,有一个很容易被忽略的点:索引字段本身也能“覆盖”查询结果。如果查询需要的所有列都在索引里,MySQL就不需要再回表访问数据行,Extra字段会显示Using index。这就是覆盖索引。
举个例子:
sql复制SELECT user_id, status, create_time
FROM orders
WHERE user_id = 12345
ORDER BY create_time DESC
LIMIT 10;
如果表上有联合索引(user_id, status, create_time),这三个字段都在索引里,查询就可以完全在索引上完成,回表次数为0,速度会非常快。
索引下推(Index Condition Pushdown,ICP)则是MySQL 5.6之后引入的优化。简单说,以前是“索引过滤完再去回表判断剩余条件”,现在是在索引遍历的过程中,就把能判断的WHERE条件一起过滤掉,减少回表次数。比如联合索引(area, age),查询WHERE area = '北京' AND age > 30,ICP会在索引层面把age > 30也过滤掉,只对真正满足条件的数据回表。
这两个概念对普通开发来说不需要记名词,但设计索引时可以多问一句:这个查询能不能只靠索引字段完成?如果能,查询效率会显著提升。
5. 分页慢与锁阻塞:两类高频慢查询的专项拆解
5.1 深分页为什么慢:LIMIT 1000000, 10背后的代价
分页查询慢是另一种常见的“法拉利送外卖”场景。看起来只是翻到第10万页,但数据库实际付出的代价远超你的想象。
sql复制SELECT * FROM orders ORDER BY id LIMIT 1000000, 10;
这条SQL的执行逻辑是:先按id排序,然后扫描前1000010行,最后把前1000000行丢掉,只返回最后10行。扫描一百万行再丢掉的成本,全加在这条查询上了,但它实际上只给你10条数据。
优化思路有几个方向,我按推荐程度排序:
第一,延迟关联。先查主键,再关联原表:
sql复制SELECT o.* FROM orders o
INNER JOIN (
SELECT id FROM orders
ORDER BY id
LIMIT 1000000, 10
) t ON o.id = t.id;
这个写法的核心是让子查询里的SELECT id只走索引,避免把一整行数据全部加载出来。
第二,游标分页。如果业务允许,用WHERE id > last_id代替LIMIT偏移量:
sql复制SELECT * FROM orders
WHERE id > 1000000
ORDER BY id
LIMIT 10;
这种方式没有“跳过”的开销,每次都是从上一次拿到的最大ID往后查,性能极其稳定。很多ToB系统和Feed流都在用这种模式。
第三,用Redis优化。这也是很多人问“分页查询慢怎么用redis优化”的原因。但我想先说清楚:Redis并不是所有分页问题的银弹。它更适合两种场景:
- 热点页缓存:比如搜索结果列表的第一页、前几页,短时间内会有大量用户访问。你可以把前N页的完整结果缓存到Redis里,设置过期时间。用户翻页时直接读缓存,数据库压力骤降。
- ID列表缓存:用Redis的Sorted Set存储分页数据的ID列表,比如按某个排序规则把ID全部推进ZSET里,分页时用
ZREVRANGE或ZRANGE取出一段ID,再去主库查出完整数据。
但注意:ZSET方案在数据量极大(几百万甚至上千万)时,内存占用很可观;数据更新频繁时,还要维护排序集合的一致性,复杂度会上升。所以我的建议是,先用SQL层面把深分页问题解决掉,实在压不住并发,再考虑Redis。
5.2 锁等待和死锁:不是查询慢,是“一直在等”
还有一种慢查询,表现是执行时间极长,但它的SQL本身并不慢——它在等待别人释放锁。这种场景最坑的是,你用EXPLAIN看执行计划,一切正常,索引也走了,但查询就是卡住不动。
常用的排查命令有两条。第一条是看当前有哪些事务在跑:
sql复制SELECT * FROM information_schema.innodb_trx\G
重点看trx_started和trx_state,如果某个事务持续了很久还没提交,它很可能就是“锁的持有者”。
第二条是直接看锁等待关系:
sql复制SELECT * FROM sys.innodb_lock_waits\G
这个视图能直接告诉你谁在等锁、谁阻塞了谁。用输出里的blocking_pid,找到那个线程,确认是合法业务后,再用KILL结束掉:
sql复制KILL 123456;
处理完之后,业务的阻塞一般立刻解除。但治标还得治本——锁等待和死锁为什么频繁发生?
最常见的元凶是长事务。比如业务代码里先开启事务,然后去调用第三方接口,等了很久才返回,事务一直不提交,这个过程中持有的锁就会阻塞其他会话。所以写好一条铁律:事务里不要做远程调用,不要做耗时操作,尽量保持事务短小。
死锁则是两个事务互相持有对方需要的锁,形成了循环等待。经典的例子是:
事务A:先更新order表,再更新user表。
事务B:先更新user表,再更新order表。
两个事务同时执行时,就可能出现死锁。MySQL检测到死锁后,会自动回滚其中一个事务,让另一个继续。你会在应用日志里看到“Deadlock found when trying to get lock; try restarting transaction”的报错。
解决死锁,最实用的办法是让所有事务按照相同的顺序加锁。上面的例子,不管什么业务,都先更新order表再更新user表,死锁的概率就会大幅降低。另外,减少大范围更新、给高频更新的表加合理的索引减少锁范围(比如只锁一行而不是锁多行),也能降低死锁概率。
6. 从救火到防火:慢查询治理的体系化经验
6.1 上线前把EXPLAIN当成代码评审的一环
慢查询最让人头疼的不是慢,而是上线后才发现慢。所以在代码评审阶段,我就要求团队同学把涉及数据库的SQL都贴上EXPLAIN结果,重点检查三件事:
type是不是ALL?rows是不是接近全表行数?- 有没有
Using filesort或Using temporary?
只要这三项里有两项异常,就必须先设计合理的索引再合并代码。等上线后再来优化,你已经把风险丢到用户头上了。
6.2 慢查询治理的长期动作
慢查询日志不只是用来“救火”的,它更是一个持续观察业务健康度的窗口。我现在基本每个月会做一次慢日志巡检,看两个趋势:
一是慢SQL的数量是上升还是下降。如果某张表相关的慢SQL突然变多,大概率是数据量涨了,或者业务加了新的查询模式,需要及时补充索引。
二是慢SQL的分布是否集中。如果发现某几条SQL长期霸榜,那问题不在SQL本身,而在业务设计。比如一个高频接口每次都查询几百条历史数据,也许需要改成按需加载,或者增加缓存。这时候,SQL优化已经解决不了根本问题,得靠架构调整。
另外,把慢查询日志的关键指标接入监控告警也很重要。不需要每条都告警,不然值班的人会被海量报警淹没。我一般只设两个阈值:
- 单条SQL执行时间超过5秒就告警;
- 每分钟慢SQL数量超过某条线就告警。
前者抓异常峰值,后者抓趋势恶化。这样既能发现突发问题,也能提前感知性能劣化。
6.3 最后分享一个小习惯
讲完这些方法,最后说一个我个人的小习惯。每次处理完一条慢查询,我都会把“优化前执行时间、优化后执行时间、慢SQL原文、根因、解决方案”记到一个文档里,分门别类整理好。这个动作看起来很原始,但坚持下来,你会发现自己对“慢查询为什么会慢”的理解会越来越系统。
踩过的坑包括隐式转换、深分页、联合索引顺序、长事务阻塞……这些内容如果你都见过一遍,以后再遇到类似的慢查询,基本扫一眼执行计划就能猜个八九不离十。这就是经验的价值——从“开着法拉利送外卖”到“开着法拉利避开拥堵路段”,差的不是车,而是对路线的熟悉程度。
