1. 从一次线上告警说起:为什么CPU优化不能只看使用率
凌晨两点被电话叫醒,监控面板上一台核心业务服务器的CPU使用率飙到了95%,但业务方反馈的却是"接口响应变慢,偶尔超时"。登录机器一看,top显示的us(用户态)并不高,反而是sy(内核态)和wa(IO等待)在反复横跳。这个场景我遇到过太多次了,也是我想把CPU性能优化单独拎出来聊透的原因——CPU使用率高不等于CPU是瓶颈,CPU使用率低也不代表CPU没问题。
很多人做性能排查的第一反应就是敲top,看到%CPU高就加机器,看到低就甩锅给网络或数据库。这套逻辑在简单场景下能蒙对,但一旦遇到多核、超线程、容器化、NUMA架构这些现代部署环境,就会彻底失灵。CPU性能优化真正要回答的是三个问题:CPU的时间到底花在哪了?是谁在花?花得值不值?这三个问题分别对应观测、归因、优化三个阶段,缺一不可。
这篇内容适合谁看?如果你已经能熟练使用top、ps这类基础命令,但在面对"CPU高但找不到凶手""负载高但CPU空闲""容器里CPU被限流却查不出原因"这类问题时感到无从下手,那这篇就是写给你的。我会从CPU时间的构成讲起,把上下文切换、运行队列、调度延迟、CPU缓存、NUMA这些容易被忽略的维度一个个拆开,配上可直接复现的命令和真实排查链路。全程不堆砌术语,每个参数我都会解释它为什么重要、怎么读、读到异常值之后下一步该干什么。
需要提前说明的是,CPU优化没有银弹,任何脱离具体业务负载特征的调优都是耍流氓。计算密集型和IO密集型应用的优化方向完全相反,延迟敏感型和吞吐优先型的取舍也截然不同。所以我在讲每个工具和参数时,都会带上"什么场景下该关注它"这个前提,你可以对照自己的业务场景按需取用。
2. CPU时间的解剖学:us、sy、wa、si、st到底在说什么
2.1 用户态与内核态的边界在哪里
top输出里那一行%Cpu(s): 12.3 us, 8.7 sy, 0.0 ni, 75.2 id, 3.1 wa, 0.5 hi, 0.2 si, 0.0 st,每个字段背后都是一类CPU时间开销。us是用户态时间,也就是你的应用程序代码在CPU上执行指令消耗的时间;sy是内核态时间,是应用程序通过系统调用陷入内核后,内核代表应用执行的时间。这两者的比例是判断性能问题性质的第一把钥匙。
一个健康的、以业务逻辑为主的服务,us应该显著高于sy。如果sy持续偏高,通常意味着几件事:系统调用过于频繁(比如大量小文件读写、频繁的网络收发)、锁竞争导致内核自旋、或者是内存分配/回收压力大触发了频繁的页表操作。我见过一个案例,某服务sy占到40%,最后定位到是日志组件每条日志都调用write系统调用且没做缓冲,改成批量写入后sy直接降到5%以下。
ni是nice值调整过的进程消耗的用户态时间,一般生产环境很少主动调nice,看到非零值要留意是不是有人手动干预过调度优先级。id是空闲时间,wa是IO等待时间——注意,wa不是CPU在干活,而是CPU空闲着等IO完成,它高说明瓶颈在磁盘或网络存储,不在CPU。hi和si分别是硬中断和软中断处理时间,网卡流量大、中断亲和性配置不合理时这两个值会飙升。st是虚拟化环境下的"被偷走的时间",也就是宿主机把物理CPU分给了别的虚拟机,你的虚拟机明明想跑却拿不到CPU,这个值在云主机上尤其要盯紧。
2.2 用vmstat和pidstat把时间拆到进程级
top给的是全局视角,要定位到具体进程和线程,pidstat是更趁手的工具。pidstat -u 1能按秒输出每个进程的%usr、%system、%CPU,pidstat -w 1则输出上下文切换情况。我习惯在排查时同时开三个窗口:一个跑vmstat 1看全局趋势,一个跑pidstat -u -w -t 1看进程和线程级明细,一个跑top -H看线程级CPU占用。
vmstat里的r列(运行队列长度)和b列(阻塞进程数)是判断CPU是否真瓶颈的关键。r值持续大于CPU核心数,说明有任务在排队等CPU,这才是CPU不足的铁证。而cs列(上下文切换次数)和in列(中断次数)能帮你判断系统是不是在频繁做无用功。有一次我遇到cs每秒超过50万的情况,CPU使用率却不高,最后发现是一个多线程程序用了自旋锁,线程间疯狂切换却干不了活,改成互斥锁后cs降到几千。
这里有个经验值可以参考:上下文切换次数在几千到几万每秒属于正常范围,超过10万就要警惕,超过50万基本可以确定有锁竞争或线程模型设计问题。但这不是绝对标准,要结合业务QPS来看——高并发短连接服务本身切换就多,关键是看每次切换是否带来了有效的工作推进。
2.3 负载均值里的隐藏信息
uptime和top第一行的load average是三个数字,分别代表1分钟、5分钟、15分钟的平均负载。很多人以为负载就是CPU使用率,其实负载统计的是运行队列中等待的任务数加上不可中断睡眠的任务数。这意味着一个大量做磁盘IO的进程(处于D状态)也会推高负载,哪怕CPU完全空闲。
判断负载是否健康,要看它和CPU核心数的比值。单核机器负载1.0是满负荷,8核机器负载8.0才是满负荷。但更关键的是看三个时间窗口的趋势:如果1分钟负载远高于15分钟负载,说明负载在快速上升,可能是突发流量;如果15分钟负载持续高于核心数,说明系统长期过载,需要扩容或优化。我一般会结合vmstat的r列一起看,如果r列不高但负载很高,那基本就是IO阻塞导致的,要去查磁盘。
3. 上下文切换:那个最容易被忽视的性能杀手
3.1 自愿切换与非自愿切换的区别
上下文切换分两种:自愿切换(voluntary)和非自愿切换(involuntary)。自愿切换是进程主动让出CPU,比如等待IO、等待锁、调用sleep;非自愿切换是进程还没干完活就被调度器强行剥夺CPU,通常是因为时间片用完或有更高优先级的任务抢占。pidstat -w输出里的cswch/s和nvcswch/s分别对应这两类。
自愿切换多,说明进程在频繁等待资源,瓶颈可能在IO或锁;非自愿切换多,说明CPU竞争激烈,进程想跑却排不上队。我排查过一个Java服务,nvcswch/s高达每秒8万,原因是线程数配置过多(开了2000个线程),而CPU只有8核,大量线程在争抢CPU时间片。把线程池降到200后,非自愿切换降到几千,吞吐量反而提升了30%。这个案例说明一个反直觉的道理:线程不是越多越好,超过CPU处理能力的线程只会增加调度开销。
3.2 用perf和ftrace追踪切换源头
光知道切换多还不够,得知道是谁在切换、为什么切换。perf sched系列命令能给出调度延迟和切换统计。perf sched record采集一段时间后,perf sched latency会输出每个进程的调度延迟——也就是进程从就绪到真正拿到CPU的等待时间。如果某个进程的平均调度延迟超过几毫秒,说明它饿得厉害,要么是优先级太低,要么是CPU被别的任务占满了。
更底层的可以用ftrace的sched_switch事件,它能记录每一次切换的"前一个进程→后一个进程"以及切换原因。命令是echo 1 > /sys/kernel/debug/tracing/events/sched/sched_switch/enable,然后读trace文件。这个输出量很大,一般配合perf或trace-cmd做聚合分析。我通常只在定位到某个具体进程后才用这一层,因为全量采集对生产环境影响不小。
提示:在生产环境用
perf和ftrace前,务必先在测试环境验证开销。perf record默认采样频率下对CPU的额外占用通常在1%~5%,但ftrace全量事件可能更高,低峰期操作更稳妥。
3.3 减少上下文切换的实操手段
减少上下文切换有几个方向。第一是控制线程/进程数量,让并发度和CPU核心数匹配,IO密集型可以适当放大但别失控。第二是用协程或异步IO替代阻塞式多线程,把"等待"这件事从线程切换变成事件回调,Go的goroutine、Java的虚拟线程都是这个思路。第三是调整调度策略,对延迟敏感的任务用SCHED_FIFO或SCHED_RR实时调度策略,但用不好会导致系统卡死,必须谨慎。
还有一个容易被忽略的点是中断亲和性。网卡中断如果全打在CPU0上,会导致CPU0忙于处理中断而无法跑业务,其他核心却闲着。可以用/proc/interrupts查看中断分布,用echo <mask> > /proc/irq/<irq_num>/smp_affinity把中断分散到多个核心。配合irqbalance服务效果更好,但在高PPS场景下手动绑定往往比自动均衡更稳定。
4. 运行队列与调度延迟:CPU到底够不够用
4.1 运行队列长度才是CPU饱和的真信号
前面提过vmstat的r列,这里展开讲。运行队列长度表示"此刻有多少任务处于就绪态但还没拿到CPU"。如果r值长期大于CPU逻辑核心数,说明CPU确实不够用。但要注意,r是瞬时采样值,单次看到高值不用慌,要看持续趋势。我一般会连续观察1分钟,如果r值有超过一半的采样点都大于核心数,才判定为CPU饱和。
这里有个坑:容器环境下vmstat看到的是宿主机的运行队列,不是容器的。容器里跑vmstat,r列反映的是整台物理机的负载,可能容器本身很闲但宿主机很忙。要精确看容器的CPU压力,得用cgroup的cpu.stat文件,里面的nr_running和nr_uninterruptible才是容器维度的。cat /sys/fs/cgroup/cpu/<cgroup_path>/cpu.stat能看到这些数据,配合cpu.pressure(PSI指标)能更直观地判断CPU竞争程度。
4.2 调度延迟的量化方法
调度延迟是"任务就绪到实际运行"的时间差,它是比CPU使用率更贴近用户体验的指标。一个CPU使用率70%但调度延迟很低的系统,体验可能比使用率50%但调度延迟高的系统更好。测量调度延迟可以用perf sched latency,也可以用bpftrace写个简单的脚本统计。
比如用bpftrace统计每个进程的调度延迟分布:
bpftrace -e ' tracepoint:sched:sched_wakeup { @start[args->pid] = nsecs; } tracepoint:sched:sched_switch /@start[args->next_pid]/ { @latency[args->next_comm] = hist(nsecs - @start[args->next_pid]); delete(@start[args->next_pid]); }'这段脚本会输出每个进程被唤醒到实际运行的延迟直方图。如果某个进程的延迟集中在毫秒级以上,说明它被饿得厉害。我实测过一个数据库进程,平均调度延迟达到3ms,原因是它和一批批处理任务混部在同一批核心上,后来用cpuset把数据库隔离到独占核心,延迟降到50微秒以内,P99响应时间直接砍半。
4.3 CPU隔离与绑核的取舍
说到隔离,cpuset和isolcpus是两个常用手段。isolcpus在内核启动参数里指定,把某些核心从通用调度器中摘出来,只有显式绑定的任务才能用;cpuset是cgroup层面的,把一组进程限制在指定核心上运行。两者都能减少干扰,但代价是那些被隔离的核心如果没被充分利用就浪费了。
我的经验是:对延迟极度敏感的核心服务(比如交易撮合、实时风控),值得用isolcpus加绑核,牺牲一点资源利用率换确定性;对普通业务,用cpuset做软隔离就够了,别把核心锁死。另外绑核时要注意NUMA节点,跨NUMA访问内存的延迟是本地访问的1.5到2倍,绑核时尽量让CPU和它访问的内存处在同一NUMA节点,用numactl --hardware能看清拓扑。
5. CPU缓存与NUMA:那些看不见的延迟来源
5.1 缓存命中率为什么比CPU频率更重要
现代CPU的运算速度远超内存访问速度,一次L1缓存访问约1纳秒,一次主存访问约100纳秒,差了100倍。这意味着CPU大量时间不是在计算,而是在等数据。perf stat能直接给出缓存命中情况,perf stat -e cache-references,cache-misses,LLC-loads,LLC-load-misses跑一下,如果LLC(最后一级缓存)miss率超过10%,说明工作集太大,缓存装不下,性能会被内存带宽拖累。
优化缓存命中率的核心是提高数据局部性。数据结构设计上,把经常一起访问的字段放在相邻内存(结构体数组优于数组结构体);算法上,用分块(tiling)技术让数据块能塞进缓存;并发上,避免多个核心写同一缓存行导致"伪共享"(false sharing)。伪共享是个隐蔽的坑,两个线程分别修改同一缓存行里的不同变量,硬件会认为它们在竞争,导致缓存行在两个核心间来回弹跳。用perf c2c能检测伪共享,定位到具体的内存地址和代码行。
5.2 NUMA架构下的内存分配策略
多路服务器基本都是NUMA架构,每个CPU插槽有自己的本地内存,访问本地内存快,访问远端内存慢。如果进程在节点0的CPU上跑,却分配了节点1的内存,每次访存都要跨节点,延迟翻倍。numastat能看到各节点的内存分配和命中情况,numa_miss和numa_foreign这两个值高就说明跨节点访问严重。
解决思路有三种。一是用numactl --cpunodebind=0 --membind=0把进程和内存都绑到同一节点;二是用numactl --interleave=all让内存均匀分布在各节点,适合内存带宽敏感型应用;三是调整内核的/proc/sys/kernel/numa_balancing,让内核自动做页面迁移。自动迁移在多数场景下够用,但对延迟敏感的应用,手动绑定更可控。我一般先用numastat看现状,如果跨节点访问比例超过20%,就考虑手动干预。
5.3 用perf c2c揪出伪共享
perf c2c是专门检测缓存行竞争的利器。跑perf c2c record -a -- sleep 10采集,然后perf c2c report --stdio看报告。报告里HITM(Hit Modified)列表示一个核心访问了被另一个核心修改过的缓存行,这个值高的缓存行就是伪共享的热点。报告会给出具体的虚拟地址、物理地址和访问它的进程、代码符号。
我处理过一个案例:一个统计计数器数组,每个线程更新自己的计数器,但计数器是int类型且紧挨着排列,导致多个计数器共享缓存行。perf c2c显示HITM极高,把计数器改成按缓存行对齐(每个计数器占64字节)后,吞吐量提升了40%。这个优化的成本几乎为零,收益却很大,值得每个做高并发的人都掌握。
6. 一套可复用的CPU问题排查链路
6.1 从现象到根因的五步法
把前面讲的东西串成一条可操作的排查链路,我平时是这么走的:
第一步,看全局。uptime看负载趋势,vmstat 1看r、cs、us、sy、wa的分布。这一步判断问题是CPU密集型、IO密集型还是调度密集型。
第二步,定位进程。pidstat -u -w -t 1找出CPU占用高或切换频繁的进程和线程。如果是Java应用,再配合jstack看线程栈;如果是C/C++,用perf top看热点函数。
第三步,深入线程。top -H -p <pid>找到具体线程ID,转成十六进制后在jstack或gdb里定位代码。这一步是找到"谁在烧CPU"的关键。
第四步,分析原因。用perf record -g -p <pid>采集调用栈,perf report看火焰图式的调用分布。是算法复杂度问题、锁竞争问题还是缓存问题,这一步基本能定性。
第五步,验证优化。改完之后用同样的工具复测,对比r值、调度延迟、缓存miss率的变化。没有量化对比的优化都是自我感觉良好。
6.2 常见异常组合的快速对照
不同的指标组合指向不同的问题,我整理了一张对照表,排查时可以直接查:
| 现象组合 | 可能原因 | 下一步动作 |
|---|---|---|
| us高、sy低、r高 | 计算密集型,CPU真不够 | 优化算法或扩容 |
| sy高、cs高 | 系统调用频繁或锁竞争 | perf trace看系统调用,perf lock看锁 |
| wa高、r低 | IO瓶颈,CPU在等磁盘 | iostat看磁盘,优化IO或换SSD |
| st高 | 虚拟化资源被抢占 | 联系平台方或换宿主机 |
| si高、网络流量大 | 软中断处理不过来 | 调整中断亲和性或启用多队列 |
| 负载高但CPU空闲 | 大量D状态进程 | ps看D状态进程,查IO或NFS |
| nvcswch高 | CPU竞争激烈 | 减少线程数或绑核 |
这张表不是万能公式,但能帮你快速缩小排查范围。实际场景往往是多个问题叠加,需要结合业务特征综合判断。
6.3 优化后的验证与回归
优化做完不算完,必须验证。验证要关注三个层面:一是指标层面,CPU使用率、调度延迟、缓存miss率是否下降;二是业务层面,QPS、P99延迟、错误率是否改善;三是稳定性层面,优化后跑够长时间(至少覆盖一个业务高峰),确认没有引入新的抖动。
我踩过一个坑:为了降低上下文切换把线程池调小,压测时QPS确实涨了,但上线后遇到突发流量,线程池瞬间打满导致请求排队,P99反而恶化。后来改成动态线程池,根据队列深度自动扩缩,才兼顾了吞吐和延迟。这个教训是:任何静态配置都有其适用边界,优化时要考虑边界外的场景。
7. 我在实际调优中攒下的几条经验
调优这件事,工具和参数是死的,经验是活的。分享几条我反复验证过的体会。
第一条,先测量再动手。我见过太多人凭直觉改参数,改完不知道有没有效果,甚至改出问题。perf、bpftrace这些工具的学习成本不高,花半天掌握,能省下后面无数次的瞎猜。
第二条,关注长尾而非均值。平均CPU使用率60%看着很健康,但如果每秒有几十毫秒的尖峰打满所有核心,用户体验照样差。用mpstat -P ALL 1看每个核心的波动,用PSI的cpu.pressure看full字段(所有任务都因CPU不足而停顿的时间占比),比看均值有意义得多。
第三条,别忽视内核版本和编译选项。不同内核版本的调度器行为差异很大,CFS的调优参数(sched_latency_ns、sched_min_granularity_ns)在不同版本默认值不同。另外,应用编译时的优化级别(-O2还是-O3)、是否开启PGO(Profile-Guided Optimization),对CPU效率影响可能超过很多系统级调优。
第四条,容器环境要穿透看。容器里看到的/proc很多是宿主机的,CPU限流(throttling)要看cpu.stat里的nr_throttled和throttled_time。如果nr_throttled持续增长,说明容器被cgroup限流了,这时候加容器内的线程数没用,得调大cpu.cfs_quota_us或减少容器密度。
第五条,建立基线。性能优化最怕没有参照系。平时业务低峰期采集一份CPU指标基线(各核心使用率、上下文切换、缓存miss率、调度延迟),出问题时一对比就知道哪里异常。基线不用很复杂,一个定时脚本采集关键指标存时序库就行,关键时刻能救命。
CPU性能优化是个深坑,但也是个回报很高的技能。把观测做扎实,把归因做准确,优化自然水到渠成。上面这些工具和方法,你不需要一次全用上,挑当前场景最相关的两三个先跑起来,慢慢就会形成自己的排查直觉。