数据库服务器CPU占用过高怎么办?慢SQL与并发问题分析

数据库服务器 CPU 飙到 90% 以上,是运维值班最常见的告警之一。它带来的连锁反应很快:查询变慢、连接堆积、接口超时,最后整个应用跟着卡死。很多人一遇到 CPU 高,第一反应就是"加核"或者"重启数据库",但如果不找到根因,加核只是拖延,重启只是掩盖——过不了多久又会出现。

这篇文章给一套可操作的排查方法:先用 top、performance_schema 这类工具定位到"是哪个线程、哪条 SQL 在吃 CPU",再用慢查询日志和 EXPLAIN 分析问题 SQL,然后区分是"单条烂 SQL"、"并发连接过多"、"锁等待"还是"参数不合理",最后按优先级给出优化方向。整个过程不需要昂贵的监控系统,用数据库自带的能力就能完成。

一、排错顺序:先定位,再优化

面对 CPU 高,最忌讳的是盲目动手。正确的顺序是:

  1. 确认是数据库进程在吃 CPU,还是别的东西(比如备份脚本、压测、恶意进程)。
  2. 定位到具体是哪些会话/线程在消耗 CPU。
  3. 抓出这些会话正在执行的 SQL。
  4. 分析 SQL 为什么慢(全表扫描、排序、临时表、锁等待)。
  5. 判断是 SQL 问题、并发问题还是配置问题。
  6. 按优先级处理:先止血(杀慢查询、限流),再根治(优化 SQL、加索引、调参)。

下面逐层展开。

二、用 top 定位到数据库进程和线程

第一步是确认凶手。登录服务器:

# 整体情况:看 CPU 使用率和负载
top -c
# 或更直观的
htop           # 需安装

# 看哪个进程最吃 CPU
top -b -n 1 | head -20

# 确认数据库进程
ps -eo pid,ppid,pcpu,pmem,cmd --sort=-pcpu | head

如果确认是 mysqld 或 postgres 进程在吃 CPU,接下来要看具体线程。top 里按 H 可以切换到线程视图(显示每个线程的 CPU),或者直接用:

# 查看 mysqld 进程内各线程的 CPU(-H 显示线程)
top -H -p $(pgrep -x mysqld)
# 或
ps -L -p $(pgrep -x mysqld) -o pid,tid,pcpu,stat,comm --sort=-pcpu | head

记下高 CPU 的线程 ID(TID),一会儿要跟数据库里的线程对应起来。

也可以从系统整体判断瓶颈类型:

# 各核使用率分布:判断是单核瓶颈还是整体瓶颈
mpstat -P ALL 2 5      # RHEL 系 yum install sysstat;Debian 系 apt install sysstat
# 所有核都高 → 并发压力大或 SQL 普遍重
# 只有一两个核高 → 单线程瓶颈或某个热点 SQL

# 看 IO 是否也饱和
iostat -x 2 5

如果 CPU 高但 IO 也接近 100%,那瓶颈可能主要在磁盘,CPU 高只是被 IO 拖累的表象。

三、用 performance_schema 找出高消耗 SQL

MySQL 5.7 和 8.0 自带的 performance_schema 是最强的在线诊断工具,不用装任何东西。核心是语句摘要表:它把结构相同、只是参数不同的 SQL 归一成一条,统计执行次数、总耗时、平均耗时等。

-- 确认 performance_schema 开启
SHOW VARIABLES LIKE 'performance_schema';
SHOW VARIABLES LIKE 'performance_schema_consumer_events_statements_history%';

-- 按总耗时排序,找最耗资源的 SQL(MySQL 8.0)
SELECT
  DIGEST_TEXT,
  COUNT_STAR                       AS exec_count,
  ROUND(SUM_TIMER_WAIT/1e12, 2)    AS total_sec,
  ROUND(AVG_TIMER_WAIT/1e9, 2)     AS avg_ms,
  SUM_ROWS_EXAMINED                AS rows_examined,
  SUM_ROWS_SENT                    AS rows_sent,
  SUM_NO_INDEX_USED                AS no_index_used
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 20;

这个查询会告诉你:哪些 SQL 被执行得最多、累计最耗时、扫描了多少行、有没有用到索引。重点是看 SUM_ROWS_EXAMINED 远大于 SUM_ROWS_SENT 的语句——扫描几百万行只返回几条,典型的索引缺失,是 CPU 杀手。

