高级后端 · CPU 与调度分析

CPU 高先分账再定位: us/sy/wa/st 各指向不同病灶 — run queue 才是饱和的真话筒, 平均 CPU 是最大的骗子

top 的 %Cpu(s): 先把 100% 拆成账本 us 65% 用户态 sy 20% wa 10% st 3% · hi/si 2% (图右两小条) us 高 → 业务计算: JSON / 正则 / 加密 / GC / 算法 sy 高 → syscall: 网络 / 文件 IO / 线程调度 (us=20 sy=60 必查) wa 高 → CPU 在等 IO, 转交 iostat 排查 (不是 CPU 瓶颈) st 高 → steal: 宿主机抢走你的 CPU, 不是应用的问题 hi/si: 硬/软中断 — 网卡流量大的机器 si 可能很高 id: idle 空闲 — 注意 wa 不算进 id, 算"忙" Run Queue: 饱和的真话筒 r = 40 runnable 等 CPU CPU0 100% CPU1 5% CPU2 3% CPU3 4% CPU4 6% CPU5 2% CPU6 3% CPU7 2% 8 核总体仅 8% 单核热点: event loop / GIL / 锁串行 → 平均 CPU 骗人 cs 突涨 20k→500k/s: 线程过多 / 锁竞争 / 频繁唤醒 CPU 高的标准排查链 — 一层层缩小, 不要跳步 ① 现象确认 uptime / top 1m>5m>15m 压力上升 ② 哪个进程 pidstat -u 1 top 按 P 排序 ③ 单核 or 全部 mpstat -P ALL 1 top 里按 1 看每核 ④ 哪类时间 us / sy / wa / st 分账定方向 ⑤ 哪个函数 perf / pprof / py-spy runtime 画像 ⑥ 为什么 发布? payload 膨胀? 对齐时间线找根因 接力棒: Linux 工具告诉你"哪一层在忙", runtime profiling 才告诉你"哪段代码" — 两层缺一不可 错误示范: CPU 高 → 扩机器 → 完事 (根因没动, 流量再涨一倍时更贵) vmstat 速查 r 可运行队列 b 等 IO (D 状态) si/so swap 进出 cs 上下文切换 事故现场: 32 核 CPU 8%, 服务却卡死 mpstat: CPU7 = 100%, 其余 ≈2% — 单线程 event loop 在烧 top 按 1 / mpstat -P ALL 1 才能看见, 全机平均永远显示"健康"

%CPU 分账四象限

  • • us 高: 业务计算 — JSON/正则/加密/GC
  • • sy 高: 内核 — syscall/网络/调度 (us20+sy60 必查)
  • • wa 高: CPU 在等 IO — 转交 iostat
  • • st 高: 宿主机偷 CPU — 找云厂商, 别改代码

调度层两个仪表

  • • vmstat r: runnable 堆积 = CPU 饱和真话筒
  • • vmstat b: D 状态堆积 = IO 卡住
  • • load = R + D, 所以 load 高 ≠ CPU 高
  • • cs 突涨: 线程过多 / 锁竞争 / 过度并发

定位漏斗

  • • top → pidstat (进程) → mpstat (单核) → 分账
  • • perf / pprof / py-spy 接力找函数
  • • 单核 100% 是 event loop/GIL/锁的指纹
  • • 最后一步永远是对齐发布与 payload 时间线

💡 一句话理解

CPU 分析像超市收银台: %Cpu(s) 是把收银员的 100% 时间拆成"扫码(us)、对账找主管(sy)、等顾客掏钱包(wa)、被隔壁店借走(st)"四本账; run queue 是门口排队的顾客数 — 收银员忙不忙看利用率, 队排多长才是顾客真正的痛。而 top 平均值像"全店平均客流": 一号窗口排爆了, 平均下来还是"健康" — 所以定位永远从"分账 + 按核看"开始, 再用 perf/pprof 把显微镜对准函数。

🧠 必知必会 必考 & 必会

