我至今记得那个周三下午。公司主业务的 MySQL 8.0.28 实例,一台稳稳跑了三年多的“老将”,32 核 128G 内存,两千多张表,高峰期 QPS 能打到 8000 多,平时连慢查询都很少出现。结果 14:42 开始,手机告警响成一片:应用网关大量 502,业务群里疯狂刷“数据库连不上了”。我当时第一反应是“别是被人删库了”,结果登上机器一看,CPU 才 24%,内存也没爆,这反而更让我警惕——MySQL 看着很健康,业务却已经进不去了,这通常意味着问题出在更深的地方。
这篇文章就把这次“突然倒下”的完整排查链路写出来。整个过程从发现到恢复花了两小时,但真正把根因挖干净、把防御体系补起来,花了整整两周。如果你也在维护 MySQL 主库,或者刚接手一摊没人敢动的老业务,我踩过的这些坑,你应该用得上。
1. 故障爆发:一场从“慢查询”开始的雪崩
1.1 告警现场的原始状态
先说说监控面板上的数据。当时我们用的监控体系是 Prometheus 加 mysqld_exporter,数据库层的指标其实挺全的,但 14:42 那会儿面板上最扎眼的不是 CPU,也不是内存,而是 Threads_connected 和 Threads_running 两条曲线直接拉满。Threads_connected 一直在往上窜,逼近 2000 的连接数上限,Threads_running 长时间维持在 200 以上。
这里有个经验点:Threads_connected 高并不一定可怕,连接数只是客户端和服务端的“握手”,很多连接其实是在池子里挂着睡大觉。真正致命的是 Threads_running,它代表此刻有多少线程正在真正执行 SQL。正常情况下一个 32 核的实例,Threads_running 超过 100 就已经很危险了,飙到 200 以上意味着有大量线程在同时争抢 CPU、锁和 IO 资源,整个数据库的执行队列已经全部堵死。
业务侧的日志更直接。应用连接池报的错是“无法从连接池获取连接”,超时时间从 5 秒一路调到 30 秒也没用。这说明问题不在应用,而在数据库侧——连接要么拿不到,要么拿到之后执行 SQL 也卡住,最后把连接池里所有连接全部占满,新请求就只能排队等超时。
1.2 我第一时间拉取的“犯罪现场”信息
登上数据库机器之后,我做的第一件事是执行 SHOW FULL PROCESSLIST,这是 MySQL 排查所有阻塞类问题必须做的第一步,没有之一。
sql复制SHOW FULL PROCESSLIST;
当时的结果让我心里一沉。会话列表里一眼扫过去,大部分线程的 State 都停在 Waiting for table metadata lock,还有一小部分 State 停在 Updating,Info 字段里是一条 SQL 文本几乎一模一样的 UPDATE 语句。两条信息放在一起,基本可以锁定问题方向:有长事务持有表结构元数据锁,后面所有访问同一张表的 DML、DDL 全部排队。
这里需要解释一下 metadata lock 是什么。MySQL 里任何会话对表做 DML(增删改)或 DDL(alter 等)时,都要先拿一个表级元数据锁,作用是保护表结构不被并发修改。同一个表上,多个 DML 之间可以兼容,但 DDL 和 DML 之间互相阻塞。当某个事务长时间不提交,它拿着的元数据锁就不会释放,后续其他会话不管想 select 还是 update,全部都会卡在 Waiting for table metadata lock。
当时列表里正好有一条 ALTER TABLE 在排队,这个现象特别典型:线上有人在用 Navicat 或者某个管理工具给表加字段,结果因为长事务没结束,这个 DDL 一直卡着,然后它又把后面所有读写同一张表的查询全部堵住了。看到这里,我第一个想法是“找到那个长事务,杀掉它”,但接下来查出来的东西,远比一个简单的长事务要复杂得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从系统层到数据库层的链条排查
2.1 磁盘空间:最先暴露的物理层问题
在进 MySQL 里翻长事务之前,我先在 Linux 层扫了一眼基础资源。这一步太关键了,很多 MySQL 的“假死”现象,根子其实在操作系统。
bash复制df -h
输出结果直接让我倒吸一口凉气:根分区 / 使用率 99%,可用空间只剩 2GB 左右。/var/lib/mysql 所在的目录磁盘几乎写满。
磁盘满为什么会表现为数据库“卡死”?这里捋一下链路。MySQL 事务提交时,必须先写 redo log,同时把 binlog 写入磁盘并做 sync_binlog,这是一个强制性的“落盘”动作。磁盘满了之后,binlog 写入直接失败,事务无法提交,于是一个还没提交的事务只能一直挂在那里——但它持有的锁不会释放。业务侧看到的现象就是一堆请求卡住,然后连接被占满,雪球越滚越大。
这个案例里,磁盘满和长事务是互相叠加的:有业务线程触发了一个大事务,写入时发现磁盘满了,事务提交不了,卡在了提交阶段,它占住的表和行锁全部不释放,然后又把大量其他正常请求堵在门外。
所以排查 MySQL 卡顿,一定不要只看数据库内部的指标。遇到类似问题,先敲一遍这几个命令:
bash复制df -h # 看磁盘是否写满
free -h # 看内存是否不足,会不会触发 swap 导致性能崩塌
top -c # 看 mysqld 进程 CPU 和内存占用
iostat -x 1 # 看 IO 是不是被打满
这个习惯在很多故障里都能救命。毕竟,MySQL 可以因为一句烂 SQL 被打死,也可以仅仅因为 / 分区满了而整个“装死”。
2.2 锁等待与长事务:业务侧看到的“卡死”
确认磁盘问题之后,我回到数据库层,开始追长事务和锁等待。先查当前有哪些事务在跑、谁持有锁、谁在等锁。
sql复制SELECT trx_id, trx_mysql_thread_id, trx_started, trx_query
FROM information_schema.innodb_trx\G
这条 SQL 会把当前 InnoDB 里所有未结束的事务列出来。结果里有一个事务的 trx_started 显示它已经存活了 1 小时 20 分钟,trx_query 指向一条 UPDATE 语句,没有 WHERE 条件里的有效索引——看到这里,我心里基本已经有数了。
接着查锁等待关系:
sql复制SELECT * FROM sys.innodb_lock_waits\G
输出里能看到 blocked_pid、blocking_pid、waiting_query、blocking_query 这些字段,直接告诉我们谁堵了谁。顺着 blocking_pid 找到的线程,就是那个卡了一个多小时的大事务。
这里有个容易踩坑的点:information_schema.innodb_trx 里的 trx_query 可能只显示事务当前正在执行的 SQL,但在有多个语句的事务里,真正持有锁的 SQL 可能已经执行完,只留下一个“不干活但也没提交”的空事务。所以不能只依赖 trx_query 判断,还要配合 performance_schema.data_lock_waits 看具体锁对象。
sql复制SELECT * FROM performance_schema.data_lock_waits\G
这套组合拳打下来,锁等待的“事故现场”基本就还原了:一条没走索引的大范围 UPDATE,修改了几百万行数据,每修改一行就要加一把行锁,同时因为磁盘写满,事务迟迟无法提交,锁越积越多,最终把整张表的读写全堵死。
2.3 一条 UPDATE 引发的全表扫描
接下来是最关键的一步:对那条 UPDATE 做 EXPLAIN,确认为什么它会如此致命。这里我说的是 EXPLAIN,不是 EXPLAIN ANALYZE,因为前者在 MySQL 8.0 里已经足够定位问题。
sql复制EXPLAIN SELECT * FROM order_detail WHERE status = 0 AND order_time > '2025-01-01';
当然,真实的 UPDATE 语句不能直接跑,我改成同条件的 SELECT 去分析执行计划。结果非常扎眼:type=ALL,key=NULL,rows=4200000。这意味着优化器认为需要把整张 420 万行的表从头到尾扫一遍才能找到目标数据。
如果这条 UPDATE 真的执行,InnoDB 会逐行扫描、逐行加锁,锁定范围远远超出业务预期。更麻烦的是,因为 order_time 条件里的索引没有命中,锁范围直接从几行变成了全表级别的扫描,行锁升级为大量锁记录,其他业务的 INSERT、UPDATE 全部被堵住。
这种“一条烂 SQL 打崩整个库”的场景,在 MySQL 故障里占了相当高的比例。问题往往不是 MySQL 不够稳,而是总有人能在某次上线时写出一条不走索引的 UPDATE 或 DELETE,把几百万行数据翻个底朝天。
3. 止血与恢复的真实操作
3.1 处理锁等待:定位事务、Kill 与验证
恢复的第一优先级,是把那个卡了一个多小时的长事务干掉,让锁释放出来。这里不能瞎 KILL,要先确认线程 ID。
sql复制SELECT trx_id, trx_mysql_thread_id, trx_started, trx_query
FROM information_schema.innodb_trx
WHERE trx_started < NOW() - INTERVAL 30 MINUTE;
把 trx_mysql_thread_id 拿到的线程 ID 填进 KILL 语句:
sql复制KILL 18346;
执行之后立刻再查一次 SHOW FULL PROCESSLIST,确认那条 Updating 状态和 Waiting for table metadata lock 状态的线程已经消失。锁释放后,积压的请求会像开闸放水一样涌进去,Threads_running 会在几分钟内迅速回落。
这里有一个值得注意的操作细节:KILL 之后不要立刻重启 MySQL。 很多人在数据库卡死时会下意识想重启实例“清空状态”,但在没有确认磁盘、锁、binlog 等问题之前,重启只会让情况更糟。比如磁盘满的时候,正常 shutdown 可能因为写不进 redo log 而失败,最后只能 kill -9 强杀,反而可能导致崩溃恢复流程从头跑一遍,恢复时间更长。
3.2 清理磁盘与 binlog 的取舍
锁处理完,接下来处理磁盘。当时磁盘满的原因很典型:binlog 保留策略是 30 天,而业务高峰期一天能产生 40GB 以上的 binlog,再加上 MySQL 的 error log 和慢查询日志也在同一块盘上,几个月下来整个根分区就被磨满了。
清理 binlog 的标准动作是用 PURGE BINARY LOGS,按照时间或文件名删掉早期文件:
sql复制PURGE BINARY LOGS BEFORE NOW() - INTERVAL 7 DAY;
这个命令会保留最近 7 天的 binlog,把更早的删掉,同时对 binlog index 文件做更新,是官方支持的清理方式,不会破坏复制关系。
但这里有个现实问题:如果磁盘已经 100% 满到一点空间都不剩,PURGE 也可能因为需要写 index 文件而卡住。我当时遇到的情况就是 PURGE 执行后迟迟没反应。这时候必须用应急手段:先 du -sh /var/lib/mysql/binlog.* 找出最大的几个 binlog,把确认已经从库拉取过、保留时间也够久的 binlog 用 mv 移出目录,先腾出几个 G 的空间,让 PURGE 能跑起来。
这里一定要明确:不要直接 rm binlog。 直接删除文件会让 binlog index 里记录的路径失效,主从复制可能直接断掉,重启后 MySQL 也可能因为找不到文件进入恢复错误。用 mv 到备用目录属于应急操作,后续要尽快把文件清理掉并重新规划磁盘空间。
3.3 连接池与连接数的应急调整
锁释放、磁盘清理完之后,连接数还是很高。当时 max_connections 配置的是 2000,但已经明显不够用,我临时把它调大了一档:
sql复制SET GLOBAL max_connections = 5000;
注意,这只是缓兵之计,不是彻底方案。max_connections 只是限制连接数上限,真正决定能不能抗住压力的是每条 SQL 的执行效率和锁竞争情况。连接数调大之后,如果 SQL 还是全表扫描,只会让更多线程挤在一起抢资源,情况反而更糟。
为什么当时不直接把 2000 改成 5000 这么简单?因为每个连接都要占用线程栈、buffer,连接数无脑调大,MySQL 的线程切换开销会成一个巨大的额外负担。正确做法是控制住 Threads_connected,让它稳定下来;同时关注应用侧连接池的释放逻辑,确认不会再瞬间把连接占满。
这里顺便提一个恢复后容易踩的坑。如果是 MySQL 8.0 且使用默认的 caching_sha2_password 认证插件,老版本的客户端(尤其是 Delphi 的 FireDAC、老 JDBC 驱动、旧版 Navicat)会出现类似 firedac phys mysql client does not support authentication protocol requested 的报错。这不是我们的核心故障,但在故障恢复后大量应用重连时最容易冒出来。遇到这种问题,要么升级客户端驱动,要么把对应账号的认证方式改回 mysql_native_password:
sql复制ALTER USER 'your_user'@'%' IDENTIFIED WITH mysql_native_password BY 'your_password';
不过从安全角度讲,我不建议把所有账号都改成老认证方式,最好只对确实不兼容的存量客户端做临时兼容,并推动客户端升级。
4. 事后复盘:三个藏得很深的根因
4.1 索引列上的运算:“int+5”这种写法为什么让 DBA 头大
故障恢复之后,我们开始复盘那条 UPDATE 为什么没走索引。代码组同事查了一圈,最后定位到源头是业务同事写的一个查询方法。核心 SQL 长这样:
sql复制UPDATE order_detail
SET status = 1
WHERE order_detail_id + 5 IN (
SELECT related_id FROM task_list WHERE task_no = 'T20250726'
);
看到 order_detail_id + 5 这半句的时候,我整个人是崩溃的。这是一个很经典的“在索引列上做运算”的写法,MySQL 优化器面对这种情况基本无能为力:索引 B+ 树里的键值是原始列值排序的,你让它去查找 order_detail_id + 5,它只能先把每一行的原值算一遍,再比较结果,索引完全失效。最终就是全表扫描,420 万行全部翻个底朝天。
所以说到底,int + 5 不是 MySQL 本身的问题,而是应用代码没有遵循“索引列保持独立”这条铁律。凡是 WHERE 条件里的索引列,都不要套函数、不要做加减乘除、不要用隐式类型转换,否则优化器再聪明也没辙。
同类问题还包括:WHERE DATE(create_time) = '2025-07-26' 应该改写成 create_time >= '2025-07-26 00:00:00' AND create_time < '2025-07-27 00:00:00';WHERE YEAR(create_time) = 2024 也是一样的坑。排查线上慢 SQL 的时候,看到一个索引列被函数或表达式包住,基本可以直接判定它走不了索引。
4.2 OR 拼接、连接池参数和客户端认证的连锁反应
复盘过程中还撞见另一个典型问题:消息模块的同事为了图省事,在一个查询里用 OR 拼接了多个条件,最后问了我一句“MySQL 的 OR 能去重吗”。这句话直接让我警觉起来。
先回答这个具体问题:OR 本身不会去重,它只是把两个条件的结果合并在一起。如果你想要去重效果,UNION 默认去重,UNION ALL 不去重,但 UNION ALL 性能更好,因为少了排序和去重开销。
重点是 OR 对执行计划的影响。当 WHERE a = 1 OR b = 2 里有多个不同索引时,MySQL 的优化器不一定能同时利用两个索引做索引合并,更多时候会退化成全表扫描。所以如果一段 SQL 里有多个 OR,我通常会建议改写成 UNION ALL 或拆成两条 SQL 在应用层合并,避免让优化器陷入两难。
连接池参数也在这个故障里扮演了“放大器”的角色。当时应用侧 HikariCP 配置的 maximumPoolSize 是 200,这个数偏大。连接池不是越大越好,默认推荐值是 CPU 核数 * 2 到 CPU 核数 * 4,32 核的机器也就 64 到 128 之间,200 已经偏多。连接池设置过大,数据库侧要维护的连接和线程上下文就更多,一旦出现慢 SQL,所有线程都会去抢那把锁,雪崩来得更快。
我当时把连接池最大连接数压到 50,同时把 connectionTimeout 从 30 秒降到 10 秒,让应用侧更早感知数据库异常,而不是无限等下去。
4.3 监控告警的盲区:我为什么没提前发现
复盘中最扎心的问题,是为什么这套跑了三年的“王者”实例,磁盘都满了我们才收到告警。我们的监控体系里有 CPU、内存、连接数、主从延迟,但对以下四个关键项完全没有设告警:
- 磁盘分区使用率
Threads_running高水位Waiting for table metadata lock会话数replication lag异常波动
这次故障实际上在发生前 24 小时就有征兆:binlog 一天生成 40GB,磁盘使用率从前一天的 91% 涨到 99%,如果 磁盘超过 85% 就告警,完全能抢在故障发生前处理掉。那长事务呢?那个 UPDATE 事务在磁盘写满之前就已经在跑了,如果当时有“事务运行超过 30 分钟”的告警,也能在它还没把锁扩散到全库之前就杀掉。
基于这次复盘,我把监控体系补成了下面这张表。不要等到 MySQL 真的倒下才排障,把边界条件盯住,很多故障都是可以提前 24 小时发现的。
| 监控项 | 告警阈值 | 说明 |
|---|---|---|
| 磁盘分区使用率 | 超过 80% 告警,超过 90% 紧急 | 尤其盯数据目录、binlog、日志目录所在分区 |
| Threads_running | 持续 5 分钟超过 100 | 超过 100 代表有大量并发在执行,必须定位慢 SQL |
| 长事务存活时间 | 超过 30 分钟告警 | 长事务=持锁不释放=潜在雪崩点 |
| metadata lock 等待数 | 超过 5 个告警 | 说明有 DDL 或长事务阻塞了元数据锁 |
| 主从延迟 | 持续超过 30 秒告警 | 延迟可能引发从库读到的数据过期或数据错乱 |
5. 给同样跑着 MySQL 的你的持久改进方案
5.1 SQL 治理:三条能在上线前挡住问题的规范
复盘之后,我们给研发团队定了三条硬性规范,这三条每一句都是真金白银换来的教训。
第一条,所有 UPDATE 和 DELETE 语句在上线前必须经过 EXPLAIN 审核,执行计划里出现 type=ALL(全表扫描)且 rows 超过 1 万的,直接打回。这条规则执行起来并不难,现在的 SQL 上线流程里加一个扫描动作就行,但效果立竿见影。
第二条,禁止在索引列上做任何计算、函数或类型转换。WHERE order_detail_id + 5、WHERE DATE(create_time) = ...、WHERE status+1 = 2 这类写法一律不通过,必须改写成使用原始列值的等值或范围条件。
第三条,禁止不带 WHERE 条件的全表 UPDATE/DELETE,或 WHERE 条件里只有一个恒真条件的写法。可以在 MySQL 侧给危险连接设置 SET SQL_SAFE_UPDATES=1 作为兜底,通过 init_connect 或客户端参数开启。但坦白说,这个参数只能防君子不能防小人,最靠谱的还是在中间层做 SQL 拦截,或者在数据库账号上配置审计插件,把高危 SQL 直接拦下。
这三条规范听起来简单,但每一条背后都是血淋淋的生产事故。如果当初有人在上线前多看一眼执行计划,这个周三下午也许根本不会发生。
5.2 磁盘与日志:给 binlog 留出独立空间
磁盘这块,我把 binlog 从数据目录所在的根分区挪到了独立的磁盘或挂载点上。这样即使 binlog 增长异常,也不会拖垮整个 MySQL 数据目录,处理起来也更从容。
binlog 保留策略从 30 天缩短到了 7 天,并配合每天凌晨的冷备任务。保留 30 天 binlog 的初衷是想做任意时间点的数据恢复,但实际场景里 7 天已经足够覆盖绝大多数恢复窗口,再长的历史恢复直接走备份策略,没必要让 binlog 把磁盘磨满。
慢查询日志也做了单独治理。原来慢查询日志直接写到默认目录且没有做切割,高峰期一天能写出十几个 G,现在通过 mysqld --slow-query-log-file=/var/log/mysql/slow.log 独立存放,并配合 logrotate 做日志切割,同时把 long_query_time 从 2 秒降到 1 秒,避免漏掉慢 SQL。
如果你用的是 Docker 部署 MySQL,这里还有一个更高的风险点:容器日志默认写到 /var/lib/docker/containers,如果没有给容器做日志轮转,单容器日志文件可以冲到几十上百 G,直接把宿主机的盘写满。Docker 部署的 MySQL 务必配置 json-file 日志的 max-size 和 max-file,千万别让日志文件无限增长。
5.3 监控升级:把锁等待和磁盘写满变成第一优先级告警
最后把监控体系真正补全。现在我们的 MySQL 实例上,这几个指标全部接入了告警规则:
- 磁盘使用率超过 80% 触发 warning,超过 90% 触发 critical;
Threads_running持续 5 分钟高于 100 直接告警;- 事务存活超过 30 分钟触发告警,并自动把事务信息、等待锁信息推到值班群;
Waiting for table metadata lock会话数超过 5 告警;- 主从复制延迟超过 30 秒告警。
这套监控上线之后,上个月又出现过一次 binlog 快速增长的情况,磁盘使用率爬到 85% 时我们就收到了告警,赶在业务受影响之前就把 binlog 清理干净了。这就是“提前 24 小时发现”和“事后 2 小时救火”的区别。
监控工具不一定非要上很重的体系。如果你只有一台机器,用 mysqld_exporter 加 Prometheus 加 Alertmanager 就能跑起来;如果运维团队不强,写一个简单的 shell 脚本 df -h 出来解析一下,超过阈值就发企业微信或钉钉告警,也能解决 80% 的问题。关键是先有告警,再谈告警质量。
还有一个细节,主从复制方面也要盯紧。这次主库故障恢复后,从库的 IO 线程其实一度处于中断状态,因为主库 binlog 在磁盘满期间出现了写入异常,从库拉取不到完整日志。检查命令很简单:
sql复制SHOW SLAVE STATUS\G
重点看 Slave_IO_Running 和 Slave_SQL_Running 是否都是 Yes,以及 Seconds_Behind_Master 是否在持续增长。如果 IO 线程报错,通常需要从最近的完整备份重新搭建从库,或者通过 CHANGE MASTER TO 重新指定 binlog 位点。这个操作建议在业务低峰期做,并且先确认主库 binlog 没有被清理掉。
顺便提一个和这次故障无关但容易一起踩的坑:MySQL 8.0 对 ALTER TABLE 做了很多优化,但 DDL 仍然会拿到元数据锁。如果你经常在白天做表结构变更,建议把 DDL 放在专门维护窗口,或者使用 pt-osc、gh-ost 这类在线变更工具,减少阻塞业务的时间窗口。
这次故障之后,我把那台“老将”的磁盘架构、连接池参数、监控告警规则全部重新梳理了一遍。MySQL 这类的数据库,真的不是装上就能撒手不管的,它需要有人盯磁盘、盯锁、盯慢查询、盯复制状态。每次“突然倒下”,背后都链接着无数个被忽略的预警信号。希望这篇复盘能让你在遇到类似场景时,少走一点弯路,更早一步发现问题。