如果 performance_schema 不便使用,可以看 processlist 找当前正在跑的会话:

-- 按执行时间排序,找长时间运行的会话
SELECT id, user, host, db, command, time, state,
       LEFT(info, 200) AS sql_text
FROM information_schema.processlist
WHERE command <> 'Sleep'
ORDER BY time DESC;

MySQL 8.0 还有 sys 库封装的视图,更友好:

-- 按总延迟排序的语句
SELECT * FROM sys.statement_analysis
ORDER BY total_latency DESC LIMIT 10;
-- 当前正在执行的语句
SELECT * FROM sys.session WHERE command <> 'Sleep';
-- 使用临时表和排序的语句
SELECT * FROM sys.statements_with_temp_tables LIMIT 10;
SELECT * FROM sys.statements_with_sorting LIMIT 10;
SELECT * FROM sys.statements_with_full_table_scans LIMIT 10;

PostgreSQL 侧,如果没有装 pg_stat_statements,可以查 pg_stat_activity:

-- 找出运行时间长的查询
SELECT pid, usename, state, now() - query_start AS duration, query
FROM pg_stat_activity
WHERE state <> 'idle' AND now() - query_start > interval '5 seconds'
ORDER BY duration DESC;

-- 装了 pg_stat_statements 后按总耗时排序
SELECT query, calls, total_exec_time, mean_exec_time, rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 20;

建议:PostgreSQL 生产环境几乎必装 pg_stat_statements 扩展,MySQL 生产环境保留 performance_schema(默认开启,注意它会占用少量内存)。没有这类工具,排查 CPU 问题会非常被动。

四、慢查询日志

performance_schema 看的是"当前和历史摘要",慢查询日志看的是"超过阈值的语句明细",两者互补。

-- MySQL:查看慢查询配置
SHOW VARIABLES LIKE 'slow_query_log';
SHOW VARIABLES LIKE 'long_query_time';
SHOW VARIABLES LIKE 'slow_query_log_file';
SHOW VARIABLES LIKE 'log_queries_not_using_indexes';

-- 动态开启(临时)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 0.5;             -- 超过 0.5 秒记录
SET GLOBAL log_queries_not_using_indexes = ON; -- 记录未用索引的(量大时慎开)

说明一点:log_queries_not_using_indexes 会记录所有未用索引的语句,在小表上也很多,日志会爆炸,生产环境慎开,或配合 log_throttle_queries_not_using_indexes 限流。

分析慢日志可以用 mysqldumpslow(自带)或 pt-query-digest(Percona Toolkit,功能强得多):

# 自带工具:按耗时排序取前 10
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log

# pt-query-digest:生成详细报告,推荐
pt-query-digest /var/log/mysql/slow.log > slow_report.txt
pt-query-digest --since=1h /var/log/mysql/slow.log   # 只看最近 1 小时

pt-query-digest 的报告会按"总耗时贡献"给 SQL 排序,直接告诉你优化哪几条收益最大。

五、EXPLAIN 分析:Sql 为什么慢

抓到问题 SQL 后,用 EXPLAIN 看执行计划。重点看这几列:

EXPLAIN SELECT * FROM orders WHERE user_id = 1001 AND status = 'paid';

-- MySQL 8.0.18+ 支持 EXPLAIN ANALYZE,会真实执行并给出实际耗时
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 1001;

EXPLAIN 输出里要重点盯的信号:

信号 含义 处理方向
type = ALL 全表扫描 加索引
type = index 全索引扫描 索引可能不合适
key = NULL 没用上索引 检查索引和条件
rows 很大 预估扫描行数多 优化条件或索引
Extra: Using filesort 需要额外排序 让排序字段走索引
Extra: Using temporary 用了临时表 优化 GROUP BY/ORDER BY 或加索引
Extra: Using join buffer JOIN 没走索引 给关联字段加索引
Extra: Using where 过滤在服务层做 尽量让条件下推到索引

典型问题及对策:

全表扫描:WHERE 条件字段没索引,或者索引因为类型转换、函数运算、前导通配符而失效。比如 WHERE DATE(created_at) = '2026-10-12' 会让 created_at 上的索引失效,应改成范围查询 WHERE created_at >= '2026-10-12' AND created_at < '2026-10-13'。

filesort:ORDER BY 字段没走索引,数据库要在内存或磁盘排序。给排序字段(尤其是和 WHERE 组合的复合索引)加索引,让排序也能用上索引。