uptime 三分钟读法
load average 的 1/5/15 分钟三个值是趋势仪: 1m>5m>15m 压力在爬升, 反之在恢复。20 10 3 说明刚刚突然出事。
$ uptime
# load average: 2.31, 1.94, 1.72 → 1m>5m>15m, 压力爬升中
# load average: 20, 10, 3     → 刚刚突然出事, 查 5 分钟内变更
Load = R + D
Linux load 统计"可运行任务 + 不可中断睡眠任务"。大量 D 状态 (等磁盘/NFS) 也能把 load 顶到几十, CPU 却很闲。
# load=30, 32 核, 不能直接说健康:
# vmstat r=2  b=28  wa=70%  → 28 个进程在等 IO, 是磁盘的事
%Cpu(s) 八字段
us sy ni id wa hi si st 各答一题: 业务多重 / 内核多重 / 等 IO 多久 / 被宿主机偷多少。
# us 业务 · sy 内核 · ni nice · id 空闲
# wa 等 IO · hi 硬中断 · si 软中断 · st 虚拟化偷走
进程状态 R/S/D/Z
top 的 S 列: R 在跑, S 可中断睡眠, D 不可中断 (通常等 IO, 最值得警觉), Z 僵尸 (父进程没 wait)。
$ ps -eo pid,stat,cmd | grep -E "D|Z"
# 大量 D → 磁盘/NFS 卡;  大量 Z → 父进程回收缺失
mpstat 单核瓶颈
全机平均会骗人, 单核不会。event loop、GIL、锁串行、IRQ 倾斜都会烧满一个核。
$ mpstat -P ALL 1
# CPU0 %usr=98  CPU1 10  CPU2 8 ... → 单核热点实锤
vmstat 的 r 与 b
r=运行+等 CPU 的任务数, 长期大于 2×核数即调度压力; b=阻塞在 IO 的任务数, 持续高指向磁盘/NFS。
$ vmstat 1
# r=40 (8核) → CPU 饱和排队;  b=25 wa=70 → IO 瓶颈
上下文切换两类
voluntary (cswch): 主动让出 — 等锁/等IO/sleep; nonvoluntary (nvcswch): 时间片用完被赶下 CPU — CPU 竞争激烈的信号。
$ pidstat -w 1
# cswch/s 巨高 → 大量阻塞等待;  nvcswch/s 高 → CPU 抢得凶
pidstat 五件套
比 top 更适合查应用: -u CPU、-r 内存与缺页、-d 磁盘、-w 切换、-t 线程粒度。
$ pidstat -p 1234 -durwt 1    # 一个进程五个维度, 1s 刷新
$ pidstat -t -p 1234 1        # 按线程看, 揪出 100% 的那个线程
线程爆炸指纹
/proc/PID/status 的 Threads 字段: 正常 100 突然 5000, 必然伴随 cs 暴涨与 P99 抖动 — 请求级建线程是元凶。
$ grep Threads /proc/1234/status
# Threads: 5000   ← 正常 100, 突增 50 倍不是好消息
strace -c 与 futex
CPU 不高但很慢时, strace -c 统计 syscall: futex 多 = 等锁, epoll_wait 多 = 等 IO, connect 卡 = 建连慢。
$ strace -c -p 1234
# futex  3800  92%  → 92% 时间在等锁, 继续 profile 锁
perf top / record
perf top -p PID 实时看函数热点; perf record -F 99 -g 采样后 perf report 看调用栈, 是火焰图的数据源。
$ perf record -F 99 -p 1234 -g -- sleep 30
$ perf report    # json.Marshal 45% / memcpy 20% ...
衔接 runtime 画像
pidstat 显示 Go 服务 CPU 700% (8 核用 7 核) 后, Linux 的活干完了 — 换 pprof 进 runtime 找哪段代码。
# pidstat: API 进程 750% → 进 pprof:
$ go tool pprof -top http://svc:6060/debug/pprof/profile?seconds=30
平均 CPU 会骗人
32 核平均 8% 可能藏着 CPU7=100%: 单线程 event loop、Python GIL、单队列。结论必须建立在"按核看"之后。
# 错: "平均 8%, CPU 没问题"  → 服务照样卡
# 对: mpstat -P ALL 1 → CPU7=100% → 单核热点定位

🏭 生产实战 real world

场景 1 · 32 核 CPU 8% 却响应超时: mpstat 揪出单核 100%

新人看总 CPU 8% 直接排除 CPU 嫌疑。真凶是单线程组件 (event loop / GIL / 全局锁) 烧满一个核, 其他核全程围观。

# 第一步: 按核看, 不看平均
$ mpstat -P ALL 1
# CPU   %usr  %sys  %idle
# 0     98     1      1      ← CPU0 满血
# 1~31   2     0     97      ← 其余围观

# 第二步: 找到烧 CPU0 的线程
$ pidstat -t -p 1234 1 | sort -k8 -rn | head -3
# tid 4482 %usr 97.8  ← 单线程热点
# 第三步: 对该线程做 runtime 画像 (py-spy / perf -t)

结论指向单线程架构 (asyncio event loop / GIL / 单 reactor), 解法是拆分或多进程, 不是加机器。

场景 2 · us 高: 从 pidstat 到 pprof 定位 JSON 序列化

