凌晨两点,告警电话把我从床上拽起来:核心业务机的load average飙到了32,平时还不到3。打开监控的第一反应,不是去看代码,也不是直接重启,而是先看这半小时内的数据变化。系统负载与性能排查这件事,经历过几次深夜故障之后就会明白,关键不是某个命令记得多熟,而是让数据替你完成判断。这篇文章就来聊聊,遇到CPU飙升、load异常、接口变慢这类问题时,我实际使用的定位链路和思考方式,适合正在做后端开发、运维或者SRE的朋友参考,读完能直接拿去救火。
这种问题有个让人头疼的地方:表面现象几乎一样,都是“机器很忙、服务很慢”,但背后原因可能完全不同。可能是CPU真的被打满,也可能是磁盘IO在拖后腿,还可能是内存回收引发的连锁反应。直接把机器重启或者扩容,很多时候只是把问题往后推了几个小时,下一个高峰照样出现。所以我会先从最基础、也最容易被误解的load average说起。
1. 负载高不等于CPU忙:先看懂load average这张晴雨表
1.1 load average到底在统计什么
很多人看到uptime输出里“load average: 14.23, 12.10, 9.87”这组数字,第一反应是“CPU用了百分之多少”。这是最大的误解。load average不是百分比,而是一个数量的概念——它统计的是当前处于可运行状态和不可中断睡眠状态的进程/线程总数。
这里有个关键点:不只是等待CPU的进程会被计入。内核在计算load时,会把两种状态都算进去:
- R状态:正在CPU上跑、或者排在运行队列里等待CPU的进程。
- D状态:不可中断睡眠,绝大多数时候是在等待磁盘IO或其他硬件IO完成。
所以load反映的是整个系统里“还有多少活儿等着被处理”,而不仅是CPU排队长度。内核每隔5秒采样一次运行队列长度,再用指数加权平均的方式计算出1分钟、5分钟、15分钟三档数值。之所以要保留三个时间窗口,就是为了让你能看出趋势:1分钟负载高、15分钟低,说明是刚刚发生的突发状况;三个值都高,说明问题已经持续了一段时间。
判断load是否过高,还得结合CPU核数来看。单核机器load到1就已经饱和,4核机器load到4才刚满。比较靠谱的判断方式是用lscpu或者nproc确认核数,然后看load是否持续超过核数的1.5到2倍。我一般还会多看一个东西:load的1分钟值是不是在快速爬升。如果是从2冲到15,哪怕绝对值不高,也是明确的报警信号。
1.2 最容易被误判的三种load反常现象
第一种是load很高,但CPU使用率很低。这种组合在初学者的排查里经常被误判成“服务器假死”。实际上,当大量进程处于D状态等待IO时,CPU大部分时间空闲,load却会居高不下。可以用ps -eo state,pid,cmd | grep ^D快速确认当前有多少D状态进程。我曾经碰到一台机器load常年10以上,CPU idle却有80%,最后查出来是某块共享存储网络抖动,所有读写请求都卡在等待IO完成上。
第二种是CPU使用率很高,但load不高。这通常意味着只有少数几个进程在满负荷运行,它们占满了CPU,但没有形成明显的排队。比如一个单线程进程在4核机器上跑满一个核,CPU利用率25%,load大约1,这种情况不是系统容量不足,而是这个进程自身的设计瓶颈。另外还有一种情况:很多寿命极短的进程频繁创建退出,每一次都消耗一点点CPU时间,但在load采样的瞬间并没有堆积在队列里,表现出来就是CPU高、load低。
第三种是load虚高,但业务指标正常。接口平均响应时间没变、错误率没涨,可load就是在高位。这种情况我见过几次,背后往往有这些可能:周期性任务和大批量数据导出在集中时段运行,瞬间把运行队列拉长;或者内存不足触发内核回收,kswapd这类内核线程大量占用调度资源;再或者宿主机层面对CPU资源的限制导致调度延迟。所以读load这个数字时,永远要站在业务指标旁边一起看,单看一个绝对值没有任何意义。
为了快速判断,我在排障时习惯用下面这张对照表:
| 现象组合 | 大概率原因 | 下一步动作 |
|---|---|---|
| load高 + CPU使用率低 | 磁盘IO等待、D状态进程堆积 | 查iostat、确认D状态进程 |
| CPU使用率高 + load低 | 少量进程打满、短生命周期进程过多 | 按CPU排序看进程,必要时看线程级 |
| load高 + CPU使用率高 | 真正的CPU饱和 | 定位热点函数、考虑扩容 |
| CPU和load都低,但业务卡 | 锁等待、网络延迟、外部依赖慢 | 看线程状态、链路追踪数据 |
2. 数据先行:从全局到局部的最小定位命令集
2.1 动手之前:先花三分钟明确现象边界
很多人一上来就是top猛敲,然后在几十个进程里瞎猜,这是效率最低的做法。我的习惯是先回答三个问题:什么业务受影响、从什么时间开始、之前有没有变更。这三个问题能直接砍掉一半的排查方向。
“什么业务受影响”指的是确认是全部接口都慢,还是只有某个接口慢。如果只有下单接口慢,那问题大概率不在机器本身,而是下游数据库或者缓存出了问题。“从什么时间开始”非常关键——把告警发生的时间点和发布记录、定时任务时间表拉在一起看,你会发现大量问题其实都叠加在某个固定时段上。层我还接过一个案例,每天晚上10点半准时负载飙升,查了一圈最后发现是另一个团队设置的数据库全量备份任务,磁盘IO瞬时打满,和咱们自己的代码一点关系没有。“有没有变更”就是查最近一小时内的配置变更、代码发布和扩缩容操作。
这三件事确认完,再动手敲命令。即使后面走弯路,也能随时回来对照边界,不会越查越偏。
2.2 top、vmstat、pidstat的组合用法与看数标准
我通常会用一套固定组合,几分钟内把CPU、内存、IO、上下文切换全部扫一遍:
# 确认核数和load lscpu | grep -E '^CPU\(s\)|Model name' uptime # 全局动态指标,每秒刷新,连续看几轮 top -c # 内存、IO、CPU的快速快照,间隔1秒采样3组 vmstat 1 3 # 每个进程/线程的CPU使用情况,持续5秒 pidstat -u -t 1 5 # 整体IO和每块磁盘的负载情况 iostat -x 1 3这套命令的价值在于它从不同维度交叉验证同一个现象。top -c能直接看到按CPU排序的前几个进程以及它们的完整命令行,vmstat展示的是系统整体的运行队列、IO等待、上下文切换和内存换页情况,pidstat -t则能把进程拆到线程粒度,找出到底是哪个线程在消耗资源。
读这些数据时,我给自己定了几条硬标准:
- top里的
us高说明用户态程序在忙,sy高说明内核态占用了大量CPU,wa高基本可以断定IO在拖后腿,st高则表示这台虚拟机所在的物理机资源被邻居抢占。 - vmstat里的
r列表示运行队列里的进程数,持续大于核数的1.5倍,CPU排队已经比较严重;b列表示阻塞进程数,只要长期大于0就该引起注意。 - vmstat里的
si和so是swap换入换出量,一旦持续非零,说明内存已经顶不住了,这是在用磁盘当内存用,性能会断崖式下跌。 - pidstat里单线程CPU超过100%很正常,那代表它吃掉了多个核。重点看CPU消耗是否集中在某一类线程上,比如GC线程、日志线程或者业务处理线程。
2.3 单核不均衡与内核态热点:mpstat和perf的使用时机
整体指标正常,但业务就是慢,这种情况我会考虑两个方向:CPU多核负载不均衡,或者热点藏在内核态。
mpstat -P ALL 1能列出每个物理核的使用率。如果某些核100%,另一些核空闲,说明有线程被绑核,或者单线程应用成了瓶颈。一次排查中我发现某服务所有流量都打在一个核上,查下来是启动参数里隐式设置了CPU亲和性,去掉之后整个集群的吞吐量直接翻倍。
如果用户态CPU不高、系统态CPU高,那就得把视角切到内核。perf top可以实时显示内核态的热点函数,比如锁相关、内存分配相关的调用。更完整的做法是用perf record采样一段时间,再生成火焰图。这个操作成本不高,信息量却很大,能直接看到CPU时间到底花在哪个函数上。不过要记住,perf在部分生产环境可能需要额外权限,执行前先在测试机验证一下,避免命令本身引发不稳定。
一个实用的建议:排查时尽量用pidstat -t锁定线程ID,然后用perf top -p 线程ID只看这一个线程的热点。数据粒度越细,越不容易被无关进程干扰。
3. 四类常见毛病的辨识:CPU、内存、磁盘IO、上下文切换
3.1 CPU用户态打满:从进程到函数剥洋葱
CPU被打满是最容易定位的一类问题,因为方向很明确。先用top找到CPU占用最高的进程,再用pidstat -t拆到线程,最后用perf top看函数热点,一步步从“哪个进程”收敛到“哪个函数”。
以Java服务为例,我排查过一个持续CPU高的接口,top显示某Worker线程CPU 200%+,perf里热点集中在正则表达式的匹配函数上。进一步翻代码发现,热路径里用了一个复杂正则去解析用户输入,输入稍长一点就触发灾难性回溯,CPU消耗呈指数上升。这类问题在代码review阶段其实很难看出来,必须靠数据层面定位到函数才能触发联想。
处理方式也不复杂:把正则改成手动解析,或者加上长度限制,再或者把匹配结果做成缓存。修复后同一个接口的P99延迟从800ms降到30ms。所以CPU问题只要肯往下钻,根因都不算难找,怕的是停在进程层面就去重启机器,那样永远得不到教训。
还有一类是系统态CPU高,top里sy占30%以上。常见原因包括频繁的系统调用、锁竞争和中断风暴。这时候perf热点里通常会出现spinlock、futex之类关键字。可以再结合strace -c -p 进程ID看系统调用的分布和次数,确认是哪个调用过于频繁。不过要提醒一句,strace在线上会显著放大性能问题,建议只在低峰期短时间采样,或者直接靠perf定位。
3.2 内存回收与swap抖动:load升高的隐形推手
内存问题引发的负载飙升特别容易误判,因为表面看CPU和磁盘都不忙,但load就是高。我遇到过一个典型案例:每天晚上8点整,某搜索服务负载从2跳到11,持续十分钟后回落,期间CPU使用率不到30%,磁盘IO也不高。
最后查下来是内存回收在捣乱。业务在晚间有一个集中写入缓存的操作,瞬间产生大量脏页,内核为了腾出内存空间,触发同步回收,把一部分应用线程卡在了内存分配路径上。这些线程虽然没有真正执行逻辑,但在load统计里,它们可能表现为可运行或不可中断状态,load自然就涨上去了。
判断内存回收压力有几个信号:vmstat里si/so持续非零,sar -B里能看到pgscank和pgsteal数值明显放大,free -h显示cache占用极高。如果swap已经在缓慢增长,说明内存压力不是临时的,而是进程内存真的不够了,优先排查内存泄漏,而不是调整回收参数。
针对内存回收导致的load抖动,常规做法分两种。短期上,把集中写入操作打散到更长时间窗口里,避免瞬时制造大量脏页;长期上,评估业务进程的堆内存和缓存配置,让热点数据尽可能留在内存里,而不是频繁走磁盘。
3.3 磁盘IO等待拖累全链路:先从指标里认出等待
磁盘IO问题最典型的特征是top里的wa指标偏高,同时vmstat里的b列持续大于0。这两个信号同时出现时,基本可以断定有大量进程在等待磁盘IO完成。
进一步用iostat -x 1看每块磁盘。%util是最直观的指标,但不建议单独看它——现在很多SSD和NVMe硬盘在多队列机制下,util很容易就到100%,可实际延迟不一定高。我会更关注await(平均IO响应时间)和aqu-sz(平均IO队列长度)。如果await比正常基线高出好几倍,同时aqu-sz长期大于磁盘队列深度,那才是真正的IO瓶颈。
确定是IO问题后,用iotop找具体进程,再用pidstat -d看进程的读写速率。我见过一个典型的坑:某个服务开启了全量SQL日志,每写一条业务记录就同步刷盘两次,数据库机器负载被日志写入拖垮。把日志刷盘策略改成异步,或者其他组件的慢查询日志关掉,负载立刻就下来了。磁盘IO这类问题尤其适合对照代码排查:是不是存在循环内频繁写文件、是不是日志级别设置成了DEBUG、是不是临时文件没有及时清理。
3.4 上下文切换与锁竞争:高并发下看不到的损耗
这类问题最隐蔽,因为所有常规指标看起来都“正常”,CPU有闲余、内存充足、磁盘不忙,但业务响应就是慢。这时候一定要看上下文切换。
vmstat里的cs列表示每秒上下文切换次数。一般来说几千到几万都正常,但如果到了几十万甚至上百万,系统大量时间都在做进程/线程切换,而不是在真正执行业务。再用pidstat -w 1区分自愿切换和非自愿切换:非自愿切换高,说明线程数远超可用核数,大家在抢CPU时间片;自愿切换高,说明线程频繁在等待IO或锁,睡一会儿醒一会儿,调度开销被无限放大。
我处理过一个实例:某网关服务把线程池从200调大到800,结果吞吐量反而下降了四成。因为这台机器只有16核,800个线程大多数时间在互相抢CPU,非自愿切换飙高,响应时间直线上升。把线程池调回256,同时配合队列限流,性能立刻恢复。线程数不是越大越好,经验值是“CPU核数乘以(1 + 等待时间/计算时间)”,但最稳妥的做法还是按业务峰值做压测,找吞吐量拐点。
锁竞争也是上下文切换的一个重要来源。Java服务可以用jstack抓线程快照,看大量线程是否阻塞在同一个锁对象上;C++服务可以用perf的延迟统计,看哪些锁被频繁竞争。抓线程快照不要只抓一次,隔几秒抓三到四次,看哪些线程的状态反复横跳,那基本就是热点锁了。
4. 一次CPU占用飙高问题的完整定位复盘
4.1 从告警到定位:先还原现场,再谈修复
上面讲的是知识框架,这里分享一次让我印象很深的完整排查过程。当时某订单系统每天凌晨2:30固定出现负载尖峰,load从2一路飙到15,持续20分钟左右,接口响应时间从30ms涨到2秒。由于是凌晨,用户影响不大,但批量任务会被拖垮,而且连续几天都在同一时段复发。
第一次接到告警时,我按老规矩先把时间边界拉出来。2:30的高峰,正好有两个定时事项重合:日志归档任务和报表导出任务。当时团队内部分歧很大,有人说日志归档IO太大,有人说报表导出SQL太烂占满CPU。我没有急着下结论,先用历史监控数据复盘。
当时机器还在运行,但高峰快要过去,所以我直接查sysstat记录的sar数据,把尖峰时段拉出来对比。结果显示:%user接近80%,%iowait不到5%,说明CPU确实被用户态程序消耗了,IO嫌疑被排除。再看vmstat的r列,持续在20以上,远超8核机器的承受能力,CPU队列已经严重积压。
4.2 根因在异常日志的堆栈拼接
进程层面的定位用pidstat -t就完成了。尖峰时段里,报表导出进程只占了10%的CPU,反而是某个日志处理线程占了300%以上。这个结果把排查方向从“批量任务”拉到“日志处理”上,和最初的猜想完全相反。
用perf top去看这个线程的热点函数,排在最前面的几个符号指向字符串操作和异常堆栈获取。翻代码后找到了问题所在:业务代码在捕获异常后,会把完整的堆栈信息格式化拼进一条日志字符串里。这个服务在凌晨高峰时段正好有一批异常被频繁触发,每一次异常处理都在动态采集线程堆栈——每一帧的类名、方法名、行号都要从运行时环境里临时获取并拼接成字符串。听上去好像没什么,但堆栈采集本身是相对昂贵的操作,高频触发时立刻成为CPU热点。
更糟糕的是,这条日志还设置了DEBUG级别,正常情况下根本不会被持久化,但字符串拼接在内存里已经完整执行了。也就是说,大量CPU时间花在了一段最终没有任何输出的代码上。这类问题非常典型:代码里的每一处“顺手打印”单独看都不起眼,一旦跑在热路径上,代价就会被放大千百倍。
4.3 止损动作与事后加固
定位到根因后,我们没有立刻改代码,而是先做止损:把日志级别从DEBUG调高,过滤掉大部分异常日志构造;同时把报表导出任务的并发数降一半,错开日志归档的时段。这两个动作做完,第二天凌晨的load尖峰降了一大半,从15降到了5左右。
正式修复分成两部分。代码层面,异常日志只记录异常类型和消息,不再全量输出堆栈;业务上确实需要堆栈的场景,只记录第一帧或者按采样比例记录,避免每次异常都触发完整堆栈采集。任务调度层面,报表导出整体平移半小时,与日志归档完全错开。
这次故障复盘之后,我给团队加了一条发布Checklist:热路径代码不允许拼接并打印完整堆栈,同时新增“异常日志触发次数”指标,一旦短时内超过阈值就自动告警。这类问题本质上不是技术难题,而是合理的规范缺失积累出来的性能炸弹。
5. 把性能问题扼杀在变更之前:监控与容量评估的门槛
5.1 比告警本身更重要的基线数据
排查性能问题最怕“没有参照系”。只知道当前load是15,却不知道昨天的同一时段load是多少,就很难判断这是突变还是常态。很多团队只在出问题时才看一眼监控曲线,事后又不知道保留多长时间的数据,导致想对比都无从下手。
我的建议是核心指标至少按分钟粒度保存三到六个月,包括CPU使用率、load、内存占用、磁盘IO、网络流量、接口P99延迟和错误率。保留这些数据平时看似没用,关键时刻能帮你快速回答“和上周同一时段比,是不是有漂移”。我还习惯在每次重要版本发布前后记录一份基线快照,效果和体检报告一样——下次有问题,先对比再排查。
5.2 变更前压测与代码热路径审查
绝大多数负载问题都来源于变更。代码评审里如果只关注功能正确性,很容易漏掉性能隐患。我在review时会特意扫几条规则:热路径上有没有正则表达式、反射调用、大对象频繁分配;有没有在循环里做IO操作;线程池参数是否有压测依据;日志输出是否有条件开关控制。这些点只要有一个中招,线上就有概率出现负载尖峰。
压测是最后一道防线。我的习惯是压测目标至少要达到预估峰值的1.5倍,并且不只关注吞吐量,还要关注延迟拐点——随着并发升高,P99延迟有没有突然上扬。如果压测机器出现上下文切换暴涨、运行队列持续堆积,就算功能全通,这个版本也不该上线。压测环境越接近生产,效果越好,至少CPU核数、磁盘类型这些硬指标不能差太远。
5.3 错峰与异步:低成本抵消负载尖峰的手段
不是所有性能问题都要靠代码优化解决,很多时候调度策略的调整成本更低、见效更快。定期任务尽量避开整点和半点,尤其是数据库备份、报表导出、日志归档这些IO密集任务,最好分散在不同时段;非核心链路的业务流程改成异步处理,削峰填谷,能显著降低瞬时load。
举一个实际的例子:某服务每天凌晨的批量消息推送会把load推到10以上,影响正常业务。后来把单批推送改成多批小量推送,每批之间间隔几秒,再结合异步队列限流,高峰load从10降到4,而且业务完成时间几乎没有变化。负载管理的本质不是在问题发生后救火,而是把资源的消耗曲线尽量拉平,让系统在任何时刻都有余量应对突发流量。
我在实际处理过这么多性能问题之后,最大的体会是:遇到load飙升,先冷静还原现场,再逐层定位,最后动手处理。性能问题的表象虽然千篇一律,但每个现象的背后的数据都不同。只要愿意顺着数据一层层拆下去,答案基本都会自己浮出来。最怕的是凭经验猜,猜错了方向还在原地绕圈。希望这套方法和案例,能让你在下次遇到类似情况时,少走一点弯路。