临时表:GROUP BY、DISTINCT、UNION 经常产生临时表。给分组字段加索引,或改写 SQL 减少临时表。

隐式类型转换:user_id 是字符串类型,却用 WHERE user_id = 1001(数字),MySQL 会做类型转换导致索引失效。参数类型要和字段类型一致。

索引设计不合理:复合索引的列顺序不对、区分度低的列放在前面、或者一个表上索引太多导致优化器选错。复合索引要遵循"最左前缀"原则,把区分度高的列和常用于等值查询的列排在前面。

-- 查看表的索引
SHOW INDEX FROM orders;
-- 查看索引基数(区分度)
SHOW INDEX FROM orders\G   -- 关注 Cardinality
-- 分析表统计信息(优化器选执行计划的依据)
ANALYZE TABLE orders;

六、并发连接数问题

CPU 高不一定是单条 SQL 慢,也可能是并发量太大。成百上千个连接同时执行哪怕是简单的查询,CPU 也会被压满。

-- 当前连接数和历史峰值
SHOW STATUS LIKE 'Threads_connected';      -- 当前连接
SHOW STATUS LIKE 'Threads_running';        -- 正在执行的连接(关键指标)
SHOW STATUS LIKE 'Max_used_connections';   -- 历史峰值
SHOW VARIABLES LIKE 'max_connections';

-- 按用户/主机聚合连接数,找异常来源
SELECT user, host, COUNT(*) AS conns
FROM information_schema.processlist
GROUP BY user, host
ORDER BY conns DESC;

Threads_running 是最值得关注的指标——它表示真正在消耗 CPU 的活动连接数。如果 Threads_connected 很高但 Threads_running 很低,说明大量连接是空闲的,CPU 问题不在连接数;如果 Threads_running 持续很高,说明并发压力真的很大。

并发问题的处理方向:

  • 限流:在应用层或网关层限制进入数据库的并发,比如用连接池控制池大小(参见连接池那篇)。
  • 加缓存:读多写少的场景,把热点数据放 Redis,减少数据库查询。
  • 读写分离:把读流量分到从库,减轻主库 CPU。
  • 分库分表:单表数据量和访问量到瓶颈时的长期方案。
  • 优化慢 SQL:并发高往往是因为单条慢,流量一上来就堆积。

七、锁等待与阻塞

CPU 高有时是锁问题引起的连锁反应。比如一个会话持有锁不释放,其他会话排队等待,应用层不断重试,连接和查询堆积,CPU 与负载都被推高。

-- MySQL 8.0:查看当前锁等待
SELECT * FROM performance_schema.data_lock_waits;
SELECT * FROM sys.innodb_lock_waits;              -- 封装的视图,更易读

-- 查看 InnoDB 事务与锁
SELECT * FROM information_schema.innodb_trx ORDER BY trx_started;
SHOW ENGINE INNODB STATUS\G                        -- 关注 TRANSACTIONS 段

-- 查看元数据锁(MDL)等待,DDL 常引起
SELECT * FROM performance_schema.metadata_locks;
SELECT * FROM sys.schema_table_lock_waits;

PostgreSQL:

-- 查看锁等待
SELECT blocked.pid AS blocked_pid, blocked.query AS blocked_query,
       blocking.pid AS blocking_pid, blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_locks bl ON bl.pid = blocked.pid
JOIN pg_locks kl ON kl.locktype = bl.locktype AND kl.relation = bl.relation
JOIN pg_stat_activity blocking ON blocking.pid = kl.pid
WHERE NOT bl.granted AND kl.granted AND blocking.pid <> blocked.pid;

锁问题的根因通常是:事务开得太大、事务里做了远程调用或耗时操作导致持锁时间过长、或者加锁顺序不一致导致死锁。处理方向是缩小事务范围、缩短持锁时间、统一加锁顺序。

风险提示:用 KILL 或 pg_terminate_backend 杀掉持锁会话可以止血,但被杀的事务会回滚,如果它正在执行大批量写操作,回滚本身可能消耗大量资源、耗时很久,反而让情况更糟。杀之前要评估该会话在做什么,优先杀"等待方"而非"持有方"通常没有意义,关键是找到持有锁的源头。

八、参数与配置不合理