用户态烧满说明在"算"。Linux 层先锁定进程, runtime 层锁定函数, 两棒接力。

$ pidstat -u 1 | sort -k8 -rn | head -3
# 12:34:56  UID  PID  %usr  %system  %CPU  Command
#           1000 1234  690    12      702   api-server   ← 8核用7核

$ go tool pprof -top http://127.0.0.1:6060/debug/pprof/profile?seconds=30
#      flat  flat%   sum%        cum%
#    12.3s   45.2%  45.2%  encoding/json.Marshal
#     3.1s   11.4%  56.6%  encoding/json.encodeState.object

顺藤摸瓜发现接口返回 2MB 大 JSON → 加字段裁剪 + 分页, CPU 降 40%。

场景 3 · sy 高: strace -c 发现 connect 风暴

us=20 sy=60 的机器值得彻查: 内核在替你干大量活。syscalls 统计一锤定音。

$ strace -c -f -p 1234 -o /tmp/st.txt; sleep 10; head -10 /tmp/st.txt
# % time  calls  syscall
#  41.2  18342  connect      ← 每秒 1800 次建连, 短连接风暴
#  30.8  15200  close
#  12.1   6100  sendto

# 根因: HTTP client 每次 new, 没有连接池
# 修复: 复用 http.Client + Transport MaxIdleConnsPerHost=100
# 效果: sy 60% → 8%, connect 调用归零

注意: 高频 syscall 进程别直接 strace -p 跟踪 (会拖慢), 用 -c 统计模式。

场景 4 · wa 高: CPU 的锅还是磁盘的锅

wa 只说"CPU 在等 IO", 不是磁盘利用率。转交 iostat/pidstat 拿到真凶 (详见 IO 篇)。

$ vmstat 1
# r=2  b=25  wa=65%   ← b 高: 25 个进程卡在不可中断 IO

$ iostat -xz 1
# await=500ms  aqu-sz=80   ← 设备排队爆炸

$ pidstat -d 1 | sort -k5 -rn | head -3
# 09:10  PID  kB_rd/s  kB_wr/s  Command
#        5678     0    409600  log-agent   ← 凶手是日志 agent

链路: wa/b (vmstat) → await/aqu-sz (iostat) → 凶手进程 (pidstat -d)。

场景 5 · st 高: 你的 CPU 被宿主机偷走了

虚拟机/CNI 环境里 st 高说明宿主机过载, 应用怎么优化都没用, 应当换宿主或升配。

$ mpstat -P ALL 1 | tail -1
# all   %usr  %sys  %iowait  %idle  %steal
#       30     5      2       43     20     ← 20% CPU 被偷

# 判断: st 持续 > 5% 开始留意, > 10% 找云厂商/换宿主机
# 排除项: 不是应用问题, pprof 里也看不到"偷走的时间"
# 佐证: 同宿主机其他 VM 同时变慢 (云厂商口径)

把 st 加进基础设施告警, 迁移后 CPU 同样的业务延迟立刻恢复。

场景 6 · cs 暴涨: 每请求建线程的调度灾难

QPS 没变, cs 从 20k/s 涨到 500k/s — 线程数失控是第一嫌疑。

$ vmstat 1
# r=45  cs=500000   ← 8 核扛 50 万次切换/秒

$ grep Threads /proc/1234/status
# Threads: 5200    ← 正常 120

# 代码审查发现: 每个请求 exec.Command + new thread pool
# 修复: 全局 worker pool, 固定 64 worker + 有界队列
# 效果: cs 回落 2 万/s, P99 1.2s → 90ms

线程越多不一定越快: cache miss + 切换开销在核数之上惩罚你。

场景 7 · load 30 但 CPU 空闲: D 状态指向 NFS 卡死

load 高 CPU 不高, 先查 b 列和 D 状态进程, 不要按"CPU 瓶颈"扩容。

$ uptime  →  load average: 30, 28, 26
$ vmstat 1
# r=2  b=28  wa=70%   ← 全在等 IO

$ ps -eo pid,stat,wchan,cmd | awk '$2 ~ /D/'
# 7891 D  rpc_wait  tar -czf /mnt/nfs/backup.tgz  ← NFS 挂了

# 处置: 换本地盘备份 + 给 NFS 挂 soft,timeo=100 重试参数
# load 随即回落 — 它统计的是 D 状态, 不是 CPU

kill -9 都杀不掉的 D 状态进程, 只能等 IO 返回或重启主机 — 预防靠监控挂载健康。

场景 8 · pidstat -t 热点线程 → py-spy 线程栈对齐

