上周三凌晨两点,手机突然炸响,值班同事的声音都变了调:“核心交易库 CPU 飙到 98%,所有查询全卡住了!”我一边赶往公司,一边打开堡垒机,心跳得比手机震动还快。数据库运维这行,最怕的就是半夜来电——不是故障就是事故。那天晚上,我们经历了一场从故障排查到性能优化的完整实战,有些教训和经验值得写下来。

赶到机房时,监控大屏上的 CPU 曲线已经顶到天花板。我第一反应不是看慢查询,而是先检查是否有锁等待。很多新手运维容易犯的毛病是直接查慢 SQL,但有时候 CPU 飙升的根源是大量会话在争抢同一把锁。执行 后,果然看到几十个 “Waiting for table metadata lock” 的会话,源头是一个 操作在跑。生产环境做大表 DDL(数据定义语言)时,既没选对窗口期,也没用 这类工具,相当于在高峰期给高速公路上设了个收费站。我立刻 掉那个 DDL 进程,CPU 瞬间从 98% 掉到 40%,但问题只是暂时缓解——DDL 迟早要跑,而真正拖垮性能的,是这些锁等待背后的业务逻辑。
锁的问题解决了,但 CPU 仍在 60% 左右浮动,这不对劲。我开始抓取实时慢查询日志,发现一个 语句的平均执行时间从 0.3 秒飙到了 8 秒。这条 SQL 很简单:看起来就是普通分页查询,但执行计划显示它走了全表扫描,扫描行数接近 800 万。原因是 字段的区分度太低,MySQL 优化器觉得用索引不如全表快。但实际上,这张表有 800 万行, 的记录只有 2 万条。如果建一个联合索引 ,优化器就能快速定位目标。我立刻在主库上创建了该索引,执行时间从 8 秒降到 0.02 秒,CPU 也随之掉到 30%。
别急着夸自己神操作。索引建完当天下午,业务方又来了:“分页查询翻到第二页就特别慢。”我这才意识到, 配合 的分页在数据量大时存在经典陷阱—— 越大,MySQL 需要扫描并丢弃的行就越多。用户翻到第 100 页时,实际上是 ,MySQL 先扫描 2000 行再丢掉前 1980 行。解决方案不是继续加索引,而是改成“游标分页”:用上一次查询的最大 作为条件,。改完以后,无论翻到第几页,性能都稳定在 1 毫秒以内。这个坑提醒我:索引不是万能药,很多时候业务逻辑的优化比 SQL 调优更有效。
性能稳定了两天,新的幺蛾子来了。凌晨的批处理任务开始报超时,一查日志,发现是数据归档脚本在删除历史数据时产生了大量 binlog,导致主从同步延迟从几秒飙升到半小时。主库上执行一次删了 200 万行,每删一行都会写 binlog,从库逐行回放自然跟不上。这种批量删除的正确姿势是分批操作:每次删 1000 行,,循环执行。更优雅的做法是直接 分区表——如果当初按时间做了分区,只需要 ,瞬间完成,根本不产生大量 binlog。可惜业务方当初没考虑分区,我们只能临时写脚本分批删除,同时调整归档策略:以后每天定时删除 90 天前的数据,避免集中在月底一次性处理。
说到分区,这其实是个老生常谈但经常被忽视的设计。很多开发在初期觉得数据量小,没必要分区,等业务跑了一年,单表几千万行,想改就难了。我们有个报表系统就是典型案例:一张表存了所有用户的登录日志,每天新增 200 万行,半年后按天聚合的报表查询要跑 30 秒。后来我们用 分区按月份切分,查询时只扫描当月分区,时间降到 2 秒以内。但分区也不是越多越好,分区数太多会导致文件句柄紧张和查询优化变慢。一般建议每个分区保持在 500 万到 1000 万行之间,分区总数不超过 1024 个。另外,分区键的选择很关键——必须是查询中最常用的过滤条件,否则分区反而成负担。
最近遇到的一个性能瓶颈挺有意思:一个统计页面加载要 15 秒,但所有 SQL 单独拎出来跑都不超过 0.5 秒。问题出在“N+1 查询”上——前端循环调用了一个接口,每次接口里都有一条独立的 SQL,总共调了 30 次。虽然每条 SQL 很快,但 30 次的网络往返和连接池开销累加起来就成了 15 秒。我让开发把循环调用改成一次 查询,接口返回所有数据后前端自行分组统计,页面加载直接降到 1 秒。这类问题在微服务架构里特别常见,很多开发习惯了 ORM 框架的懒加载,一个实体关联查询能触发 dozens 条 SQL,运维如果不盯慢查询日志根本发现不了。所以我现在强制要求:所有接口的 SQL 执行次数必须打印到日志里,超过 5 次就报警。
聊个教训。之前有个核心库每隔两个月就莫名其妙慢一次,查了所有慢 SQL、锁、索引,都没找到根因。直到有一天偶然看到 里的 “History list length” 值异常高,才意识到是长事务搞的鬼。原来有个后台任务在跑大型报表,事务里先查了 100 万行数据,然后程序处理了 10 分钟才提交。这 10 分钟里,InnoDB 的 MVCC 需要保留所有行的历史版本,Undo 日志越积越多,最终导致查询需要扫描大量历史版本数据。解决方案很简单:把报表查询拆成多个小事务,每处理 1000 行就提交一次。从那以后,我养成每周检查一次长事务的习惯,用找出运行超过 30 秒的事务,直接通知业务方处理。
回看这些案例,数据库运维的本质其实就两件事:一是把事情做对,二是把事情做快。做对靠的是对底层原理的理解——你知道锁怎么工作,才知道怎么避免死锁;你懂 MVCC 机制,才能根治长事务问题。做快靠的是持续监控和迭代——没有一成不变的优化方案,业务数据在涨,访问模式在变,你的索引、分区、SQL 都得跟着调整。那天凌晨的故障处理最终以我们重写归档脚本、优化分页逻辑、建立慢查询周报机制而告终。当然,下个月肯定还会有新坑等着,但这就是运维的日常:解决问题、总结经验,然后等着下一个问题。


