先说个场景。你手里一台32核的服务器,跑着压测,QPS死活上不去,CPU利用率卡在15%,加并发也没用,换个更重的请求还是这个数。老板过来看了一眼说"机器性能不行",但你心里清楚,CPU连一半都没用上。这种"CPU占不上去"的问题,我这两年排查过至少二十次,分布在各种项目里,从Java服务到Python脚本,从物理机到容器都有。它比CPU爆高更难受——爆高至少说明负载上来了,占不上去则是你根本不知道瓶颈卡在哪。
先说清楚这篇文章解决什么问题:当你的应用、服务或者压测任务在运行过程中CPU使用率低于预期(比如达不到多核能力的50%以上),你怎么通过系统监控、代码分析、环境检查三板斧定位根因,并且给出实际可用的解决办法。适合谁看?正在做性能压测的研发、维护线上服务时发现资源利用率异常低的运维、以及写多线程程序但发现扩展性上不去的开发。
这个问题的名字听着像个病句,但实际排查中它确实是最难啃的一类性能问题。
1. 现象分化:CPU上不去不是一种病,先分清是哪一类
我收到的这类问题,描述通常都是"CPU占不上去",但"占不上去"这个描述背后,至少有四种完全不同的现象,对应的排查方向南辕北辙。第一步不是查代码,而是把现象精确分类。
1.1 单核已满和多核空闲,是完全不同的两件事
最常见的一种情况:程序本身是单线程或者少数几个线程在跑,其中一个线程已经跑到100%,但整机32核的CPU利用率算下来只有3%。从系统层面看,CPU占用率很低,但实际上这个任务已经没有余量了。
判断方法很简单。top进去按1看每个核心的使用率,如果只有某一个核心飙到100%,其它核心全是0%,那就是典型的单线程瓶颈。再看进程下有多少活跃线程,用top -H -p PID按CPU排序,你会发现只有一两个线程在忙。
这类问题的本质不是"CPU上不去",而是"程序根本没打算用多核"。解决方向和"CPU占不上去"完全相反——你需要做的是并行化改造,而不是找系统配置的问题。
1.2 等待太多:CPU在等IO,而不是在算
第二种情况更隐蔽。top里看到wa(iowait)很高,CPU利用率显示不高,因为CPU在等待磁盘、网络或者外部服务返回。这时候CPU执行read()或者recv()之后进入睡眠状态,什么都不干就等着。表现就是:CPU利用率上不去,QPS也上不去,RT还很高。
这类问题有个特征:你把并发调上去,CPU会稍微涨一点,但很快又被打下来,因为线程数多了之后,大家都在等锁或者等IO,反而增加上下文切换开销。用vmstat 1看r(运行队列)和b(阻塞队列),如果b长期大于0,说明有大量线程在阻塞中。
很多人在这一步容易误判成CPU问题,实际把IO瓶颈解决掉之后,CPU自己就上去了。
1.3 CPU在空转:消耗了时间片,但没干正事
还有一种我遇到的"CPU占不上去"其实是假象——CPU确实跑了,但跑得很分散,效率极低。典型场景是自旋锁(spinlock)导致CPU空转。线程在等待锁的时候不会休眠,而是不断循环检查锁是否释放,这些循环指令让CPU使用率看起来不低,但业务处理量没有任何增长。
用perf top看热点函数,如果看到大量时间花在futex、spin_lock、sched_yield这类内核函数上,基本就是锁竞争导致的应用空转。这种场景下CPU利用率能涨到50%甚至更高,但业务吞吐就是不动,你这是"CPU占上去了但没用"的变形问题。
1.4 CPU被限制:物理核很多,但你只能用其中几个
最后一种属于平台层面的问题。程序是并发设计,代码也没毛病,但CPU就是最多跑到某个比例上不去。你需要看这个进程是跑在物理机、虚拟机还是容器里。
虚拟机里常见的坑:vCPU数量没给够、CPU热插拔没生效、NUMA绑定不对。容器里更常见:cgroup的cpu.max限制了CPU额度,cpuset限制了只能在某几个核上跑,cpu.weight设置得太低导致在CPU竞争时被其它容器挤掉。
用lscpu看当前环境能看到的CPU数量,再用cat /proc/PID/status里的Cpus_allowed_list字段看进程实际允许使用的核,两者对不上,就是被限制了。
2. 三层定位法:从全局监控到内核调用链,找出真凶
把现象分类之后,我一般按三层往下查。曾经也试过直接上火焰图,但火焰图对"CPU占不满"这种问题的分析效果有限,因为它只展示CPU采样到的调用栈,如果线程都在等IO或者被阻塞,火焰图上根本看不到热点。正确顺序是先确认瓶颈在哪个资源上,再做细粒度分析。
2.1 第一层:用全局指标缩小范围
打开三个终端,分别跑这三组命令,观察30秒到1分钟:
top mpstat -P ALL 1 vmstat 1top看整体饱和度,mpstat看每个核心是否均衡,vmstat看内存、交换、IO等待和运行队列。排查"CPU占不上去"时,我最关注的是vmstat里的这几列组合:
| vmstat指标 | 含义 | 异常信号 |
|---|---|---|
| r | 运行队列长度 | 如果r小于CPU核数,说明CPU根本不够忙 |
| b | 阻塞队列长度 | 长期>0,说明有IO等待/锁等待 |
| wa | IO等待占比 | 偏高时CPU让位给了IO |
| cs | 上下文切换/秒 | 超过50万说明锁竞争或线程频繁切换 |
| us + sy | 用户态与内核态CPU时间 | sy占比过高时,时间消耗在内核处理上 |
举个例子,我之前排查过一个网关服务,r队列基本等于1,32核只有1个任务在跑,自然CPU利用率就是3%左右。但我再看top -H,发现并不是只有一个线程,而是有几十个线程,只是全都阻塞在epoll_wait上,说明流量本身没压上来,程序逻辑没毛病。
2.2 第二层:进程内线程视角
确认是应用层问题后,进入第二层。用top -H -p PID查看进程内所有线程的CPU排名:
top -H -p 12345然后按P键按CPU使用率排序。你会看到两种情况:
- 只有少数线程在忙,说明任务没被拆散,或者拆分后大部分线程都拿不到活;
- 所有线程都在忙但都很低(比如每个线程只有5%),说明线程之间互相等待或者在做频繁的空转。
第二种情况需要在线程级别抓取调用栈。用jstack(Java)或者pstack/gdb(C/C++)连续抓几次线程栈,看看大部分线程停在什么系统调用上。常见组合是:
- 停在
Object.wait():线程池空闲,说明任务没进来; - 停在
LockSupport.park():同样说明任务分配不均,或者线程在等队列; - 停在
recvfrom/read:网络IO或磁盘IO等待; - 停在
xxx await():可能是在等数据库连接、HTTP连接等池化资源。
2.3 第三层:perf采样热点
如果线程都在跑但效率不高,直接上perf采样:
perf record -F 99 -g -p PID -- sleep 30 perf report-F 99表示每秒采样99次,-g记录调用栈,是生成火焰图的经典参数。如果热点函数是应用业务代码,说明代码效率低(比如不必要的循环、JSON解析、正则匹配)。如果热点在内核(比如queued_spin_lock_slowpath),那要么是锁竞争,要么是内核模块有问题。
给个参考:用perf top看热点时,用户态热点占比高,说明是程序本身逻辑开销;内核态热点占比高,说明触发了大量系统调用或者资源竞争,先去检查锁和IO。
3. 应用代码里最常见的五类"CPU上不去"原因
系统层面的排查做完之后,大部分场景都会锁定到应用代码。我整理了五类反复出现的代码问题,每类都给出问题和排查特征,方便你对照。
3.1 串行瓶颈:一个线程拖垮整个进程
这类问题出现频率最高,尤其在数据处理类任务里。程序的主流程里有一个耗时的串行步骤,比如全量扫描一个大文件、正则匹配、目录遍历,这个步骤没法并行,导致整个任务的吞吐上限被这个步骤锁死。
我碰到过一个典型场景:一个ETL任务,前面读数据是并行8线程,但中间有个步骤是把读到的每行数据做一次正则校验,写代码的人直接在单线程里跑了。结果32核的机器,只有1个核在跑正则,其它核全在等它。修复方式是把正则校验也拆到线程池里,吞吐直接翻了8倍。
排查特征:top -H看到1个线程100%,其它线程都在Waiting;jstack看到大量线程阻塞在Future.get()或CountDownLatch.await()。
3.2 锁粒度太大:并发变成了串行排队
加锁保护共享资源本身没有错,错的是把锁的粒度设置得太大。曾经踩过一个坑:一个缓存更新方法,对外部SDK的调用放在synchronized块里面,本意是防止并发导致缓存穿透,实际上把所有请求都串行化了。压测时并发从100加到1000,CPU利用率和QPS都没变化,因为请求全在锁外面排队。
这类问题的特征是:增加并发之后,CPU利用率保持不变甚至略微下降,因为线程增多但都在等锁。排查方式用jstack连续抓几次线程栈,如果多个线程的栈顶都停在同一个锁对象上,锁竞争实锤。
解决思路:缩小锁范围、改用读写锁、用CAS替代重量级锁、或者用分片锁。修改之前先用压测验证锁确实是瓶颈,避免优化落空。
3.3 线程池配置失当:线程都饿死了
很多服务用的是固定大小线程池,比如Executors.newFixedThreadPool(4),但业务高峰期同时可能有几百个任务进来,超过4个的任务全部排队等待。结果就是:CPU利用率上不去(因为只有4个线程在跑),但任务队列越积越长,接口响应时间飙升。
排查特征:jstack看到大量线程阻塞在线程池的工作队列上;监控上的线程池队列深度持续上涨;CPU利用率不高但RT很高。
解决方案要落到线程池参数的监控上:队列积压数量、活跃线程数、拒绝次数都要监控。线程池大小不是拍脑袋定的,需要实测不同大小下的吞吐和RT曲线,找到拐点。
3.4 IO密集型模型被当成CPU密集型优化
还有一类错位:业务本质是IO密集型(比如数据库操作、外部API调用),代码写着同步请求,每个请求都要等数据库返回才开始下一个请求。这个时候CPU利用率和IO利用率都上不去,因为线程都在等待网络往返。
排查特征:vmstat的b和wa不为零,但网络流量也不高;perf采样看到大量时间在__lock_sock或wait_on_page_bit这类IO等待函数上。
解决方向很明确:用异步IO(比如Java的CompletableFuture协程化、Go的goroutine天然适合)、减小连接池预取开销、批量聚合请求。
3.5 空闲轮询:用CPU换延迟的假性能优化
有些开发者为了减少延迟会写空转等待,例如:
while (!flag) { // busy wait }其它线程把flag置为true,这个线程才继续。这种做法会让这线程占满一个核,整机CPU利用率看着挺高,实际上业务吞吐没有任何增加。你说的"CPU占不上去",其实它已经占上去了,只不过占在了无意义的空转上。
排查特征:perf top能看到Thread::SpinWait、sched_yield这类热点;top -H看到某个线程稳定在100%,但业务TPS没有对应上涨。
解决方式是改用Object.wait/notify、Condition、CountDownLatch等阻塞原语,把自旋换成睡眠唤醒。
4. 系统层隐形坑:频率、亲和性、虚拟化与容器限制
代码层排查完还搞不定,就要怀疑是不是系统环境在拖后腿。这一部分我按频率从高到低排了四个最常见的坑。
4.1 CPU频率没上去:省电策略偷走了性能
现代CPU都有动态调频机制,负载低的时候降频省电,负载高的时候自动升频。但有些服务器BIOS设置或者操作系统电源策略把调频上限卡死了,导致CPU即使有任务,频率也只能跑到基础频率,算力上不去,表现出来的就是"CPU占不满、任务没速度"。
Linux下先查当前调频策略:
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq cat /sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq常见的governor有performance、powersave、schedutil、ondemand。如果线上是powersave,直接改成performance:
cpupower frequency-set -g performance改完之后再看CPU频率是否跑到了最大睿频。这里要提醒一句:performance模式会增加功耗和发热,如果在云服务器或者托管机房,先确认电费和散热条件允许。
4.2 超线程和物理核的关系:你以为的32核只有16个真核
一些云服务器默认开启了超线程,lscpu看到的逻辑核数量是物理核的两倍。如果你的应用里没有区分物理核和逻辑核,配合上某些对超线程不友好的代码模式(比如两个线程死循环争抢同一个物理核),会导致整体吞吐上不去。
看物理核和逻辑核布局:
lscpu -e cat /proc/cpuinfo | grep -E "physical id|core id|cpu cores" | head -20确认是否超线程:如果Thread(s) per core显示2,说明每核2个逻辑线程。对于计算密集型任务,超线程实际带来的提升通常只有15%到30%,而且某些场景下反而有害。可以用taskset绑定线程到指定的物理核上做对照实验。
4.3 cgroup限额:容器里最常见的隐形天花板
在Kubernetes里跑服务时,经常遇到"CPU占不上去"的问题。首先确认Pod里的CPU request和limit:
cat /sys/fs/cgroup/cpu.max cat /sys/fs/cgroup/cpuset.cpus.effectivecpu.max的格式是quota period,比如40000 100000表示每个周期100ms只能用40ms的CPU时间,换算下来就是0.4个核。如果配额这么低,你怎么压都压不上去。
另外还有CPU weight优先级的问题。同一台物理机上如果跑了很多容器,CPU weight低的容器会竞争不过其他容器。这种场景算不公平竞争,cat /sys/fs/cgroup/cpu.weight能看到当前权重,默认是100。
排查容器的要点:先确认limit ≥ 你要用的核数,再确认同一物理机上其它容器没有在抢CPU。kubectl top node看节点整体CPU分配率,如果分配率超过100%,肯定要争抢。
4.4 虚拟化层的NUMA和中断亲和性
物理机上还有一个隐蔽的坑:NUMA。程序里的线程如果跨NUMA节点访问内存,性能会大幅下降。比如你在32核机器上起64个线程,但线程可能被操作系统调度到了两个NUMA节点之间来回迁移,每次迁移都要重新缓存内存数据,CPU利用率看起来不低,但业务流量上不去。
用numactl --hardware查看NUMA拓扑,用numactl --membind指定线程在哪个节点上分配内存。如果是Java应用,JVM启动参数里加-XX:+UseNUMA可以按NUMA节点分配堆内存。C/C++程序可以用libnuma做显式绑定。
另外还有中断亲和性:网卡的收包中断如果全部跑到一个核上,这个核会被中断打满,其它核闲得没事干,网络吞吐上不去。手动绑定中断到多个核上:
# 查看当前中断绑定 cat /proc/irq/82/smp_affinity_list # 绑定到多个核:比如绑定到2、4、6、8号核 echo "2-5" > /proc/irq/82/smp_affinity_list多数发行版默认开了irqbalance,会自动均衡中断,但有时候它的策略并不适合高吞吐场景,需要手动调整。
5. 压测方式不对,CPU也会"假死"
有时候CPU占用上不去,问题根本不在服务端,而在压测端或者测试方法上。我至少三次排查到最后一圈,发现是压测工具当时只用了单线程。
5.1 压测工具自己的瓶颈
最典型的:用ab压测的时候只指定了并发数,没注意ab本身默认只能创建少量连接。高并发长连接场景要用wrk或wrk2,并且wrk是单线程的,虽然事件驱动能打很高的QPS,但如果你要压出服务的极限,建议多开几个wrk实例。
另一个坑是压测机的CPU先被干满了。用wrk压测时,如果压测机CPU利用率几乎到100%,但目标机还很低,优先怀疑压测工具应该多实例化。压测机和目标机要分开看监控,不要只看一边。
还有JMeter:默认以GUI模式跑压测时,JMeter自己会消耗大量CPU,而且GUI模式下线程模型有瓶颈,压不太高。命令行jmeter -n -t才是正规压测方式。
5.2 请求模型的设计问题
压测的请求如果太简单,服务端每个请求处理时间极短,CPU利用率自然上不去,因为大部分时间在等网络传输。比如HTTP压测几百字节的响应,服务端可能只耗时几十微秒,网络RTT一毫秒,那CPU基本都在等网卡。
这种场景要压出CPU峰值,回答两个问题:
- 请求是否足够重?比如加一个做几毫秒CPU计算的接口,让CPU有机会跑满;
- 并发是否足够高?长连接场景并发上不去,CPU也很难打满。
针对性做法是做混合场景压测:80%简单读请求+20%复杂计算请求,模拟真实流量,再看CPU能跑多少。
5.3 从压测端排除问题
如果怀疑压测端有问题,最快的验证办法:把压测机和服务端放到同一台物理机内部网络里,或者直接用socat/nc发大流量。再不行就直接在服务端写一个死循环脚本,先把CPU打上去,确认系统本身的能力。我在排查"CPU上不去"的时候,会先手动执行:
# 在目标机上跑一段高CPU压力的测试,确认CPU本身能跑上去 for i in $(seq 1 $(nproc)); do (while true; do true; done) & done然后观察CPU利用率。如果这个脚本能把所有核都打满,说明系统、内核、电源策略没有问题,问题就锁定在应用或压测;如果连死循环都打不满,那系统层一定有问题,回过头查频率和配额。
6. 附:我日常排查CPU不饱和的速查清单
基于以上排查经验,整理一张清单,按顺序过一遍,大部分"CPU占不上去"的问题能在半小时内定位。
6.1 从现象到假设的对照表
| 现象 | 第一怀疑对象 | 验证指令 |
|---|---|---|
| 单个核100%,其它空闲 | 单线程串行逻辑 | top -H -p PID |
| 多核都低,wa偏高 | IO等待 | vmstat 1,看b和wa |
| 多核都低,上下文切换高 | 锁竞争/线程频繁切换 | vmstat 1,看cs列 |
| 多核都低,无热点无等待 | 压测没打满 / 流量不足 | top看运行队列id太高 |
| 容器中CPU到limit上限 | cgroup配额限制 | cat /sys/fs/cgroup/cpu.max |
| 虚拟机中CPU低但性能差 | vCPU分配或NUMA | lscpu看CPU数,numactl --hardware |
| 物理机上CPU低但性能差 | 频率被限制 | 查scaling_governor和cur_freq |
6.2 最终的兜底手段
清单过完还没找到问题,还有两个兜底招。
第一个是抓线程上下文切换。pidstat -w -p PID 1,如果cswch(自愿上下文切换)和nvcswch(非自愿上下文切换)比例异常,直接抓线程栈去看切换的原因。非自愿切换高,说明线程被系统抢占,可能CPU数不够;自愿切换高,说明线程在做大量等待。
第二个是用strace -c -p PID统计系统调用耗时。能直接看到程序把时间花在哪个系统调用上,poll、futex、read、write哪个占比高,就顺着去找哪个资源没跟上。
排查这类问题最忌讳的是一上来就改代码。先确认系统CPU本身能跑满、压测流量本身打进来了、应用线程数本身够用,这三个前提有一个不满足,后面全是白忙活。我这些年踩过的坑里,至少有三成最后是压测工具单线程、容器limit太小、电源策略没改这类"环境问题"。
再分享一个实战中经常忽略的点:改完系统参数一定要重跑压测,并且同时记录before/after。因为CPU占不上去的问题往往是多因素叠加,改一个参数可能只是把瓶颈从A移到B,要一轮一轮地往前推,才能彻底把CPU打满。