有些 CPU 高是配置造成的:

  • innodb_buffer_pool_size 太小:缓冲池不足导致频繁磁盘 IO,查询变慢,CPU 忙于上下文切换。一般应设为物理内存的 50% 到 70%。
  • table_open_cache、table_definition_cache 太小:频繁打开表文件,开销上升。
  • innodb_thread_concurrency 设置不当:限制并发线程数的参数,设得太低会排队,太高会竞争。
  • max_connections 过大:连接多但资源有限,反而互相拖累。
  • query_cache(MySQL 8.0 已移除):老版本上启用查询缓存在高并发写场景下是 CPU 杀手,是反面典型。
  • sync_binlog、innodb_flush_log_at_trx_commit:设得过于激进(每次都刷盘)会增加 IO 压力,在 IO 受限时可能间接推高 CPU(但这是拿安全换性能,要谨慎)。
-- 查看关键参数
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
SHOW VARIABLES LIKE 'innodb_thread_concurrency';
SHOW VARIABLES LIKE 'table_open_cache';
SHOW VARIABLES LIKE 'max_connections';

PostgreSQL 侧关键参数是 shared_buffers(约内存 25%)、work_mem(每个排序/哈希操作可用,设太大且并发高会吃光内存)、max_connections、effective_cache_size。

九、优化优先级

面对 CPU 高,按这个顺序处理,收益从大到小:

  1. 先止血:定位并处理最耗 CPU 的会话/慢查询,必要时限流,恢复业务可用性。记录现场再动手。
  2. 优化 SQL 和索引:多数 CPU 问题源于少数几条烂 SQL。加索引、改写 SQL、避免全表扫描和 filesort,收益最直接。
  3. 控制并发:用连接池、缓存、读写分离降低数据库并发压力。
  4. 处理锁等待:缩小事务、缩短持锁时间,消除阻塞。
  5. 调整参数:让缓冲池、并发线程等参数匹配硬件。
  6. 升级硬件/架构:以上都做过还不行,才考虑加 CPU、加内存,或做分库分表、引入缓存层。

加硬件永远是最后一步,因为它成本高、见效快但治标不治本,而且掩盖的问题以后还会以更大规模爆发。

常见问题

Q:CPU 高但每条 SQL 看起来都不慢,为什么?
很可能是并发量太大,大量简单查询叠加把 CPU 压满。看 Threads_running 和历史峰值,考虑加缓存、限流或读写分离。

Q:performance_schema 本身会不会吃 CPU?
默认配置下开销很小,可以接受。如果确实担心,可以只保留必要的 consumer。不要为了省这点开销就关掉它,否则出问题时无处排查,得不偿失。

Q:加了索引为什么 CPU 还是高?
检查索引是否真的被用上(EXPLAIN 的 key 列),索引是否因类型转换、函数运算失效,是否索引选择错误(优化器选了不合适的索引)。必要时用 FORCE INDEX 验证,或 ANALYZE TABLE 更新统计信息。

Q:直接重启数据库能解决问题吗?
重启能立刻清空连接、释放内存、终止所有慢查询,看似有效,但根因未除,很快就会复发。而且重启期间数据服务中断,生产环境风险很高。除非已经彻底卡死,否则不要用重启来"优化"。

Q:top 看到 CPU 高但 Threads_running 不高,怎么回事?
可能是其他进程在吃 CPU(备份、压缩、监控采集),或者数据库在做后台任务(如 InnoDB 刷脏页、purge 线程、PostgreSQL 的 autovacuum)。先确认进程归属,再判断。

Q:怎么预防 CPU 突然飙高?
建立基线监控(正常时 CPU、QPS、Threads_running 是多少),设置告警阈值;上线前对 SQL 做 review;定期用 pt-query-digest 分析慢日志;对可能的突发流量做限流预案。

总结

  • 排查顺序是"先定位哪个进程/线程、再抓 SQL、再分析原因、最后按优先级优化",不要急于加核或重启。
  • performance_schema 和 sys 库是 MySQL 自带的利器,能直接按总耗时排出问题 SQL;PostgreSQL 应装 pg_stat_statements。
  • 慢查询日志配合 pt-query-digest 能定位贡献最大的语句,EXPLAIN 用来看全表扫描、临时表、filesort 等具体病因。
  • CPU 高既可能来自单条烂 SQL,也可能来自并发过大、锁等待或参数不合理,要分别对待。
  • 优化优先级:SQL 与索引 > 并发控制 > 锁处理 > 参数调整 > 硬件升级,加硬件是最后手段。
  • 建立基线监控和 SQL 上线 review,比故障后救火更省事。