前事不忘,后事之师,不忘国耻!

 用户注册  找回密码
 用户注册
搜索
查看: 20|回复: 0

数据库主机 CPU 高负载排查实战:从 top 到 perf 火焰图

[复制链接]

数据库主机 CPU 高负载排查实战:从 top 到 perf 火焰图

[复制链接]
dbaai

主题

0

回帖

221

积分

DBAAI

积分
221
昨天 07:48 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?用户注册

×
一、具体的问题

上周三,一台跑着 MySQL 8.0 的主机开始报警:CPU 使用率在 40 分钟内从 35% 爬到 94%,一直贴着高位不下来。同一时间业务侧 P95 从 45ms 涨到 1.2s,订单查询接口偶发超时。

值班同学做的第一件事是登录上去敲 top,看到 load average 68、us 62%、sy 15%、si 18%、wa 0.3%,于是给出结论:CPU 不够了,申请加核。

这个结论是错的,而且错得挺典型。原因很简单:top 给出的是一个进程级的平均值,它把"数据库自己在算"和"内核在替网卡收包"混在了一起。si 占到 18% 意味着有将近五分之一的 CPU 被软中断吃掉,这部分跟数据库一条 SQL 都没关系,加核只会让软中断分布得更分散一点,问题该在还在。

这篇文章把"主机 CPU 高"这件事拆成一条能照着走的排查路径:先分清是算得多还是等得久,再定位到具体线程,再把线程映射回数据库会话,最后才谈调参。顺序不能颠倒,颠倒了一定会走弯路。

二、核心原理

CPU 时间片不是一坨,它被内核分成若干类,top/mpstat 里的每一列都对应一个具体的来源:

指标含义高的时候通常意味着
us用户态耗时数据库真的在算:排序、聚合、解析、压缩
sy内核态耗时系统调用密集、锁竞争、内存分配、上下文切换
si软中断网卡收包、定时器、RCU,与 SQL 无关
wa等 IO存储慢;此时 CPU 反而可能空闲
st被宿主机偷走虚拟机 CPU 超卖,看宿主
hi硬中断中断没绑核、单队列网卡


由此推出三条判断原则,这三条是我在排查里反复用到的:

第一,us 高才可能是 SQL 的问题。sy 高要去看系统调用和锁,si 高要去看网络和中断,wa 高说明问题根本不在 CPU。

第二,平均值会骗人。8 核机器上 us 62% 可能是 5 个核闲得要死、3 个核烧到 100%。必须用 mpstat -P ALL 看每核分布,top -H 看每线程分布。CPU 高负载真正要追的是"热点集中"而不是"总量高"。

第三,数据库线程和 OS 线程是能互相翻译的。MySQL 里一个会话对应 performance_schema 里的一条 thread,thread 上有 THREAD_OS_ID,这就是 top -H 里看到的那个数字。PG 更直接,pg_stat_activity.pid 就是 OS 进程号。这个映射是整条链路的关节,没有它就只能对着火焰图猜。

顶层的分析方法叫采样剖析:内核每隔一个固定周期(perf 默认 4000Hz,采样建议 99Hz)记录一次"现在谁在 CPU 上跑",跑够 30 秒后按调用栈做频次聚合,就得到一张火焰图。火焰图的宽度是耗时占比,不是调用次数——横向宽说明它真的吃 CPU,纵向深只说明调用链长。很多人看反了这一点。

三、实例参考(动手步骤)

步骤 1:先分清是"算得多"还是"等得久",别靠猜

三个命令并行跑 30 秒,一次性把定性做完:
  1. # CPU 分类 + 运行队列(r 列含 D 状态进程,别只看 CPU 数字)
  2. vmstat 1 30
  3. # 每核分布:确认是不是热点集中在几个核上
  4. mpstat -P ALL 1 30
  5. # 顺带排除 IO:如果 wa 很高,CPU 根本不是本次的主线
  6. iostat -x 1 30 | grep -E 'Device|sd|nvme'
复制代码

