你有没有经历过这种时刻:下午三点,业务正忙,突然后台报警,APP加载转圈转得让人心焦,后台日志里全是 Connection timeout 或者 Lock wait timeout exceeded。这时候,DBA(数据库管理员)或者后端开发往往是一头冷汗。很多人第一反应是“重启试试”,或者盲目地加索引、改配置,结果往往是治标不治本,甚至把问题搞得更复杂。
其实,MySQL 卡顿从来都不是玄学,它是物理世界的反应。就像人跑步喘气一样,数据库“卡”是因为它在做繁重的工作,或者在等待某个资源。今天,我们不讲枯燥的理论,而是像侦探破案一样,通过真实的场景、具体的工具和代码,带你一步步揪出那个拖慢速度的“真凶”,并给出立竿见影的优化方案。
第一阶段:当务之急,先看清“病人”的状态
在动手改 SQL 之前,你得先知道 MySQL 现在到底在干什么。很多新手会直接去看慢查询日志(Slow Query Log),但这有个前提:慢查询日志必须已经开启,且阈值设置合理。如果线上突然崩了,日志可能还没来得及写入磁盘,或者因为 IO 瓶颈导致日志本身也写不进去。
这时候,我们需要更实时的“听诊器”。
1. 实时连接与状态监控:SHOW PROCESSLIST vs performance_schema
老派的做法是用 SHOW FULL PROCESSLIST;。这很简单,能看到当前所有正在执行的 SQL 语句、用户、主机以及持续时间。
SHOW FULL PROCESSLIST;
实战技巧:
如果你看到大量状态为 Sending data 或 Sorting result 的查询,且持续时间超过几秒,那基本就是它们了。但 SHOW PROCESSLIST 有个缺点,它只显示前 100 条(默认限制),而且信息粒度不够细。
在现代 MySQL(5.7+ / 8.0+)中,我们更推荐看 performance_schema 库,特别是 events_statements_current 表。它能提供更底层的统计信息。不过,对于紧急排查,还有一个神器:sys 库。
-- 查看当前最耗 CPU 的 SQL
SELECT * FROM sys.statements_with_runtimes_in_95th_percentile
ORDER BY exec_count DESC LIMIT 5;
2. 系统层面的“体温计”:Top 命令与 iostat
数据库卡顿,有时候根本不是 SQL 的问题,而是操作系统的 IO 或者 CPU 满了。
在 Linux 服务器上,打开终端,执行:
top -c
观察 %wa (iowait) 和 %us (user CPU)。
- 如果
%wa很高(比如超过 30%),说明磁盘 IO 成了瓶颈。MySQL 正在拼命从硬盘读数据,CPU 却在等待。这时候优化 SQL 效果有限,得考虑 SSD、调整缓冲池或者减少全表扫描。 - 如果
%us很高,说明计算量大,可能是复杂的 Join、聚合函数或者大量的行拷贝导致的。
接着,用 iostat -x 1 看看磁盘的 await 和 %util。如果 await 超过 10ms(SSD 的话应该更低),那就是磁盘在喊救命了。
第二阶段:精准定位——慢查询日志与 EXPLAIN
假设系统层面没问题,那问题一定出在 SQL 上。这时候,我们要请出排查慢查询的两大金刚:慢查询日志分析 和 EXPLAIN 计划分析。
1. 慢查询日志(Slow Query Log)的正确姿势
很多团队开启了慢查询日志,但阈值设得太高(比如 10 秒),导致很多 5 秒的烂 SQL 被漏掉了。在排查卡顿期间,建议临时调低阈值:
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 设置为1秒,捕捉更多可疑分子
SET GLOBAL log_queries_not_using_indexes = 'ON'; -- 记录未走索引的查询
拿到日志文件后,别直接用文本编辑器打开(可能会把服务器撑爆)。使用官方工具 mysqldumpslow 或者第三方工具 pt-query-digest。
推荐使用 Percona Toolkit 的 pt-query-digest:
pt-query-digest /var/log/mysql/slow.log > report.txt
这个报告非常强大,它会告诉你:
- 哪条 SQL 消耗总时间最多?
- 哪条 SQL 执行次数最多?
- 平均响应时间是多少?
- 是否走了索引?
案例重现:
假设报告显示有一条 SQL:SELECT * FROM orders WHERE status = 'pending' AND create_time > '2023-01-01' 占据了 80% 的慢查询时间。
2. EXPLAIN:SQL 的 X 光片
找到嫌疑 SQL 后,下一步就是 EXPLAIN。这是理解 MySQL 如何执行 SQL 的关键。
EXPLAIN SELECT * FROM orders WHERE status = 'pending' AND create_time > '2023-01-01';
重点看这几个字段:
- type: 这是访问类型,从好到坏依次是:
system>const>eq_ref>ref>range>index>ALL。- 如果是
ALL,恭喜你,你在做全表扫描。这就是卡顿的元凶。 - 如果是
range,说明用了索引范围查询,通常是可以接受的,但如果数据量极大,也可能慢。
- 如果是
- key: 实际使用的索引。如果是
NULL,说明没用到索引。 - rows: 预估扫描的行数。如果这里显示 100万,而你的表总共才 100 万行,那肯定是全表扫。
- Extra: 额外信息。
Using filesort: 需要额外的排序操作,通常意味着索引没用好。Using temporary: 使用了临时表,常见于GROUP BY或DISTINCT,性能杀手。Using index condition: 索引下推(ICP),这是优化好的表现。
实战优化示例:
回到上面的 orders 表。假设 EXPLAIN 结果显示 type: ALL, rows: 5000000, Extra: Using where。
问题分析:
虽然 status 和 create_time 都有索引,但 MySQL 优化器发现单独查任何一个索引过滤率都不够高,或者它选择了效率较低的索引。更糟糕的是,如果这两个字段没有组合索引,MySQL 只能选一个索引进行过滤,然后对剩下的数据进行回表或排序。
优化方案:
创建联合索引:
ALTER TABLE orders ADD INDEX idx_status_time (status, create_time);注意顺序:
status的选择性(区分度)如果比create_time低(比如只有 ‘pending’, ‘success’ 几种),而create_time范围广,那么通常将选择性低的放前面,选择性高的放后面,以便利用索引的最左前缀原则进行范围查询优化。但在某些版本中,对于范围查询,优化器行为复杂,建议先测试。通常经验法则是:等值查询字段在前,范围查询字段在后。覆盖索引(Covering Index): 如果查询只需要
id,amount,create_time,而status也在索引里,那么 MySQL 可以直接从索引树中拿到数据,无需回表查询主键索引(聚簇索引)。这能大幅减少 IO。ALTER TABLE orders ADD INDEX idx_status_time_cover (status, create_time, id, amount);然后修改 SQL:
SELECT id, amount, create_time FROM orders WHERE status = 'pending' AND create_time > '2023-01-01';再次
EXPLAIN,你会发现Extra变成了Using index,rows大幅下降,速度从 5 秒变成 0.05 秒。
第三阶段:深层陷阱——锁等待与死锁
有时候 SQL 很快,但查询就是卡住不动,报错 Lock wait timeout exceeded。这不是慢查询的问题,而是锁的问题。
1. 查找锁等待源
在 MySQL 5.7+ 中,查看 performance_schema.data_locks 和 data_lock_waits 是最准确的。
-- 查看当前的锁等待关系
SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
b.trx_query blocking_query
FROM performance_schema.data_lock_waits w
INNER JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
INNER JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;
案例重现:
你发现有一个 UPDATE orders SET status='paid' WHERE id=1001 一直卡着。查上面的视图,发现它被另一个事务阻塞了。那个阻塞的事务正在执行一条巨大的 DELETE FROM logs,或者是一条没有提交的手动事务。
2. 解决与预防
- 紧急处理:如果确定阻塞事务是僵尸进程,可以
KILL掉那个线程 ID。 - 根本原因:
- 长事务:检查应用代码,是否有在事务中进行 HTTP 请求、大量计算等非数据库操作的情况。事务持有锁的时间越长,并发冲突概率越大。
- 缺少索引导致锁升级:如果
UPDATE或DELETE没有走索引,MySQL 可能会锁住整张表(表锁)或大量行(间隙锁 Next-Key Lock)。确保 WHERE 条件中的字段有索引! - 死锁:如果两个事务互相等待,就会死锁。MySQL 会自动检测并回滚其中一个。查看
information_schema.innodb_deadlocks可以分析历史死锁原因。
第四阶段:配置与架构的终极优化
如果 SQL 优化了,锁也没问题了,但数据库还是慢,那可能是“体力”不够。这时候需要调整 MySQL 的配置参数,或者引入架构层面的手段。
1. InnoDB Buffer Pool:内存的黄金
innodb_buffer_pool_size 是 MySQL 最重要的参数。它决定了有多少内存可以用来缓存数据和索引。
- 建议:设置为物理内存的 50%-70%。
- 检查:
如果SHOW STATUS LIKE 'Innodb_buffer_pool_pages_total'; SHOW STATUS LIKE 'Innodb_buffer_pool_pages_free';Pages_free经常很高,说明内存没吃满,可以考虑调大。如果Buffer Pool Hit Ratio(命中率)低于 99%,说明频繁发生磁盘 IO,必须增加 Buffer Pool 或优化查询以减少随机 IO。
2. 连接数与超时设置
max_connections: 不要设得太大。每个连接都占用内存和 CPU 上下文切换开销。一般 500-1000 足够应对大多数场景。配合连接池(如 HikariCP, Druid)使用。wait_timeout和interactive_timeout: 设置较短的空闲连接超时时间,自动清理僵尸连接,释放资源。
3. 读写分离与分库分表
如果单台 MySQL 已经触顶,就需要架构演进。
- 读写分离:主库负责写,从库负责读。使用中间件(如 ShardingSphere, MyCat)或云厂商提供的代理。
- 注意:主从延迟可能导致读取不到最新数据,需根据业务容忍度选择策略。
- 分库分表:当单表数据超过 500万-1000万 行时,索引效率下降,IO 压力增大。
- 按用户 ID 哈希分表,或者按时间范围分表。
- 这需要应用层改造,复杂度较高,需谨慎评估。
第五阶段:给小朋友也能听懂的总结(核心心法)
为了让你更深刻地理解,我们把数据库想象成一个超级图书馆的管理员:
- 慢查询(Slow Query):就像有人借书,管理员要在仓库里跑断腿去找。
- 优化:给他一张精准的目录卡片(索引),告诉他书在第几排第几层,他就能秒拿。
- 全表扫描(Full Table Scan):管理员不看目录,从第一本书开始一本本翻。
- 优化:建索引,或者把常用的书放到门口的小架子(Buffer Pool/内存)上。
- 锁等待(Lock Wait):管理员正在整理 A 书架,你非要让他现在把 B 书架的书给你,但他手里拿着钥匙打不开 B 书架的门(因为 A 书架的锁没释放,或者发生了死锁)。
- 优化:加快整理速度(缩短事务),或者确保每本书都有独立的锁(走索引,避免锁全表)。
- 硬件瓶颈(IO/CPU):管理员腿脚不好(CPU 弱),或者仓库路太滑(磁盘 IO 慢)。
- 优化:给他换双跑鞋(SSD),或者给他配个助手(读写分离)。
结语:监控是常态,优化是艺术
数据库优化不是一次性的工作,而是一个持续的过程。
- 建立监控体系:使用 Prometheus + Grafana 监控 QPS、TPS、连接数、慢查询数、锁等待数等关键指标。
- 定期复盘:每周看看慢查询日志,有没有新的“钉子户”出现。
- 代码审查:在 Code Review 环节,强制要求所有涉及多表关联、大数据量查询的代码必须附带
EXPLAIN结果。
当你下次再遇到 MySQL 卡顿,不要慌。按照系统资源 -> 实时状态 -> 慢查询日志 -> EXPLAIN 分析 -> 锁机制 -> 配置架构这套流程走一遍,你不仅能解决问题,还能成为团队里那个“定海神针”般的专家。记住,数据不会撒谎,它只是静静地在那里,等待着懂它的人去倾听它的呼吸。
