你刚接手一个线上系统,昨天还一切正常,今天突然收到告警:数据库 CPU 飙到 90%,业务接口大面积超时。你心里一紧,立刻登录监控平台,发现罪魁祸首是一条昨天执行只需要 50 毫秒的 SQL,今天却跑了 5 秒,调用量还不小。
这不是一个假设的面试题,而是很多后端工程师、DBA 或运维同学都可能遇到的真实线上故障。面对这种“昨天还好好的,今天就崩了”的突发情况,新手容易陷入两个极端:要么毫无头绪,一通乱查;要么直接甩锅给“数据库抽风”或“网络波动”。但一个有经验的工程师,心里会有一套清晰的排查地图,能像侦探一样,从现象快速定位到根因。
这篇文章,我们就来彻底拆解这个问题:当一条 SQL 性能突然急剧劣化,导致数据库 CPU 飙升时,你应该如何系统性地、有步骤地进行排查?这不是一个简单的“优化 SQL”的问题,而是一个综合性的故障诊断过程。我们将从最表层的现象入手,层层深入,直到找到那个让 SQL“变慢”的元凶。
1. 第一反应:稳住阵脚,确认问题范围
故障发生时,首要任务是控制影响,而不是立刻埋头查代码。慌乱中做出的操作,可能会让问题变得更糟。
1.1 立即止血:区分“治标”与“治本”
CPU 90%意味着数据库已经不堪重负,随时可能雪崩。此时,你需要立刻采取一些“治标”措施,为后续的“治本”排查争取时间:
- 查看并限制源头:通过数据库监控或
SHOW PROCESSLIST(MySQL) /pg_stat_activity(PostgreSQL)快速找出消耗 CPU 最高的几个会话或 SQL。如果业务允许,可以KILL掉最耗资源的几个查询,暂时缓解压力。注意:Kill 操作要谨慎,需评估会话是否在执行关键事务。 - 评估是否扩容:如果是云数据库,临时提升 CPU/内存规格是最快的缓解方式。但这只是用钱买时间,问题根源未解。
- 业务降级或限流:与业务方沟通,能否暂时关闭非核心功能,或在网关层对触发该 SQL 的接口进行限流。
核心原则:先保证系统不垮,再寻找问题根源。同时,务必记录下故障时间点、具体的慢 SQL 语句、当时的数据库监控截图(CPU、QPS、连接数、慢查询日志),这些是后续分析的黄金数据。
1.2 精准定位:真的是“这一条”SQL的问题吗?
监控告警说“有一条 SQL 慢了”,但你需要亲自验证:
- 抓取现行慢查询:立即在数据库执行慢查询捕获命令。例如在 MySQL 中,可以临时开启并查看慢日志,或使用
performance_schema中的events_statements_summary_by_digest表,找到执行时间最长、消耗资源最多的 SQL 指纹。 - 对比历史性能:将抓到的“嫌疑 SQL”与昨天的性能基线(如果有监控历史)进行对比。确认是不是同一条 SQL(SQL 文本经过参数化后一致),以及其执行计划是否发生了变化。
- 排除干扰项:确认同一时间段内,是否有其他重型任务在运行?例如:大数据批处理、全表备份、大量数据导入/导出、统计报表生成等。这些都可能抢占 CPU 和 IO 资源,导致你的业务 SQL 排队等待。
关键判断:如果确认是单条 SQL 的性能在数据量、查询条件未发生重大变化的前提下急剧下降,那么问题的焦点就从“资源竞争”转向了“SQL 执行本身”。
2. 深度探查:为什么同一条 SQL 的执行计划会“变质”?
SQL 执行突然变慢,十有八九是执行计划(Execution Plan)变了。数据库优化器基于统计信息、成本模型等选择它认为最优的路径来获取数据。一旦这个“最优路径”选错了,性能就会指数级下降。我们的排查重心,就是找出执行计划变坏的原因。
2.1 获取并对比执行计划
这是最核心的一步。你需要获取 SQL 在当前问题时刻的执行计划,并尽可能与昨天正常时的执行计划进行对比。
- MySQL: 使用
EXPLAIN或EXPLAIN FORMAT=JSON来查看执行计划。更推荐EXPLAIN ANALYZE(MySQL 8.0.18+)来获取实际执行时的统计信息。 - PostgreSQL: 使用
EXPLAIN (ANALYZE, BUFFERS)。 - Oracle: 使用
EXPLAIN PLAN FOR ...然后查询DBMS_XPLAN.DISPLAY。
对比时关注这些致命点:
- 访问类型(Access Type)是否降级:例如,昨天是
index seek(索引查找),今天变成了index scan(索引扫描)或table scan(全表扫描)。 - 索引是否失效:计划中是否出现了“隐式类型转换”、“函数包裹字段”、“OR 条件不当”导致索引未被使用。
- 连接(JOIN)顺序或算法是否改变:例如从
Nested Loop Join(嵌套循环,适合小数据集)变成了Hash Join(哈希连接,适合大数据集但内存消耗大)或Sort Merge Join,而你的数据分布并不适合新算法。 - 预估行数(Estimate Rows)与实际行数(Actual Rows)是否严重不符:如果优化器预估只返回 10 行,实际却扫描了 100 万行,这通常是统计信息不准的典型信号。
2.2 揪出导致计划变坏的四大常见元凶
根据对比结果,可以按以下优先级进行排查:
2.2.1 统计信息过时或不准
这是最常见的原因。数据库依靠统计信息(如表的行数、列的数据分布直方图、索引的区分度等)来估算成本。如果统计信息没有及时更新,优化器就是在“瞎猜”。
- 如何发现:
EXPLAIN中预估行数与实际行数差异巨大(相差几个数量级)。或者,表的数据量在近期发生了剧烈变化(如大量删除或导入),但统计信息未更新。 - 如何解决:
- 手动更新:执行
ANALYZE TABLE(MySQL)或ANALYZE(PostgreSQL/Oracle)来重新收集统计信息。 - 检查自动任务:确认数据库的自动统计信息收集任务是否正常运行,频率是否合理。
- 手动更新:执行
2.2.2 索引失效或未被使用
索引是高速通道,但通道可能被关闭或误导。
- 常见失效场景:
场景 示例 原因分析 隐式类型转换 WHERE user_id = '123'(user_id 是 int 类型)数据库需要将列值转换为字符串再比较,导致索引失效。 对索引列使用函数或运算 WHERE DATE(create_time) = '2023-10-27'计算破坏了索引的有序性。应改为 WHERE create_time >= '2023-10-27' AND create_time < '2023-10-28'。OR 条件使用不当 WHERE a = 1 OR b = 2,如果 a、b 有独立索引,可能都不走。考虑改为 UNION ALL或创建复合索引。模糊查询前缀不匹配 WHERE name LIKE '%张三%'前导通配符 %导致索引失效。LIKE '张三%'则可能使用索引。复合索引最左前缀缺失 索引是 (a, b, c),查询条件是WHERE b = 1 AND c = 2。无法使用该复合索引。 - 如何排查:仔细阅读
EXPLAIN输出,看key字段是否使用了你期望的索引,以及Extra字段是否有Using where; Using filesort; Using temporary等不良提示。
2.2.3 数据量或数据分布发生剧变
有时问题不在 SQL 本身,而在数据。
- 数据量暴增:查询的表在短时间内插入了大量数据,使得原本高效的执行计划(如全索引扫描)变得低效。
- 数据分布倾斜:例如,某个状态字段
status的值原本均匀分布,但今天突然 99% 的数据都变成了status = 'PENDING'。这时,优化器可能认为走索引不如全表扫描,但实际对于status = 'SUCCESS'的少数查询,全表扫描就变成了灾难。 - 如何排查:检查相关表的数据增长情况。对于数据倾斜,可以查看列的直方图统计信息(如果数据库支持)。
2.2.4 数据库参数或环境变化
这个原因相对隐蔽,但不容忽视。
- 参数变更:是否有人调整了数据库的优化器相关参数?例如
optimizer_switch(MySQL)、random_page_cost(PostgreSQL)等。这些参数会影响优化器的成本计算模型。 - 资源竞争:虽然第一步排除了明显的全局资源竞争,但可能发生了局部热点。例如,该 SQL 访问的表或索引所在的磁盘 IO 出现瓶颈,或者缓冲池(Buffer Pool)中该表的热点数据被挤出了内存,导致大量物理读。
- 检查 IO 延迟:查看数据库所在主机的磁盘 IO 监控。
- 检查缓冲池命中率:
SHOW ENGINE INNODB STATUS(MySQL)或pg_buffercache视图(PostgreSQL)可以帮助分析。
3. 系统性排查工具箱:从监控到日志的完整链路
一个成熟的排查流程,不能只依赖临时的EXPLAIN。你需要建立一个从宏观到微观的观察体系。
3.1 监控层面:建立性能基线与告警
- 数据库核心指标监控:CPU 使用率、内存使用率、磁盘 IOPS/吞吐量/延迟、网络流量、连接数。历史趋势图是发现异常波动的关键。
- SQL 性能监控:
- 慢查询日志(Slow Query Log):记录所有超过阈值的 SQL。这是事后分析的宝贵资料。
- 数据库性能视图:如 MySQL 的
performance_schema和sysschema,PostgreSQL 的pg_stat_statements扩展。它们可以提供 SQL 级别的聚合性能数据(总执行时间、调用次数、行扫描数等)。
- 应用层面监控:应用服务器的 CPU、内存,以及关键业务接口的响应时间、错误率。这有助于判断是数据库问题导致了应用问题,还是应用层的异常调用(如循环调用、参数异常)冲击了数据库。
3.2 日志层面:串联事件时间线
当故障发生时,按时间顺序收集并关联以下日志,可以还原故障现场:
- 数据库错误日志(Error Log):查看故障时间点前后是否有异常警告或错误信息。
- 数据库慢查询日志:定位具体的慢 SQL。
- 应用日志:查看触发该 SQL 的业务请求的日志,特别是传入的参数。是否出现了昨天没有的、会导致查询恶化的参数值?(例如,查询一个不存在的用户 ID,导致全表扫描)。
- 操作系统日志:检查数据库主机在故障时段是否有硬件报警、OOM Killer 活动等。
3.3 实战排查命令清单(以 MySQL 为例)
将上述思路转化为可执行的命令流:
-- 1. 紧急状态下,查看当前活动进程 SHOW FULL PROCESSLIST; -- 2. 抓取问题SQL的执行计划(替换为实际SQL) EXPLAIN FORMAT=JSON SELECT * FROM your_table WHERE your_condition; -- 3. 检查表结构和索引 SHOW CREATE TABLE your_table; SHOW INDEX FROM your_table; -- 4. 检查表数据量变化 SELECT TABLE_NAME, TABLE_ROWS, UPDATE_TIME FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'your_db' AND TABLE_NAME = 'your_table'; -- 5. 检查优化器开关设置(是否被改动过) SELECT @@optimizer_switch; -- 6. 检查InnoDB缓冲池状态(看热点数据是否在内存) SHOW ENGINE INNODB STATUS\G -- 关注 BUFFER POOL AND MEMORY 部分,观察 Free buffers、Database pages 等。 -- 7. 如果怀疑统计信息,更新之 ANALYZE TABLE your_table;4. 根因解决与长效预防:从一次故障到一套机制
找到根因并修复(如更新统计信息、优化 SQL、调整索引)后,故障得以解决。但工作远未结束。真正的价值在于将这次被动响应,转化为主动预防的能力。
4.1 针对根因的解决方案
根据第二部分的排查结果,采取相应措施:
- 统计信息问题:建立定时收集统计信息的任务,并在大数据操作后手动触发。
- 索引问题:优化 SQL 写法,或增删改索引。创建索引前务必评估对写操作的影响。
- 数据倾斜问题:考虑优化业务逻辑,或使用分区表将热点数据分离。
- 参数/环境问题:回滚或优化参数变更。对于硬件瓶颈,考虑升级或迁移。
4.2 构建预防体系,让问题无处遁形
- SQL 上线审核:建立 SQL 上线前的评审机制,强制要求提供
EXPLAIN执行计划,并由资深工程师或 DBA 审核。 - 性能基线监控:对核心业务 SQL 建立性能基线监控(平均耗时、P95/P99 耗时)。一旦偏离基线超过一定阈值(如 50%),立即告警。这能在 CPU 飙到 90%之前就发现问题。
- 定期健康检查:定期(如每周)运行数据库健康检查脚本,内容包括:索引使用情况、冗余索引、表碎片率、统计信息新鲜度等。
- 变更管理:任何数据库参数、表结构、索引的变更,必须走严格的变更流程,并在低峰期进行,同时准备好回滚方案。
- 压测与预案:在大促或重大功能上线前,进行充分的数据库压测,识别潜在瓶颈,并制定扩容、限流、降级等应急预案。
回到开头那个场景,一条 SQL 从 50 毫秒到 5 秒的蜕变,绝不是偶然。它暴露的可能是统计信息维护的缺失,可能是索引设计的隐患,也可能是对数据增长预期的不足。高效的排查,不是靠运气,而是靠一套融合了监控、分析、推理、验证的系统方法。这套方法的价值,不仅在于快速扑灭一次线上火灾,更在于将团队从“救火队员”的角色中解放出来,转向更重要的“防火设计”中去。