这一步的产出是一句定性结论。以本文这台机器为例:si 平均 18%、mpstat 显示 8 核里有 2 个核的 %soft 打到 90% 以上,而 iostat 的 %util 只有 12%、await 0.8ms——存储是干净的,问题在软中断,不在数据库。

步骤 2:定位到具体线程
  1. # 找到数据库进程
  2. pgrep -a mysqld
  3. # 按线程看 CPU,H 显示线程;-b 批处理模式便于记录
  4. top -H -b -n 1 -p 28411 | head -30
  5. # 线程级 CPU 时间,1 秒采样,找出持续燃烧的线程
  6. pidstat -t -p 28411 1 30
复制代码

假设锁定到线程 28439 和 28440 长期占据 95% 以上。注意 pidstat -t 里的 TID 与 top -H 的 PID 列是同一个东西,两个命令要交叉验证,别一个用进程号一个用线程号对不上。

步骤 3:把 OS 线程映射回数据库会话

MySQL:
  1. SELECT t.THREAD_ID,
  2.        t.PROCESSLIST_ID,
  3.        t.THREAD_OS_ID,
  4.        p.USER,
  5.        p.HOST,
  6.        p.DB,
  7.        p.COMMAND,
  8.        p.TIME,
  9.        LEFT(p.INFO, 100) AS current_sql
  10. FROM performance_schema.threads t
  11. LEFT JOIN information_schema.processlist p
  12.        ON p.ID = t.PROCESSLIST_ID
  13. WHERE t.THREAD_OS_ID IN (28439, 28440)
  14. ORDER BY t.THREAD_ID;
复制代码

PostgreSQL 则把 THREAD_OS_ID 换成 pid:
  1. SELECT pid, usename, application_name, state,
  2.        now() - query_start AS dur, left(query, 100)
  3. FROM pg_stat_activity
  4. WHERE pid IN (28439, 28440);
复制代码

这里有个容易忽略的点:如果查出来是空,说明这两个线程不是会话线程,而是后台线程——InnoDB 的 purge、page cleaner、log writer 都能烧 CPU。那我前面"是数据库在算"的推断就要改,得先解决后台线程为什么忙(常见是 purge 积压或刷脏压力)。本文这台机器查出来是两条业务会话,其中一条在跑一张 1.2 亿行表的聚合查询,另一条在等待它持有的锁做刷新——到这里根因基本落地了。

步骤 4:拿到调用栈,确认它到底烧在哪
  1. # 实时看热点函数(不落盘,适合快速确认)
  2. perf top -p 28411 -g
  3. # 采样 30 秒并生成火焰图(99Hz,避免采样本身成为负担)
  4. perf record -F 99 -g -p 28411 -- sleep 30
  5. perf script > /tmp/out.perf
  6. git clone --depth 1 https://github.com/brendangregg/FlameGraph
  7. /tmp/FlameGraph/stackcollapse-perf.pl /tmp/out.perf > /tmp/out.folded
  8. /tmp/FlameGraph/flamegraph.pl /tmp/out.folded > /tmp/cpu.svg
复制代码

如果火焰图上最宽的那块出现在 row_search_mvcc、InnoDB::ha_records 这类取数函数上,就是典型的"扫描行数过多导致 CPU 燃烧",Handler_read_next 一定也是巨量;如果出现在 Search::handle_one_connection 加各种锁函数上,方向就换成锁竞争;如果整张图上都是 __softirqentry_text_start,那就是步骤 1 里那条软中断路线。

顺带一个判断技巧:EXPLAIN ANALYZE 的 rows_examined 与实际返回行数相差百倍以上,就不必再深挖内核了,先回去改 SQL 和索引,性价比最高。
  1. # 交叉验证:扫描行数指标
  2. mysql -e "SHOW GLOBAL STATUS LIKE 'Handler_read%'"
  3. # 如果 Handler_read_next 在 30 秒内涨了上亿,基本可以收工了
复制代码

步骤 5:三类高负载的处置手法