进程级只说"谁在烧", 线程级才知道"哪个线程在烧", 再用 runtime dump 对齐到代码。

$ pidstat -t -p 1234 1 | sort -k8 -rn | head -3
# tid 4482  %usr 97.8

# Python: 直接看进程在干嘛
$ py-spy dump --pid 1234
# Thread 4482 (idle): "worker-7"
#     regex.py:88 (re.compile)   ← 每次循环重编译正则

# 修复: 预编译进模块常量 → CPU 97% → 12%

Linux 视角 (tid) 与 runtime 视角 (goroutine/thread name) 的映射是排障关键一步。

场景 9 · perf record + 火焰图定位 memcpy 风暴

pprof 只看得到 runtime 内, perf 能看到内核态。火焰图宽块 = 热点候选。

$ perf record -F 99 -p 1234 -g -- sleep 30
$ perf script > out.stacks
# 用 FlameGraph 工具生成: stackcollapse-perf.pl | flamegraph.pl > cpu.svg

# 火焰图最宽的三块:
#   __memmove_avx_unaligned_erms  38%   ← 大 payload 拷贝
#   json.Marshal                  27%
#   tcp_sendmsg                   12%

# 根因: 一次返回 8MB, 拷贝 3 次 (decode/encode/send)
# 修复: 加分页上限 → 拷贝量降 95%, CPU 降一半

火焰图横轴是 CPU 样本占比, 不是时间轴 — 找最宽的, 不是最"深"的。

场景 10 · sar 历史回放: 昨晚 3 点的 CPU 尖峰是谁

现场已经恢复, top 一无所获。sysstat 历史数据是唯一的目击证人。

$ sar -u -f /var/log/sa/sa26 | awk '$1 ~ /^03:/'
# 03:00:01  %usr  %sys  %iowait  %idle
# 03:00:01   82     8      2       8     ← 尖峰开始
# 03:10:01   15     2      1      82     ← 自动恢复

$ sar -q -f /var/log/sa/sa26 | awk '$1 ~ /^03:/'
# loadq 也在 03:00 跳升, 与备份 cron 时间对齐
# 修复: crontab 里 backup 加 nice -n 19 + ionice -c3, 限速 rsync

