凌晨 2 点 14 分,某电商平台的监控大屏弹出一条告警:数据库连接数飙到上限,业务超时率直线上升。值班同事的第一反应是重启数据库,被我拦住了——这个动作在多数场景下不但救不了火,反而会把问题拖进更深的泥潭。搞 MySQL 运维这些年,我最大的体会是:排查问题的思路比问题本身更值钱。这篇文章不打算泛泛罗列一堆命令,而是把我在真实生产环境里反复踩过的坑、验证过的方案整理出来,按“症状 → 定位 → 根因 → 处置”的顺序拆解,覆盖连接打满、慢查询、主从延迟、磁盘误判这几类最常见的线上故障,每一节都会给出可以直接抄作业的操作步骤和参数建议。无论你是刚接手数据库的运维新人,还是被线上问题追着跑的后端开发,希望能少走几家弯路。
1. 连接数被打满的那一刻:从临时止血到根因定位
1.1 被连接数淹没时的第一反应,千万别是重启
连接数打满的典型表现是业务侧大量报错:Too many connections,应用日志里清一色的连接超时,监控图上 Threads_connected 拉成一条平线。很多人的第一反应是修改max_connections把它调大,或者直接重启数据库。这两个操作我都不建议优先做。
调大max_connections的确是立竿见影的止血手段,但隐藏风险不小。每个连接在 InnoDB 里都要占独立的线程栈和内存结构,连接数从默认的 151 调到 1000,瞬间可能多吃掉几个 GB 的内存。如果机器本身内存并不宽裕,你前脚调完,后脚就可能触发 OOM,把整个实例搭进去。至于重启,代价更直观:缓存全丢,连接全断,恢复时间取决于数据量和 redo log 的回放进度,生产环境里几十分钟起跳,这是最重的操作,必须放在最后。
我的建议顺序是四步走。第一步,确认当前连接数的构成,SHOW STATUS LIKE 'Threads_connected';拿到总量,SHOW PROCESSLIST;看每个会话在干什么。第二步,如果是连接被慢查询或锁等待占住,优先杀掉问题会话而不是重启实例。第三步,确实需要扩容连接数时,评估好内存余量再改参数,并且要动态调整,SET GLOBAL max_connections = 500;观察一段时间再写进配置文件。第四步,也是最容易被忽略的,去查连接池那一侧——很多“数据库连接打满”其实是应用连接池泄露,不是数据库的问题。
1.2 从 SHOW PROCESSLIST 里读出问题会话的类型
SHOW PROCESSLIST是定位连接问题的第一现场。我习惯重点看三列:Command、Time和State,它们组合在一起能快速判断会话在干什么。
第一类高发场景是大量Command为Sleep的会话堆积。每个 Sleep 会话代表一个空闲连接,正常情况下几十个以内是合理的,但如果堆到几百上千,几乎可以断定是应用侧连接池泄漏——要么连接没归还,要么创建连接后忘了关闭。遇到这种情况,数据库侧调整参数只是缓兵之计,真正要查的是应用代码和连接池配置,比如连接池的maxActive是不是被某个线程无脑创建、testOnReturn是否开启。某次我排查一个金融客户的故障,500 个连接里 480 个是 Sleep,最后定位到是框架的initialSize写成了 100,而实际并发只有 20,纯属把连接池当成线程池在玩。
第二类是Command为Query但State长时间卡在Copying to tmp table on disk或Sorting result。这类会话通常在执行大范围排序或分组查询,把临时表写到了磁盘上,速度和断崖一样往下掉。处理方案是先用KILL <id>打断最耗时的那几个会话,再去优化对应 SQL。我一直强调,SHOW PROCESSLIST里的 Time 越大不代表越危险,关键是看 State 是否在真正推进,卡在某个状态纹丝不动的才是最要命的。
1.3 连接数暴涨的四个常见根因与对策
结合几次典型事故,我把连接数暴涨的根因归纳成四类,每一类对应不同的处置重点。
慢查询堆积是最常见的诱因。SQL 没走索引,单条查询跑几十秒,并发一高就把连接池占满。这类问题的特征比较明显:SHOW PROCESSLIST里能看到多条长时间Query的会话,且 SQL 相似度很高。处置时先抓出慢 SQL 清单,再针对性优化,相关排查方法我会在下一节展开。
连接池参数配置不合理,包括初始连接数过大、最大连接数设置超过数据库上限、空闲回收时间过长。这类问题通常在流量高峰或应用发布后集中爆发,特征就是上面说的海量 Sleep 会话。处置方式是在应用配置里调小初始连接数、缩短空闲时间,并开启连接的回收校验。
突发流量或爬虫攻击。这类情况数据库本身可能没毛病,就是瞬间请求量超过了负载能力。特征是SHOW PROCESSLIST里大部分是正常查询,但连接数仍在持续上涨。处理方案是先在网关或接入层做限流,把连接数压到安全水位,再评估是否需要扩容。
锁等待导致的会话阻塞。比如一个事务长时间不提交,持有行锁不放,后续会话全部卡在Waiting for lock。处置的核心是找到锁的持有者,information_schema.innodb_trx、sys.innodb_lock_waits两张表配合使用能很快揪出元凶事务。
我第一次在夜里的告警群里抢修时,全程手忙脚乱,后来见多了才明白:连接数是数据库的“表盘”,真正的问题往往在别处。别被表象牵着走,先看清楚连接在干什么,再做下一步决定。
2. 慢查询不是索引的错:三个让我栽过跟头的真实 Case
2.1 隐式类型转换:一个字符集差异引发的惨案
大多数人对慢查询的第一反应是“加索引”,但现实中相当一部分慢查询的根因不是索引缺失,而是 SQL 写法让索引根本用不上。第一个我印象极其深刻的案例是隐式类型转换。
某业务系统上线半年后,订单表查询突然变慢,单条查询从几十毫秒涨到三秒多。表上明明已经建了索引,EXPLAIN 一看却是全表扫描。反复检查后才发现,订单号字段在表里是varchar(36),SQL 里传进来的却是一个字符串类型的变量,按理说类型匹配没问题——问题出在字段的字符集是utf8mb4,而连接字符集是latin1。这种情况下,MySQL 为了比较会先对字段做字符集转换,索引直接失效。
这个坑的隐蔽之处在于,它不是每次查询都慢,而是取决于应用侧连接配置和数据内容。处置方式也不复杂:统一连接字符集为utf8mb4,或者改写 SQL 让字段和条件保持同一字符集。我在那之后养成一个习惯,写查询时强制检查字段定义和参数类型是否完全一致,包括字符集、排序规则,数字和字符串之间尤其要小心。判断方法很简单,对可疑查询执行EXPLAIN,如果key列是NULL而type是ALL,优先怀疑类型或字符集问题。
2.2 深分页与大偏移量:为什么越往后翻页越慢
另一个高频案例是深分页查询。业务需求很常见:前端表格分页展示,用户翻到第 50000 条。SQL 写法通常是LIMIT 50000, 20,这行 SQL 的扫描成本不是 20 条,而是前 50000 条全部扫一遍再丢弃。数据量小时没什么感觉,主键流水号到了千万级之后,查询耗时随页数线性飙升。
这类问题的优化思路有几个方向。第一种是“延迟关联”,即先走覆盖索引查出目标主键,再回表取完整数据。写法类似这样:
SELECT * FROM orders WHERE id >= ( SELECT id FROM orders ORDER BY id LIMIT 50000, 1 ) LIMIT 20;第二种是“基于游标分页”,也就是前端不再传页数,而是传上一次拿到的最后一条记录的主键值:
SELECT * FROM orders WHERE id > 上次的最大id ORDER BY id LIMIT 20;第三种思路是把排序字段做成组合索引的引导列,理论上可以让排序操作走索引而非文件排序。实际生产里,我优先推荐第二种,改动相对小,而且用户翻页体验通常是“下一页”,不一定要精确跳页。
2.3 优化器不走索引:统计信息失真怎么办
第三种情况气人但也常见:索引明明建得好好的,EXPLAIN 里优化器就是不走,偏要选一个全表扫描,或者选了一个明显不是最优的索引。这背后通常是索引统计信息失真,或者优化器基于成本模型做出的“错误估算”。
某天下午,某后台管理系统的列表页突然卡死,SHOW PROCESSLIST看到一条SELECT长时间跑在Sending data。表上明明有包含所有查询条件的组合索引,EXPLAIN 显示possible_keys有索引,但key是空的。原因在于这张表频繁做批量插入删除,索引统计信息已经严重滞后于实际数据分布。
处置方式依次尝试三个层级。第一个层级是重新收集统计信息:ANALYZE TABLE t;,这一招能解决相当一部分统计失真问题。第二个层级是改写 SQL,用FORCE INDEX强制指定索引。第三个层级是调整优化器的相关参数,比如optimizer_switch里的index_merge、mrr_cost_based,但这些参数全局影响面大,线上调整要非常谨慎。
排查慢查询的通用路径我整理成一张表,方便对照操作:
| 现象 | 优先怀疑 | 快速验证方法 | 常用处置 |
|---|---|---|---|
| 全表扫描,字段有索引 | 隐式类型转换 / 函数处理 | EXPLAIN 看 type 和 key | 修正类型匹配、统一字符集 |
| 翻页越往后越慢 | 深分页大偏移 | 检查 LIMIT 偏移量 | 游标分页、延迟关联 |
| 有索引但不走 | 统计信息失真 | ANALYZE TABLE 后重试 | 更新统计信息、FORCE INDEX |
| 单表数据量巨大 | 索引选择性差 | 查看 cardinality | 优化索引结构、分区 |
3. 主从延迟的隐蔽诱因:别只顾着看 Seconds_Behind_Source
3.1 大事务是延迟的头号元凶,而且最冤
很多运维同行看主从延迟,只会盯着SHOW REPLICA STATUS里的Seconds_Behind_Source这一列。我承认这个指标简单直观,但长年用它你就会发现:它等于 0 不代表没有延迟,不等于 0 也不代表一定出问题。更要注意的是,延迟的根因往往藏在你想不到的地方。
最常见的延迟元凶是大事务。想象一下某个业务清数据,DBA 直接跑了一条DELETE FROM audit_log WHERE create_time < '2024-01-01';,这一下删掉了 2000 万行。事务在主库执行,InnoDB 要记录全部变更到 binlog,事务提交时生成一个巨大的 binlog 事件。从库拿到这个事件后必须单线程回放——不管主库有多高的并发,一个大事务就能让从库卡住十几分钟甚至更久。整个过程中,Seconds_Behind_Source一路飙高,业务侧读从库出现明显的延迟数据。
处置思路分两方面。事前预防:大表清理不要用一条超大范围 DML 一次做完,改成按主键范围分批次 DELETE,每批几百到几千行,批次之间稍作停歇;如果是要归档数据,优先考虑复制到归档表后用分区表 TRUNCATE 分区。事后止血:如果大事务已经在跑了,能等就等,强行 kill 会有回滚开销,不一定会更快。
3.2 从库复制并行度不够,才是延迟的一种“慢性病”
除了大事务这种“急性发作”,还有一种慢性病式的延迟:主库并发写入很高,从库却始终追不上。很多人第一反应是网络带宽、磁盘 IO 不够,但看过监控后会发现 IO 根本没用满,问题出在复制并行度配置。
MySQL 5.7 之后引入了基于组提交的并行复制(MTS),核心参数是slave_parallel_workers/replica_parallel_workers。默认情况下它是 0,等于关闭并行,所有事务都在从库单线程回放。生产环境里如果主库是 8 核以上的机器,写入并发稳定在几百 TPS,建议把它调到 4 到 16 之间。另一个关键参数是binlog_transaction_dependency_tracking,它决定了从库判断事务能否并行回放的依据,默认值是COMMIT_ORDER,基于主库提交顺序判断;如果业务里不同事务之间没有数据冲突,可以改为WRITESET,能让并行度明显提升。
这里有个容易踩的坑:并行度拉满之后,从库的 CPU 会跟着涨,如果从库本身还扛着大量读流量,并行复制带来的 CPU 开销可能适得其反。我的经验是先从 4 开始,观察延迟收敛情况和 CPU 水位,逐步上调,不要一上来就写 32。
3.3 延迟监控的误报陷阱:0 秒也可能骗人
我遇到过几次更诡异的场景:Seconds_Behind_Source显示为 0,但业务侧明显读到了过期数据。原因是这个值的计算逻辑——从库 SQL 线程已经把 relay log 回放完了,但 IO 线程还没来得及拿到新的 binlog,这时候延迟秒数会归 0,实际主从之间可能差了整整一个网络的往返时间。
还有一种情况是主库长时间没有写操作,Seconds_Behind_Source会静止在一个非零值上不变化,容易被人误判为延迟严重。判断方法不是只看当前值,而是看它是否在持续增长或者持续不为零。更可靠的监控方式是比对主从库的 binlog 位点,或者直接在主库写入一条带时间戳的探针数据,然后在从库查询这条数据的时间差。我在自建的巡检脚本里就用第二种方法,每 30 秒插入一条心跳记录,从库读取延迟的差值,效果比单纯盯状态变量可靠得多。
4. 磁盘警报背后的三张鬼牌:碎片、Binlog 与临时空间
4.1 表空间碎片:为什么删了 5000 万行,磁盘一点没少
运维同学最常见的困惑之一,是明明对一个大表执行了 DELETE 删掉一半数据,但磁盘占用纹丝不动。这就要说到 InnoDB 的存储机制:InnoDB 以 B+ 树和页为单位组织数据,DELETE 操作只是在页里打上“可复用”标记,并不会把物理空间归还给操作系统。数据页中留下大量空洞,表空间文件依然保持原来的体积。这种情况专业叫法是“表空间碎片”。
碎片带来的问题不只是磁盘不释放,更麻烦的是查询性能劣化。因为扫描时依然要遍历这些有空洞的页,有效数据密度降低,缓冲池命中率也会下降。
处置方案是重建表。常用两条路:
ALTER TABLE <表名> ENGINE = InnoDB;或者使用OPTIMIZE TABLE <表名>;。两者本质都是重建表,让数据重新紧凑排列,释放碎片空间。需要注意两点:一是重建过程中会对原表加锁,大表操作要评估业务能不能接受窗口期,建议在低峰期执行;二是重建会重建二级索引,IO 开销不小,磁盘剩余空间必须大于表体积的 1.5 倍左右,否则可能中途失败。我处理过一张 200GB 的表,OPTIMIZE TABLE跑了四个小时,期间 CPU 和 IO 都拉满,事前没有充分评估的话很容易引发次生故障。
4.2 Binlog 积压与过期策略的坑
MySQL 的 binlog 是默认开启的,却常常被忽视。某次某系统磁盘告警,我登陆一看,/data/mysql目录 90% 被mysql-bin.00xxxx系列文件占满,时间跨度长达两周。原因很简单:主从复制断了,IO 线程没有消费完 relay log,主库为了确保从库能追上,会一直保留 binlog 不清理。
很多 DBA 只知道有个参数expire_logs_days,这个参数在 MySQL 8.0 里已经被binlog_expire_logs_seconds替代。但更隐蔽的问题是:清理策略只是“到时间才清”,如果从库追不上,清理动作会暂停。判断方法是看从库的状态:如果Replica_IO_Running是 No,或者位点和主库差距持续拉大,binlog 就会只增不减。
处置顺序是:先恢复主从链路,再考虑手动清理过期 binlog。手动清理用PURGE BINARY LOGS BEFORE NOW() - INTERVAL 3 DAY;,千万别直接删文件——删文件会让主从位点信息混乱,严重时只能重建从库。我见过有人为了省事直接rmbinlog,结果 replica 报错Could not find first log file name in binary log index,最后花了半天重建从库,代价惨痛。定期监控 binlog 占用比例,并配置自动清理策略,是磁盘空间问题的前置防线。
4.3 临时文件把 tmpdir 写爆了:一个少有人关注的角落
另一种磁盘告警的源头,是 MySQL 的临时目录。SHOW VARIABLES LIKE 'tmpdir';可以查看它的位置,默认通常指向/tmp。如果系统里频繁出现大量排序或分组操作,且结果集很大,MySQL 会把临时文件写入这个目录。当并发量足够大、临时文件足够多时,哪怕数据目录本身体积很小,/tmp所在分区也会被打满。
症状是 SQL 突然全部报错:No space left on device,但检查数据目录却发现空间充足。我在某业务系统上就踩过这个坑:夜间跑批任务触发大量分组聚合查询,一个小时内/tmp就被几十 GB 的临时文件塞满。处置方式是在监控上同时盯住 tmpdir 所在分区的使用率,并考虑把它改到数据盘或单独的 SSD 分区上。修改方式很简单:调整启动参数--tmpdir=/data/mysql_tmp,重启实例生效。这里提醒一句,重启数据库前先确认临时目录能挂载和写入,不然实例可能起不来。
4.4 误判“磁盘满”的最后一类场景:undo 表空间只涨不缩
除了上面三类,还有一个相对零散但很折磨人的场景:ibdata1或独立的 undo 表空间文件持续膨胀。MySQL 8.0 之前 undo 默认放在系统表空间ibdata1中,默认不会收缩。长事务或大查询跑久了,undo 空间会被撑大,事务结束之后文件体积也回不去。处置方案在 8.0 里相对简单,undo 已经支持独立表空间和自动截断,参数innodb_undo_log_truncate=ON、innodb_max_undo_log_size合理设置即可。5.7 及以下版本就比较麻烦,可能需要重建实例或定期切换 undo 表空间。这类问题排查链路长,先确认是哪个文件在涨、再关联当时执行的 SQL,能节约大量定位时间。
5. 从应急到预防:我常用的巡检项与工具箱
5.1 每月巡检清单:不花钱的“体检”
经过前面这些事故之后,我给自己定了每个月第一周执行的巡检清单,不需要额外工具,执行几条 SQL 就能覆盖大部分风险点。
第一项是连接数水位与增长趋势,重点看Threads_connected和max_connections的比值,超过 70% 就要准备扩容或排查连接池。第二项是慢查询数量与 TOP SQL,看slow_query_log里最近一周的记录,分析执行计划是否有索引失效的情况。第三项是主从复制状态,检查SHOW REPLICA STATUS是否双 Yes,以及延迟是否在持续增长。第四项是磁盘空间与表空间碎片,对比information_schema.tables里data_free字段,找出空洞率高的表。
第五项容易被忽略,是权限与账号安全,我巡检时会把权限过大、长期不用的账号筛出来。这条不属于“性能”,但属于运维里更底层的安全问题,等出了问题再处理就晚了。
5.2 我常用的几条救命命令,按场景速查
这里做一份常用命令速查表,都是我实际用过且验证有效的:
| 场景 | 命令/工具 | 说明 |
|---|---|---|
| 查看当前连接构成 | SHOW PROCESSLIST; | 结合 Command、State 判断会话状态 |
| 定位锁等待 | SELECT * FROM sys.innodb_lock_waits; | 直观展示谁在等谁 |
| 查看大事务 | SELECT * FROM information_schema.innodb_trx WHERE trx_runtime > 60; | 找出运行超过 60 秒的事务 |
| 分析慢查询 | pt-query-digest slow.log | 聚合慢日志,找 TOP SQL |
| 统计信息刷新 | ANALYZE TABLE <表名>; | 解决优化器选错索引的常见手段 |
| 重建表释放碎片 | ALTER TABLE <表名> ENGINE = InnoDB; | 大表注意窗口期和磁盘余量 |
| 保留最近 N 天 binlog | PURGE BINARY LOGS BEFORE NOW() - INTERVAL 3 DAY; | 恢复主从后清理积压日志 |
pt-query-digest是 Percona Toolkit 里的经典工具,我建议每个 MySQL 运维环境都装上。它能把慢日志按执行次数和总耗时排序,输出一份通俗易懂的报告,尤其适合在批量慢查询事件发生时快速锁定方向。
5.3 故障复盘的正确姿势,比处置本身更重要
最后说说故障复盘。每处理完一次线上问题,我都会把整个过程按时间轴记录下来:什么时候发现、当时看到了什么指标、做了哪些操作、每一步的耗时、事后定位到的根因。这个习惯坚持了好几年,我的经验是复盘文档的价值不在于“完成报告”,而在于它能让你在下次遇到相似问题时,几十分钟内想起“上次是怎么查的”。
复盘时重点关注三个问题:一是监控是否覆盖了这次故障的指标,如果没有,说明监控要补项;二是应急预案里的操作顺序有没有需要调整的,比如这次是不是应该先杀会话而不是调参数;三是这类问题能否通过工具或巡检提前发现,把“救火”变成“防火”。
写在最后的一点体会
回看这些年排过的故障,真正把系统拖垮的往往不是问题本身,而是排查路径混乱、操作顺序颠倒。比如连接数打满时先重启、磁盘满时直接删 binlog 文件、主从延迟时盲目调大并行度——这些动作我都见过,也都见过它们引发更严重的次生灾害。MySQL 运维没有银弹,扎实理解每个指标背后的机制,配合一套自己熟悉的排查工具链,慢慢就会形成肌肉记忆。希望这篇文章里的实战思路,能在你下次面对告警时多一分镇定,少一次本可以避免的误操作。