类型 A:软中断(si 高)。 先看是不是单队列网卡把中断全压在 CPU0 上:
  1. cat /proc/interrupts | awk '{print $1, $NF}' | head -20
  2. ethtool -l eth0                      # 确认网卡支持几个队列
  3. ethtool -S eth0 | grep -iE 'drop|fifo'   # 有丢包说明队列不够
  4. cat /sys/class/net/eth0/queues/rx-0/rps_cpus   # 0 表示 RPS 未启用
复制代码

处置方式是开多队列并让 RPS 把收包分散到多核(例如 8 核写 ff),中断按队列绑到对应核,同时打开中断合并降低中断次数。注意 rps_cpus 与绑核要错开,把软中断和业务线程放到同一个核上会互相抢。

类型 B:NUMA 跨节点访问。 表现为 sy 偏高、延迟抖动大:
  1. numactl --hardware          # 看几个 node、每个 node 的 CPU 和内存
  2. numastat -p 28411           # 看进程的本地/远端内存命中情况
  3. cat /sys/kernel/mm/transparent_hugepage/enabled
复制代码

numastat 的 numa_miss 和 other_node 高就说明内存跨节点了。处置是用 numactl --cpunodebind=0 --membind=0 启动实例,或者直接在 BIOS 里关掉跨节点交织。THP 建议用 madvise 而不是 always,always 在有大内存池的场景下会让分配延迟抖动变得非常难看。

类型 C:SQL 本身。 这是最常见也最好治的。1.2 亿行表按时间范围聚合却没有合适索引时,最快的处置不是调内核,而是加一条覆盖索引:
  1. -- 改造前:全表 1.2 亿行扫描,耗时 48 秒
  2. SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS d, COUNT(*), SUM(amount)
  3. FROM t_order
  4. WHERE status = 1
  5. GROUP BY d;
  6. -- 加覆盖索引(含过滤列 + 覆盖列 + 排序列)
  7. ALTER TABLE t_order
  8.   ADD INDEX idx_status_created_amount (status, created_at, amount), ALGORITHM=INPLACE, LOCK=NONE;
复制代码

执行计划从 type=ALL 变成 type=range,rows_examined 从 1.2 亿降到 41 万。

加索引这件事有两个前提要先确认,否则容易把 CPU 问题换成写入问题:一是这条 SQL 的执行频率,一天只跑两次的报表加索引不划算,直接搬到离线库更合适;二是表的写入量,sys.schema_unused_indexes(MySQL)或 pg_stat_user_indexes 里已经有一堆没人用的索引时,先删冗余再加新,比单纯堆索引更有效。判断依据是"这条索引能不能被至少两个高频查询复用",不能就说明设计还有更好的写法。

另外,把整个排查过程固化成一段可反复执行的采集脚本,比每次临时敲命令靠谱得多,尤其在出故障时手忙脚乱很容易漏步骤:
  1. #!/bin/bash
  2. # cpu_probe.sh <pid> —— 一次采集,落盘留证
  3. PID=$1; OUT=/tmp/cpu_probe_$(date +%Y%m%d_%H%M)
  4. mkdir -p $OUT
  5. vmstat 1 30                    > $OUT/vmstat.txt
  6. mpstat -P ALL 1 30             > $OUT/mpstat.txt
  7. iostat -x 1 30                 > $OUT/iostat.txt
  8. top -H -b -n 1 -p $PID         > $OUT/top_threads.txt
  9. numastat -p $PID               > $OUT/numastat.txt
  10. cat /proc/interrupts           > $OUT/interrupts.txt
  11. perf record -F 99 -g -p $PID -- sleep 30 && perf script > $OUT/perf.out
  12. echo "采集完成,输出目录:$OUT"
复制代码

步骤 6:验证,别看感觉看数字

治理前后各用同一组命令抓 30 秒数据,做成可对比的表:

指标治理前治理后采集方式
CPU 使用率94%38%mpstat 合计 %idle 反推
softirq 占比 si18%1%mpstat -P ALL
运行队列 r413vmstat 1 30 均值
每秒上下文切换38200042000vmstat cs 列
慢 SQL 扫描行数121400000412000EXPLAIN ANALYZE
业务 P951240ms46ms应用侧 APM