生产机常备: 打开 sysstat 采集 (ENABLED="true" in /etc/default/sysstat), 本地历史救命。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · load 大于核数就断定 CPU 爆 — load 统计 R+D 两个队列, 磁盘卡也能顶满. 原因: 机械背"load>核数"口诀. 正解: vmstat r/b 分开看。
# 错: load=30, 32核 → "快爆了"      # 其实 b=28 在等磁盘
# 对: vmstat 1 → r=2 b=28 → 查 IO
坑 2 · 只看全机平均 CPU — 平均 8% 藏着 CPU7=100%. 原因: 单核热点被 32 核稀释. 正解: mpstat -P ALL 1 / top 按 1。
# 错: top 顶部 "Cpu(s): 8.0% us" 就翻篇
# 对: top 按 1 → 逐核查看
坑 3 · 只看进程不看线程 — 进程 700% 只知道"在烧", 不知道谁烧. 原因: 线程粒度被跳过. 正解: pidstat -t / top -H。
# 错: pidstat -u 1 → 1234 700% → 没线索了
# 对: pidstat -t -p 1234 1 → tid 4482 97%
坑 4 · 虚拟机上忽略 st — st=20% 时应用层怎么优化都白搭. 原因: 宿主机超卖偷走 CPU. 正解: st>10% 找云厂商/迁移。
# 错: 继续调代码优化 P99     # 被偷的 20% 回不来
# 对: mpstat 看 %steal → 工单 + 换宿主
坑 5 · 把 wa 当磁盘利用率 — wa 只是"CPU 等 IO 的时间占比", 不度量磁盘. 原因: 概念混淆. 正解: 磁盘看 iostat await / %util / aqu-sz。
# 错: "wa 50% = 磁盘饱和一半"
# 对: iostat -xz 1 → await/aqu-sz 判定磁盘状态
坑 6 · cs 高直接定罪锁 — cs 高也可能是频繁 timer/唤醒/正常 GC. 原因: 单指标定罪. 正解: 结合 mutex profile / strace futex 佐证。
# 错: cs 50万/s → "锁竞争!" → 改锁 → 白改
# 对: strace -c 看 futex 占比 + mutex profile 交叉验证
坑 7 · 只看 snapshot 不看趋势 — top 某一秒 100% 可能是瞬时 GC. 原因: 现场照片≠监控录像. 正解: vmstat 1 连看 + sar 历史。
# 错: top 一眼 100% → 误判饱和
# 对: vmstat 1 10 看连续 + sar 对齐历史
坑 8 · pidstat 不带 interval — pidstat -u 不加 1 输出开机以来均值, 全是历史水分. 原因: 用法不对. 正解: 永远带 interval [count]。
# 错: pidstat -u          # 开机均值, 掩盖当下
# 对: pidstat -u 1 5      # 逐秒 5 次
坑 9 · 忽略 nvcswch — 被动切换高说明 CPU 供不应求, 却没人看. 原因: 只认识 cswch. 正解: pidstat -w 两列都看。
# 错: 只看 cswch/s (等锁多) 就下结论
# 对: nvcswch/s 也高 → CPU 竞争激烈, 该扩容了
坑 10 · 单线程瓶颈去扩机器 — event loop 烧一个核, 加 100 核也只提升一个核. 原因: 架构串行点没识别. 正解: 拆分/多进程/多 reactor。
# 错: 8核→64核, 吞吐不变 (还是 1 核上限)
# 对: 单核热点 → 水平分片或多进程
坑 11 · 线上裸 strace 高频进程 — strace 每次系统调用都停进程, 高频 syscall 直接把服务拖死. 原因: ptrace 开销. 正解: 统计用 -c, 或改用 eBPF (syscount)。
# 错: strace -p 1234 (生产 hot path)  # 服务卡死
# 对: strace -c -p 1234 (10s) 或 syscount-bpfcc
坑 12 · perf -F 9999 高频采样 — 采样频率拉满, 自身开销 20%+, 结果反而失真. 原因: 误解"越准越好". 正解: -F 99 足够统计意义。
# 错: perf record -F 9999 -p PID
# 对: perf record -F 99 -p PID -g -- sleep 30
坑 13 · perf record 忘加 -g — 只有函数自身占比, 没有调用栈, 定位不了"谁调它". 原因: 参数遗漏. 正解: 始终 -g 记录调用链。
# 错: perf record -F 99 -p PID      # 无栈, 难归因
# 对: perf record -F 99 -g -p PID
坑 14 · perf report 全是地址符号 — 显示 0x7f... 无法读. 原因: 缺调试符号. 正解: 装对应版本 debug symbols 或 --symfs。
# 错: 78.2%  0x00007f8a2c1e4f10  [unknown]
# 对: apt install libc6-dbg + 重跑 perf report
坑 15 · 容器里看宿主机 CPU — top 显示全机 64 核, 容器 limit 2 核, 误判"CPU 很空". 原因: cgroup 视角混淆. 正解: 看 /sys/fs/cgroup/cpu.max 与 throttling。
# 错: 宿主 CPU 10% → "资源充足"
# 对: cat cpu.stat → nr_throttled 增长 = 容器被限流
坑 16 · 用 nice 当性能优化 — 调 nice 不增加 CPU 总量, 只是重新分配. 原因: 误解调度语义. 正解: 真缺就扩容, nice 只用于削峰让路。
# 错: nice -n -20 ./api   # 非root无效且不创造算力
# 对: 备份任务 nice -n 19 + ionice 给业务让路
坑 17 · 网卡中断没绑核 — 大流量机器 si 全砸在 CPU0. 原因: IRQ affinity 默认集中. 正解: 开启多队列 + irqbalance。
# 错: CPU0 %soft 90%, 其余空闲 → 误判"单核业务瓶颈"
# 对: 确认 irqbalance 运行 + RSS 队列分散
坑 18 · 把 GC CPU 当业务热点 — pprof 里 runtime.gcBgMark 占 30% 就去改业务算法. 原因: 没追分配源. 正解: 看 allocs profile 找分配大户。
# 错: 优化 gcBgMarkWorker 函数本身 (不可优化)
# 对: allocs profile → json.Decode 65% → 流式解析
坑 19 · pprof 只采 30 秒错过偶发 — 偶发尖峰 30s 窗口采不到. 原因: 采样窗太短. 正解: 持续采集 (pyroscope / pprof 带 label) + 报警触发时抓。
# 错: 每次 profile -- sleep 30, 一年遇不到尖峰
# 对: P99 告警 webhook 自动抓 profile 入对象存储
坑 20 · CPU 高直接扩机器 — payload 膨胀导致的 CPU 高, 扩容只延后爆炸. 原因: 跳过漏斗⑤⑥. 正解: 走完 perf/pprof → 时间线归因再扩容。
# 错: CPU 100% → 加 4 台 → 下月流量×2 再爆
# 对: pprof: json 45% ← response 20KB→8MB ← 分页参数失效