1. 定位性能问题的整体思路和前提
1.1 先搞清楚“慢”到底是什么
很多同学一听到MySQL性能优化,第一反应就是“加索引”“改配置”“上缓存”,这些确实是后端手段,但问题是:你连问题在哪都不知道,改配置改哪里?加索引加哪一列?缓存什么数据?方向错了,越优化越乱。
我见过太多人拿着一条线上慢SQL截图来问“这个怎么优化”,结果一问三不知——这条SQL跑了多久?正常情况下应该是多久?是不是只有某个时间段慢?有没有并发影响?连“慢”的标准都没定义清楚,就开始动手改,这属于本末倒置。
性能定位的第一件事,是给“慢”下一个可量化的定义。比如:这个接口正常应该200ms返回,现在变成了2s,这叫慢;某条报表SQL晚上跑30秒,业务上能接受,那就谈不上“性能问题”,顶多是“可以更好”。慢查询日志里默认是10秒,但如果你所有SQL都是1秒,10秒的阈值对你没有任何意义——它根本不会记录。所以我们的第一步,就是先定阈值、开日志、看全局。
1.2 定位流程:从全局到局部
我个人的排查习惯,是遵循一条链路:整体负载 → 慢SQL → 单条SQL执行计划 → 索引与锁 → 应用层逻辑。这个顺序不能乱。
先看整体负载,是因为MySQL慢很多时候不是SQL本身的问题,而是服务器CPU打满、磁盘IO瓶颈、内存不足导致Swap,甚至网络带宽被占满。这时候不能一上来就盯着SQL不放,得先看系统层面,确认MySQL所在的主机资源是否健康。
确认了MySQL本身没有资源瓶颈之后,再去捞慢查询日志。这一步的目标是从一堆SQL里,找出“真正需要优化的那一批”。慢查询日志里记录的是“执行时间超过阈值的所有SQL”,但它们的性质不一样:有的是偶发性的(某一次遇到锁等待),有的是持续性的(每次都很慢),有的是有规律的(每个月月底跑报表时慢)。我们需要区分对待。
最后才是执行计划分析。执行计划告诉你的是MySQL打算怎么执行这条SQL——是走全表扫描还是索引查找?预估要扫多少行?有没有文件排序?有没有临时表?这些信息才是我们判断“为什么慢”的核心依据。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 慢查询日志:性能问题的第一现场
2.1 开启慢查询日志的配置与确认
慢查询日志是MySQL自带的功能,但我发现很多生产库实际并没有开启,或者阈值设得毫无意义。先看一组常见的配置项:
ini复制slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 2
log_queries_not_using_indexes = 1
min_examined_row_limit = 100
slow_query_log:总开关,1为开启。slow_query_log_file:日志文件路径。注意MySQL运行账号需要对该目录有写权限,否则开启失败。long_query_time:阈值,单位秒。线上我一般建议先设2秒,跑一周看看情况再调。如果业务压力大、SQL普遍快,可以改成1秒;反之如果业务本身就是重查询,可以先设5秒,避免日志文件爆炸。log_queries_not_using_indexes:记录所有没走索引的SQL。这个选项虽然好用,但会记录大量“本来就不需要索引”的查询(比如查一行固定的配置数据),日志量会比较大,建议配合下面的min_examined_row_limit一起用。min_examined_row_limit:只记录扫描行数超过该值的SQL。加上这个条件,能过滤掉“没走索引但只扫了十几行”的无意义记录,让日志更有参考价值。
这些参数可以写进my.cnf,也可以在线修改:
sql复制SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 2;
SET GLOBAL log_queries_not_using_indexes = 'ON';
SET GLOBAL min_examined_row_limit = 100;
注意:long_query_time在线修改后,对新建立的连接立即生效,已存在的连接要重连才生效。另外,long_query_time的单位是秒,如果业务对延迟极其敏感,可以写成小数,比如0.5表示500ms。
2.2 慢查询日志内容怎么读
日志开启后,内容长这样:
code复制# Time: 2025-01-15T14:23:11.882345Z
# User@Host: report_user[report_user] @ [192.168.1.50]
# Query_time: 12.345678 Lock_time: 0.001234 Rows_sent: 1000 Rows_examined: 2000000
SET timestamp=1736936591;
SELECT * FROM order_detail WHERE status = 0 ORDER BY create_time DESC LIMIT 1000;
这段日志信息量很大,我逐个拆解:
Query_time是执行总耗时,包括CPU执行、磁盘IO、锁等待,是我们最需要关注的数字。Lock_time是锁等待消耗的时间。如果这个值占总耗时比例很高,说明SQL本身执行可能不慢,是被其他事务堵住了,这时候要去查锁竞争而不是索引。Rows_sent是最终返回的行数。Rows_examined是MySQL为了产生结果集实际扫描的行数。SET timestamp=...是这条SQL的实际执行时间点(Unix时间戳),方便你对照业务高峰期。
判断一条慢SQL是否值得优化,我有个经验公式:Rows_examined ÷ Rows_sent,这个比值越大,说明“为了返回这么点数据,翻了大量记录”,优化空间就越大。比如上面这个例子,比值是2000:1,这说明扫描了200万行只返回1000行,典型的全表扫描或者索引选择不当。
另一个小技巧:查看日志里Query_time的分布。如果一条SQL每次都是10秒,那是稳定的性能问题;如果偶尔才出现一次10秒,平时都在100ms以内,那大概率是偶发性的资源争用或锁等待,需要对症处理。
2.3 日志分析的两个实用工具
慢日志文件会越来越大,不可能每天用cat去翻。推荐两个工具。
第一个是mysqldumpslow,MySQL自带的,用来做简单的统计汇总:
bash复制mysqldumpslow -s t -t 10 /var/log/mysql/mysql-slow.log
这条命令按平均查询时间排序,列出最慢的10条SQL(同类SQL会做聚合,比如只差在条件的数字/字符串不同的会被归为一类)。它的输出会把具体的值替换成N或S,这样可以快速看出“哪些SQL模板”是稳定慢的。
第二个是pt-query-digest,Percona Toolkit里的工具,功能更强大。它会把慢日志按照“查询模式”聚合,并给出每个模式的执行次数、平均耗时、总耗时占比、响应时间分布等维度:
bash复制pt-query-digest /var/log/mysql/mysql-slow.log
输出里Profile部分的Response time占比非常关键——占比最高的查询模式就是优先级最高的优化对象。注意:虽然Percona Toolkit现在很多功能要付费,但pt-query-digest作为开源GPL部分仍然可以正常使用。
3. 执行计划:看懂MySQL的“路线选择”
3.1 EXPLAIN怎么用
慢查询日志告诉你“哪条SQL慢”,但没告诉你“为什么慢”。这时候就需要看执行计划。
MySQL里最常用的分析工具就是EXPLAIN,用法很简单:在SQL前面加EXPLAIN关键字即可,MySQL不会真的执行这条SQL,只是生成一份查询计划。
sql复制EXPLAIN SELECT * FROM order_detail WHERE order_no = 'DS20250115001';
如果是UPDATE或者DELETE,也要先看对应的SELECT版本执行计划——更新和删除本质上也是先查出来再改,所以把WHERE条件拿出来做SELECT分析即可。
MySQL 8.0还有一个带实际执行的版本:EXPLAIN ANALYZE。它会把SQL真正跑一遍,并输出每一步的实际耗时和实际行数,比EXPLAIN的估算值准得多。我在8.0环境做优化时,基本都会用这个工具验证方案。
sql复制EXPLAIN ANALYZE SELECT * FROM order_detail WHERE order_no = 'DS20250115001';
3.2 关键列逐个拆解
EXPLAIN的返回结果有很多列,我挑几个最核心的讲,不搞一堆用不上的概念。
type列:这是访问类型,直接决定了查询效率。从好到差大致是:
| type | 含义 | 评价 |
|---|---|---|
| system | 表只有一行 | 极罕见,可忽略 |
| const | 主键或唯一索引等值查询 | 非常快 |
| eq_ref | 使用唯一索引进行关联查询 | 非常快 |
| ref | 非唯一索引等值查询 | 较快 |
| range | 索引范围扫描 | 尚可 |
| index | 遍历索引树 | 一般,有优化空间 |
| ALL | 全表扫描 | 最差,重点优化对象 |
key列:实际使用的索引名。如果是NULL,说明没有用索引,要重点排查。
rows列:MySQL估算的需要扫描的行数,这个不是精确值,但数量级很有参考意义。如果rows是几十万、上百万,即使type是range或ref,也要考虑是不是索引区分度不够。
Extra列:重要的附加信息,这里列几个常见的坑:
Using filesort:表示需要文件排序,常见于ORDER BY没有命中索引。行数多的时候非常慢。Using temporary:用了临时表,常见于GROUP BY、DISTINCT、子查询等操作。Using index:表示覆盖索引,查询的字段都能在索引里找到,不需要回表,这是最优情况。Using where:表示在存储引擎层拿到记录后又进行了条件过滤。Using index condition:索引条件下推,是5.6以后的优化手段,比普通回表快。
id和select_type:用于区分多表连接或子查询的执行顺序。id越大越先执行;id相同的按从上到下的顺序执行。
3.3 一个完整SQL的执行计划分析实战
举个我最近处理过的例子,线上有一个订单查询接口,用户端需要按手机号查最近一年的订单列表:
sql复制SELECT id, order_no, amount, status, create_time
FROM orders
WHERE phone = '13800138000'
ORDER BY create_time DESC
LIMIT 20;
线上反馈这个接口偶发超过3秒。看执行计划:
sql复制EXPLAIN SELECT id, order_no, amount, status, create_time
FROM orders
WHERE phone = '13800138000'
ORDER BY create_time DESC
LIMIT 20;
结果:
code复制id | select_type | table | type | key | rows | Extra
1 | SIMPLE | orders | ref | idx_phone | 45823 | Using index condition; Using filesort
type是ref,走了idx_phone索引,按理说不差。但rows是45823,意味着phone='13800138000'这个条件命中了4.5万行记录,然后要做ORDER BY create_time DESC排序,于是触发了Using filesort。当这个手机号下的订单量越来越大,排序代价就越来越高,最终造成偶发超时。
优化方案很直接:建一个联合索引(phone, create_time)。
sql复制ALTER TABLE orders ADD INDEX idx_phone_create_time (phone, create_time DESC);
再跑一次执行计划:
code复制id | select_type | table | type | key | rows | Extra
1 | SIMPLE | orders | ref | idx_phone_create_time | 20 | Using index condition
rows从45823降到了20,Using filesort消失了,因为B+树索引本身就是按phone分组、按create_time排序存储的,SQL正好命中了这个顺序,排序直接被索引替代了。这个Case就是典型的“能用索引解决排序就不要让MySQL自己排”。
4. 常见问题与排查技巧实录
4.1 慢查询的常见类型与应对策略
我处理过的线上慢SQL,基本可以归成下面几类:
第一类:全表扫描型。执行计划type是ALL,rows很大。原因通常是WHERE条件里的列没有索引,或者函数/类型转换导致索引失效。应对办法就是加索引。
第二类:索引存在但没走。这是最容易让人疑惑的。比如WHERE name LIKE '%小明%'这种前置模糊匹配,即使name有索引也用不上;再比如对索引列做了DATE(create_time) = '2025-01-01'这种函数操作,索引直接失效。这一点我在后面单独讲。
第三类:深分页问题。页面需要翻到第1000页,LIMIT 10000, 20意味着MySQL要扫出前10000行再丢掉,效率极低。我处理分页慢的思路一般是两个方向:业务上限制最大翻页深度,配合游标分页(基于上次查询的最后一条记录ID)替代偏移分页;或者改造成基于索引的延迟关联,先查出主键,再关联原表取出所需字段。
第四类:数据量大,索引帮助有限。即使走了索引,但过滤条件区分度太差,比如WHERE status = 1,如果status字段只有两三个值,索引区分度低,优化器可能直接放弃索引。这类问题需要业务层面配合——比如拆表、汇总表、或者接上Redis/ES分担查询压力。
4.2 索引失效的典型场景
给列建了索引,查询还是全表扫描,这是新手最容易踩的坑。根据我的经验,索引失效的常见场景有这些:
- 对列使用了函数:
WHERE DATE(create_time) = '2025-01-01',可以改成范围条件create_time >= '2025-01-01' AND create_time < '2025-01-02'。 - 隐式类型转换:
WHERE phone = 13800138000,字符串类型的列和数字比较,MySQL会先把列转成数字再比较,索引失效。写法要保持数据类型一致。 - 前导模糊匹配:
WHERE name LIKE '%张%',如果要优化,可以看看业务上能不能改成name LIKE '张%'。 - 联合索引不满足最左前缀:比如联合索引
(a, b, c),查询条件是WHERE b = 1 AND c = 2,因为跳过了a列,索引用不上。 - 范围查询右侧的列:
WHERE a > 100 AND b = 5,命中联合索引的(a, b)时,b列会因为a的范围条件而无法走索引。 - 用
OR连接多个条件,且其中一列没有索引:WHERE phone = '13800138000' OR status = 2,需要所有条件列都有索引才能走索引合并,否则全表扫描。
判断索引是否真的失效,不要靠猜,直接看执行计划的key列是不是NULL,或者用EXPLAIN ANALYZE看实际扫描行数。
4.3 死锁和语句阻塞的定位思路
慢查询日志里如果经常出现Lock_time很高的记录,或者DBA告诉你“这个库有死锁”,那就要把定位思路从“慢SQL”切换成“锁竞争”。
死锁的定位,首先要能拿到死锁现场。MySQL提供了SHOW ENGINE INNODB STATUS命令,输出里LATEST DETECTED DEADLOCK部分就是最近一次死锁的详情,包含持有锁的事务、等待锁的事务、死锁产生时执行的SQL语句。
至于阻塞,核心是看当前事务和锁状态:
sql复制SELECT * FROM information_schema.INNODB_TRX;
SELECT * FROM information_schema.INNODB_LOCKS;
SELECT * FROM information_schema.INNODB_LOCK_WAITS;
INNODB_TRX表里能查到当前正在执行的事务、启动时间、执行SQL。如果有一个事务跑了很久,trx_query显示一个UPDATE语句,而它没有提交,那么所有访问同一行数据的事务都会被它阻塞。这种问题根本不是SQL层面的,是业务逻辑里的事务没有及时提交或者锁范围过大。
这类问题我的经验是:锁等待和死锁,多数不是单条SQL的问题,而是多条SQL之间的事务设计问题。排查时优先看事务的开启时间和持锁情况,而不是在慢SQL上优化半天。另外,排查阻塞时还可以看performance_schema.events_statements_current表,查当前正在执行的SQL语句,以及对应的thread_id,再结合sys.innodb_lock_waits视图,能更快精确定位谁在等谁。
4.4 我的几条实操心得
最后分享几个我在实际排查中养成的习惯,希望能帮大家少走弯路。
第一,慢查询日志要持续开着,但阈值要动态调整。 很多人只在排查问题时开一下,排查完就关掉,导致下次问题出现时没留现场。我的做法是在测试环境长期开着,生产环境也开着,但日志轮转要做好(比如用logrotate),避免日志把磁盘打满。阈值可以从2秒开始,运行一段时间再根据实际情况调。
第二,尽量不要直接在生产库跑EXPLAIN ANALYZE。 虽然是EXPLAIN ANALYZE只执行一次,但被分析的SQL如果本身是重查询,真的执行一遍也会产生压力。我一般先在测试库复现,或者在从库上执行分析。如果必须在生产环境看执行计划,用不带ANALYZE的EXPLAIN就够了,它不会真正执行SQL,几乎没有开销。
第三,优化完一定要对比验证。 改索引也好,改SQL也好,同一个环境,同样的数据,优化前后执行计划和实际耗时都要对比。之前有个同事把慢SQL从3秒优化到了0.1秒,大家都很高兴,结果上线后发现流量高峰时出现了“索引维护开销过大”的副作用——因为加的索引字段更新太频繁,反而影响了写入性能。所以加索引不只有好处,写放大和存储成本也要一起评估,尤其是大表加索引,最好在业务低峰期操作。
第四,分页查询慢优先考虑游标分页。 网上讨论“分页查询慢怎么优化”时,很多人会建议加Redis缓存,但我个人觉得,Redis适合缓存“变化频率低、并发高”的数据,如果分页查询的数据是实时变化的,缓存命中率很低,反而白白占用内存。更稳妥的做法是游标分页:比如把LIMIT 10000, 20改成WHERE id > 10000 LIMIT 20,前提是排序字段稳定,而且业务上接受“没有跳页功能”。如果产品必须要跳页,那就得靠索引覆盖+延迟关联来扛。
第五,SQL优化方案的验证,要基于真实的查询模式。 我见过不少刚入行的工程师,看到一条查询慢就建议把SELECT的字段都加到索引里做覆盖索引,结果索引变得又宽又大,写入性能明显下降。正确的思路是统计这个表的主要查询模式,找出高频、关键路径上的查询,针对它们设计索引,而不是面面俱到。
MySQL性能优化要学的东西还有很多,但慢查询定位和执行计划分析是必须熟练掌握的基础能力。先把这两个工具练好,遇到性能问题能快速判断出“是全表扫描、是索引失效、是锁等待还是数据量太大”,后续的优化手段才能有的放矢。我自己的习惯是每次处理完一个性能问题,都会把定位过程、判断依据、优化方案和效果记录下来,形成一个小型的问题案例库,下次遇到类似问题就能直接对照着排查,效率会高很多。
