|
|
马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?用户注册
×
一、具体的问题
上周三,一台跑着 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 秒,一次性把定性做完:
- # CPU 分类 + 运行队列(r 列含 D 状态进程,别只看 CPU 数字)
- vmstat 1 30
- # 每核分布:确认是不是热点集中在几个核上
- mpstat -P ALL 1 30
- # 顺带排除 IO:如果 wa 很高,CPU 根本不是本次的主线
- iostat -x 1 30 | grep -E 'Device|sd|nvme'
复制代码
这一步的产出是一句定性结论。以本文这台机器为例:si 平均 18%、mpstat 显示 8 核里有 2 个核的 %soft 打到 90% 以上,而 iostat 的 %util 只有 12%、await 0.8ms——存储是干净的,问题在软中断,不在数据库。
步骤 2:定位到具体线程
- # 找到数据库进程
- pgrep -a mysqld
- # 按线程看 CPU,H 显示线程;-b 批处理模式便于记录
- top -H -b -n 1 -p 28411 | head -30
- # 线程级 CPU 时间,1 秒采样,找出持续燃烧的线程
- pidstat -t -p 28411 1 30
复制代码
假设锁定到线程 28439 和 28440 长期占据 95% 以上。注意 pidstat -t 里的 TID 与 top -H 的 PID 列是同一个东西,两个命令要交叉验证,别一个用进程号一个用线程号对不上。
步骤 3:把 OS 线程映射回数据库会话
MySQL:
- SELECT t.THREAD_ID,
- t.PROCESSLIST_ID,
- t.THREAD_OS_ID,
- p.USER,
- p.HOST,
- p.DB,
- p.COMMAND,
- p.TIME,
- LEFT(p.INFO, 100) AS current_sql
- FROM performance_schema.threads t
- LEFT JOIN information_schema.processlist p
- ON p.ID = t.PROCESSLIST_ID
- WHERE t.THREAD_OS_ID IN (28439, 28440)
- ORDER BY t.THREAD_ID;
复制代码
PostgreSQL 则把 THREAD_OS_ID 换成 pid:
- SELECT pid, usename, application_name, state,
- now() - query_start AS dur, left(query, 100)
- FROM pg_stat_activity
- WHERE pid IN (28439, 28440);
复制代码
这里有个容易忽略的点:如果查出来是空,说明这两个线程不是会话线程,而是后台线程——InnoDB 的 purge、page cleaner、log writer 都能烧 CPU。那我前面"是数据库在算"的推断就要改,得先解决后台线程为什么忙(常见是 purge 积压或刷脏压力)。本文这台机器查出来是两条业务会话,其中一条在跑一张 1.2 亿行表的聚合查询,另一条在等待它持有的锁做刷新——到这里根因基本落地了。
步骤 4:拿到调用栈,确认它到底烧在哪
- # 实时看热点函数(不落盘,适合快速确认)
- perf top -p 28411 -g
- # 采样 30 秒并生成火焰图(99Hz,避免采样本身成为负担)
- perf record -F 99 -g -p 28411 -- sleep 30
- perf script > /tmp/out.perf
- git clone --depth 1 https://github.com/brendangregg/FlameGraph
- /tmp/FlameGraph/stackcollapse-perf.pl /tmp/out.perf > /tmp/out.folded
- /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 和索引,性价比最高。
- # 交叉验证:扫描行数指标
- mysql -e "SHOW GLOBAL STATUS LIKE 'Handler_read%'"
- # 如果 Handler_read_next 在 30 秒内涨了上亿,基本可以收工了
复制代码
步骤 5:三类高负载的处置手法
类型 A:软中断(si 高)。 先看是不是单队列网卡把中断全压在 CPU0 上:
- cat /proc/interrupts | awk '{print $1, $NF}' | head -20
- ethtool -l eth0 # 确认网卡支持几个队列
- ethtool -S eth0 | grep -iE 'drop|fifo' # 有丢包说明队列不够
- cat /sys/class/net/eth0/queues/rx-0/rps_cpus # 0 表示 RPS 未启用
复制代码
处置方式是开多队列并让 RPS 把收包分散到多核(例如 8 核写 ff),中断按队列绑到对应核,同时打开中断合并降低中断次数。注意 rps_cpus 与绑核要错开,把软中断和业务线程放到同一个核上会互相抢。
类型 B:NUMA 跨节点访问。 表现为 sy 偏高、延迟抖动大:
- numactl --hardware # 看几个 node、每个 node 的 CPU 和内存
- numastat -p 28411 # 看进程的本地/远端内存命中情况
- 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.2 亿行扫描,耗时 48 秒
- SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS d, COUNT(*), SUM(amount)
- FROM t_order
- WHERE status = 1
- GROUP BY d;
- -- 加覆盖索引(含过滤列 + 覆盖列 + 排序列)
- ALTER TABLE t_order
- 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 里已经有一堆没人用的索引时,先删冗余再加新,比单纯堆索引更有效。判断依据是"这条索引能不能被至少两个高频查询复用",不能就说明设计还有更好的写法。
另外,把整个排查过程固化成一段可反复执行的采集脚本,比每次临时敲命令靠谱得多,尤其在出故障时手忙脚乱很容易漏步骤:
- #!/bin/bash
- # cpu_probe.sh <pid> —— 一次采集,落盘留证
- PID=$1; OUT=/tmp/cpu_probe_$(date +%Y%m%d_%H%M)
- mkdir -p $OUT
- vmstat 1 30 > $OUT/vmstat.txt
- mpstat -P ALL 1 30 > $OUT/mpstat.txt
- iostat -x 1 30 > $OUT/iostat.txt
- top -H -b -n 1 -p $PID > $OUT/top_threads.txt
- numastat -p $PID > $OUT/numastat.txt
- cat /proc/interrupts > $OUT/interrupts.txt
- perf record -F 99 -g -p $PID -- sleep 30 && perf script > $OUT/perf.out
- echo "采集完成,输出目录:$OUT"
复制代码
步骤 6:验证,别看感觉看数字
治理前后各用同一组命令抓 30 秒数据,做成可对比的表:
| 指标 | 治理前 | 治理后 | 采集方式 | | CPU 使用率 | 94% | 38% | mpstat 合计 %idle 反推 | | softirq 占比 si | 18% | 1% | mpstat -P ALL | | 运行队列 r | 41 | 3 | vmstat 1 30 均值 | | 每秒上下文切换 | 382000 | 42000 | vmstat cs 列 | | 慢 SQL 扫描行数 | 121400000 | 412000 | EXPLAIN ANALYZE | | 业务 P95 | 1240ms | 46ms | 应用侧 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 高也可能只是某个大查询把存储打满,或者是内存不足导致频繁刷脏。先确认是"读慢"还是"写慢",再决定是加内存、调刷脏参数还是换盘。
坑七:改完直接收工,不复采。 没有前后对比数据,下次同类问题出现时就没法复用结论,等于白排查一次。 |
|