四、实操检查清单


  • [ ] 高负载第一个小时不申请加核,先跑 vmstat 1 30 + mpstat -P ALL 1 30 定性
  • [ ] 记录 wa 和 %util,排除存储因素后再谈 CPU
  • [ ] 用 mpstat -P ALL 确认热点是"集中在几个核"还是"均匀摊开"
  • [ ] 用 top -H -b -n 1 -p <pid> 与 pidstat -t 交叉锁定燃烧线程
  • [ ] 用 performance_schema.threads.THREAD_OS_ID(PG 用 pid)把线程翻译成会话
  • [ ] 映射查不到结果时,立即转向排查后台线程(purge / page cleaner / log writer)
  • [ ] 用 perf record -F 99 -g -p <pid> -- sleep 30 生成火焰图,只看宽度不看深度
  • [ ] si 高时检查 ethtool -l、ethtool -S | grep drop、rps_cpus
  • [ ] sy 高且 NUMA 机器上跑 numastat -p <pid>,看 numa_miss
  • [ ] THP 统一用 madvise,内存池场景不要用 always
  • [ ] 采用 CPU 节能策略的主机,把 scaling_governor 固定为 performance
  • [ ] 治理后必须用同一组命令复采一遍,用表格对比而不是用"感觉好多了"结案
  • [ ] 结论和采集数据一并归档到变更记录,方便下次同类故障对照


几个容易踩的坑

坑一:拿进程级 %CPU 下结论。 top 那一列是所有线程的平均值,8 核里烧 3 个核也只显示 37%。必须先降维到线程和核。

坑二:把 si 当成数据库计算。 软中断是内核在替网卡干活,这部分工作量的增长往往来自 QPS 上升或网络小包变多,与 SQL 复杂度无关。这时加 CPU 核数只会让软中断分布更散,热点核依然打满。

坑三:虚拟机上的 %steal 被忽略。 云主机的 CPU 争抢体现为 st 列。st 超过 5% 时,所有性能结论都不可靠,先找云厂商要专属宿主机或者调实例规格,别在客户机里瞎调。

坑四:top 的刷新周期太长漏掉尖峰。 默认 3 秒刷新一次,对秒级尖峰基本看不见。改用 pidstat -u 1 或 sar -u 1,必要时上 perf 采样。

坑五:只调内核参数不动 SQL。 排查里十次有七次的根因是某条 SQL 扫描行数暴涨。Handler_read_next、rows_examined 这两个数字花十秒就能查,比调内核参数划算得多,应该放在最前面。

坑六:认为 wa 高就等于磁盘坏。 wa 高也可能只是某个大查询把存储打满,或者是内存不足导致频繁刷脏。先确认是"读慢"还是"写慢",再决定是加内存、调刷脏参数还是换盘。

坑七:改完直接收工,不复采。 没有前后对比数据,下次同类问题出现时就没法复用结论,等于白排查一次。
免责申明1、欢迎访问本站,本文内容及相关资源来源于网络,版权归版权方所有!本站原创内容版权归本站所有,请勿转载!
2、本文内容仅代表作者观点,不代表本站立场,作者自负,本站资源仅供学习研究,请勿非法使用,否则后果自负!请下载后24小时内删除!
3、本文内容,包括但不限于源码、文字、图片等,仅供参考。本站不对其安全性,正确性等作出保证。但本站会尽量审核会员发表的内容。
4、如本帖侵犯到任何版权问题,请立即告知本站 ,本站将及时删除并致以最深的歉意!客服邮箱:admin@dbabbs.com
您需要登录后才可以回帖 登录 | 用户注册

本版积分规则

QQ|Archiver|小黑屋|DBA论坛中国 ( 鲁ICP备20017503号-2 )

GMT+8, 2026-9-25 06:40 , Processed in 0.022176 second(s), 10 queries , MemCached On.

Powered by Discuz! X5.0

© 2001-2026 Discuz! Team.

快速回复 返回顶部 返回列表