如果你也干过后端运维或者性能排查,一定遇到过这种场景:服务没宕机,接口也没报错,但响应时间就是从 200ms 抖到 2s。打开监控大盘,CPU、内存、磁盘、网络全部有曲线,可曲线都在动,就是不知道到底哪一环卡住了。我去年自己写了一个小工具,名字叫 REA,全称 Resource Efficiency Analyzer,说白了就是一个轻量级的资源效率分析器。它做的事情很简单:一次性把 CPU、内存、磁盘 IO、网络、进程和线程占用全部拉出来,再按一套规则给报告排排序,告诉你嫌疑最大的瓶颈在哪里。如果你是小团队或个人开发者,不想为一次偶发故障搭一整套重型监控平台,这个思路应该能给你一点启发。
1. 为什么我会写一个叫 REA 的轮子
1.1 现成监控方案让我头疼的地方
我不是反对用监控平台,而是觉得大部分监控体系重得吓人。想要看到完整的 CPU、内存、磁盘、网络曲线,需要先部署一堆采集器,再配存储、配告警、配仪表盘,最后一套下来,光维护成本就抵得上一个小团队半个月的工作量。而且真正到了线上故障时刻,监控大盘上所有曲线都在动,每一个面板看起来都有嫌疑,但你就是没办法快速把它们交叉起来判断因果。
我之前处理过一次慢接口问题,印象很深。接口偶发抖动,CPU 平均占用 30%,磁盘 IO util 也没超过阈值,内存剩余一大半,监控上看起来"一切正常"。后来查了很久,才发现是业务日志写太频繁,导致磁盘 IO 在某一秒内被顶满,形成毛刺。可监控面板用平均值展示,毛刺被拉平了,自然看不出来。那次之后我就想:与其继续往监控平台里堆指标,不如写一个只负责"临时诊断"的小工具,在问题现场跑几十秒,把多维指标放在一起看,直接输出一个嫌疑排序。
1.2 REA 到底解决什么问题
REA 的定位很明确:单机上下文里的快速排查工具。它不是一个 7x24 小时监控系统,而是一个"现场采样器"。你输入一条命令,它在后台跑 10 到 30 秒,周期性地读取 CPU、内存、磁盘、网络、进程和线程数据,最后生成一份带排序建议的报告。报告会告诉你:当前机器上,综合来看,哪个资源最紧张,哪个进程最可疑。
适合它的场景主要有三类。第一类是开发自测阶段,接口突然变慢,需要快速排除是数据库、缓存还是本机资源问题。第二类是压测现场,在压测过程中找到瓶颈资源,而不是压完再翻监控曲线。第三类是线上偶发故障,问题只出现几秒钟,重监控平台看了半天看不出名堂,用 REA 在问题复现时抓现场。它不追求指标全面,追求的是"人一眼就知道该往哪个方向查"。
2. 核心功能拆解与设计思路
2.1 五个采集模块与关键指标
REA 在数据采集上只保留了五个模块,分别对应系统资源的五个基本面:CPU、内存、磁盘、网络、进程线程。每个模块都从 Linux 内核暴露的状态接口里拿数据,没有额外的 Agent,也没有外部依赖。
| 模块 | 数据来源 | 核心指标 | 说明 |
|---|---|---|---|
| CPU | /proc/stat | 用户态占比、内核态占比、iowait、上下文切换、运行队列 | 判断是计算密集、内核态繁忙,还是在等 IO |
| 内存 | /proc/meminfo | MemAvailable、Buffer/Cache、Swap 使用量 | 判断真实可用内存是否够用 |
| 磁盘 | /proc/diskstats | IO util、await、IOPS、读写延迟 | 判断磁盘是否成为队列瓶颈 |
| 网络 | /proc/net/dev、/proc/net/netstat | 吞吐、丢包、TCP 重传 | 判断网卡和协议栈状态 |
| 进程线程 | /proc/[pid]/stat、/proc/[pid]/task | 进程 CPU、RSS、线程数、线程名 | 定位到具体可疑进程 |
我不追求把内核里所有计数器都塞进去,只保留和"变慢"强相关的这些。你可以把服务响应变慢理解成餐厅出菜变慢:要么厨师炒菜慢,对应 CPU 忙;要么食材不够,对应内存不足;要么传菜通道堵住,对应磁盘和网络;要么后厨有员工在添乱,对应异常进程或线程。采集模块就是围绕这条链路设计的。
2.2 为什么选择 /proc 而不是一堆 Agent
很多人的第一反应是:要采集这些数据,直接装 Agent 不就好了?但如果目标只是临时诊断,装 Agent 反而带来了新的变量。Agent 本身要占端口,要依赖运行环境,还有自己的 Bug,排障时等于多了一个怀疑对象。而 Linux 的 /proc 文件系统是内核主动暴露出来的运行时状态,读它不需要改内核,不需要装驱动,大部分指标普通用户都能读。
这是 REA 最核心的设计选择:只读 /proc,不碰运行时。它提供的每一个指标,都是内核已经统计好的数据,REA 只是做采样、做差值、做展示。这样工具本体可以做到单文件分发,跑完就走,不留任何常驻进程,也不需要清理日志。代价是只能跑在 Linux 上,Windows 和 macOS 用户需要另外想办法。但对于绝大多数线上后端环境,这个取舍值得。
2.3 规则引擎是怎么做"初步因果推断"的
很多排障新人有一个误区:以为拿到原始指标就能定位问题。实际上,单看一个指标几乎说明不了任何事。CPU 高可能是用户态计算密集,也可能是内核态频繁切换;磁盘 util 高可能是正常大文件写入,也可能是因为日志刷太勤。REA 解决的思路不是做复杂的根因分析,而是用一个轻量级规则引擎做多维指标交叉打分。
规则大概长这个样子:CPU 用户态超过 70% 且运行队列超过核数的 1.5 倍,记作"用户态计算繁忙";CPU 系统态超过 30% 且上下文切换超过每秒 1 万次,记作"内核态/切换过高";IO util 超过 80% 且 await 超过 100 毫秒,记作"磁盘队列瓶颈";内存可用率低于 20% 且 Swap 持续增长,记作"内存吃紧"。每命中一条规则,就给对应资源算一次分数,最后按分数排出嫌疑顺序。
这里要强调一点:规则只是辅助,不是结论。它会告诉你"磁盘的嫌疑最大",但不会替你决定要不要加索引、扩内存或换固态盘。实际排障时,这些命中规则只负责缩小范围,最后还是要靠人去确认因果。把"嫌疑排序"做出来,对一线排查来说已经能省下大量时间。
3. 从零实现 REA 核心采集器
3.1 工程结构、命令长什么样
REA 的代码结构很简单,主语言我用的是 Python 3,原因是不需要编译,部署方便,而且标准库里的文件读取足够支撑性能采样。核心代码只有几千行,工程目录大概是这样:
rea/ ├── rea.py # 入口 ├── config.yaml # 阈值配置 ├── collectors/ │ ├── __init__.py │ ├── cpu.py # CPU 采集 │ ├── mem.py # 内存采集 │ ├── disk.py # 磁盘采集 │ ├── net.py # 网络采集 │ └── proc.py # 进程线程采集 ├── report.py # 报告生成 └── rules.py # 规则引擎启动方式很朴素:
python3 rea.py --duration 10 --interval 2 --pids 12345参数--duration表示总共采多长时间,--interval表示每隔多少秒采一次,--pids用于额外跟踪指定进程。时间太短采不到稳定结果,至少采 10 秒;间隔太短又容易受到瞬时抖动干扰,一般设 1 到 2 秒。第一次读到的数据只当作基线,从第二次开始才计算差值,这一点非常关键,后面我会细说。
3.2 CPU 采集器:采样两次算差值
CPU 采集最容易被新手写成"读一次 /proc/stat,直接除以某个数就算出使用率",这是错的。因为 /proc/stat 里存的是内核启动以来的累计值,不是某个时间段的值。正确做法是采样两次,用两个时间点的差除以中间的耗时。
def read_cpu_stat(): with open("/proc/stat") as f: line = f.readline() parts = line.split() # parts[0] == "cpu",从 parts[1] 开始是 user 到 steal user, nice, system, idle, iowait = (int(x) for x in parts[1:6]) irq, softirq, steal = (int(x) for x in parts[6:9]) idle_all = idle + iowait total = user + nice + system + idle + iowait + irq + softirq + steal return {"idle": idle_all, "total": total}注意这里没有把 guest 和 guest_nice 再加进去,因为内核文档里写得很清楚,这两个字段已经包含在 user 和 nice 里,重复加会导致 totals 算多,CPU 使用率会偏低。采集到两次快照后,使用率就是1 - (idle_delta / total_delta),用户态占比是(user_delta + nice_delta) / total_delta,系统态占比是(system_delta + irq_delta + softirq_delta) / total_delta。
上下文切换个数不能从这个 CPU 行里读,需要单独读 /proc/stat 里的ctxt行。它同样是一个累计值,也是用差值除以经过时间,得到每秒上下文切换次数。运行队列则可以从 /proc/loadavg 的 1 分钟平均值大致估算。
3.3 内存、磁盘、网络采集要点
内存采集也有一个经典大坑:很多人直接拿 MemFree 判断内存够不够,这会把问题带偏。因为 Linux 的页缓存 buffer/cache 会在需要的时候被回收,MemFree 低不代表内存不够。正确做法是读 /proc/meminfo 里的 MemAvailable 字段,它已经帮我们考虑了一部分可回收页缓存,能更真实地反映"现在到底还能分配多少内存给应用"。
def read_mem_kb(): info = {} with open("/proc/meminfo") as f: for line in f: key, _, value = line.partition(":") if key in ("MemTotal", "MemAvailable", "SwapTotal", "SwapFree"): info[key] = int(value.strip().split()[0]) # unit: kB return info磁盘采集相对复杂,我选择读取 /proc/diskstats。它每一行的字段有固定顺序,第 3 个字段是设备名,第 7 个字段是读完成次数,第 11 个字段是写完成次数,第 13 个字段是 IO 进行中计数,第 14 个字段是设备忙总毫秒数。IO util 可以用两次采样之间那个"忙总毫秒数"差值除以采样间隔毫秒数得到,比如 1 秒内设备忙了 800ms,util 就是 80%。await 可以粗略用读写毫秒总和除以读写次数总和,精度虽然不比专业工具,但足够判断趋势。
网络模块我主要读 /proc/net/dev 算每个网卡的收发包量和丢包数,再从 /proc/net/netstat 里读 TCP 重传计数的差值。吞吐量、丢包率、重传率这三个指标放在一起,能快速判断性能瓶颈是在应用层还是网络协议栈。
3.4 进程级 TopN 和线程识别
进程采集是 REA 最有用的部分,因为系统资源指标只能告诉你"哪个资源有问题",进程指标能告诉你"到底是谁在制造问题"。实现上需要遍历 /proc 下的所有数字目录,读取每个进程的 stat 文件,然后用和 CPU 采集相同的差值逻辑计算进程 CPU 占用。
这里有一个计算细节容易出错:进程 CPU 占比的归一化方式。/proc/stat的第一行是所有 CPU 核的累计值,而单个进程的 utime 和 stime 只算它自己占一个核的时间。如果直接把两者差值相除,在四核机器上会得到一个只有实际值四分之一的数。正确做法是先除以核数:
cpu_pct = proc_delta_ticks / (total_delta_ticks / cpu_count) * 100这样才能得到大家熟悉的 top 命令风格百分比:一个单线程进程跑满一个核,显示 100%,四线程跑满四个核,显示 400%。
线程识别更进一步,扫描 /proc/[pid]/task/ 下的所有线程目录,读取每个线程的 comm 文件和统计信息,找出 CPU 占用高的线程 ID 和名字。Java 应用排障时,这一步尤其重要,因为拿到线程名之后可以直接到线程转储里搜同名线程,快速定位业务代码位置。权限不足时,部分进程的 stat 信息读不回来,REA 会降级提示,不影响其他模块运行。
4. 报告生成、阈值配置与告警
4.1 报告长什么样,瓶颈分怎么算
采集结束后,REA 会输出一份纯文本报告,内容概要是这个样子:
REA Report: 10s @ app-server-01, 17:00:23 System: CPU avg: usr 45% sys 22% iowait 8% Mem: available 2.3G / 7.8G (29%) Disk sda: util 68%, await 92ms Net eth0: rx 120Mbps, tx 30Mbps, tcp retrans 12 Suspicious ranking: 1. Disk sda: util 68% + await 92ms + writes high (score 3) 2. CPU sys: context switch 23k/s (score 2) 3. Mem: swap used 200M (score 1) Top processes by CPU: PID 1023 java 156% PID 3501 log-rotate 45%瓶颈分的计算逻辑在 rules.py 里维护。每个指标都有一个阈值和一个权重,命中一条规则就累加对应权重。如果规则对应的指标实际值超过阈值,还能根据超过比例再加一点分,但我会把加成封顶在 1 分以内,避免某条规则因为极端数值完全掩盖其他问题,让报告变得不可信。分数越高,只是嫌疑越大,不直接等于根因。
4.2 用 YAML 控制阈值和规则
不同机器差异很大,四核开发机和三十二核生产机的上下文切换基数完全不同,机械盘和固态盘的 await 基线也差一个数量级。所以阈值不能写死在代码里,REA 用一个 YAML 文件来管理:
thresholds: cpu_user_high: 70 cpu_sys_high: 25 ctx_per_s_high: 15000 io_util_high: 80 io_await_high: 100 mem_available_low: 20 rules: - name: cpu_user_busy condition: "cpu.user > 70 and loadavg.1min > cpu.num * 1.5" text: "用户态 CPU 占用高,可能是业务计算密集" - name: io_await_busy condition: "disk.util > 80 and disk.await > 100" text: "磁盘队列等待明显,优先看慢查询或日志写入"调优建议很简单:一开始不要设太细,先保证最常遇到的五六条规则能命中,跑一段时间后再根据业务实际表现慢慢加。规则越多,误报率越高,反而会失去"帮你缩小范围"的意义。
4.3 告警输出与对接
报告直接打印在终端里是最朴素的使用方式。REA 也支持--json参数,把同一个报告输出成 JSON,方便外部脚本或者现有监控平台来消费。我们可以把结果推送到群机器人或者写入一个固定的日志文件,由已有的定时任务或日志采集系统去读取。
告警设计上有两个原则:一是同一轮采样只发一次告警,避免同一故障重复刷屏;二是连续 N 轮都命中同一规则才触发推送,这个 N 可以在配置里调整。瞬时毛刺很多,不是每次命中都需要打扰人。REA 的目标是当"第一道过滤器",把真正值得人工下场的信号挑出来。
5. 常见问题与排查技巧实录
5.1 症状速查表
我把平时踩过的坑整理成了一张速查表,REA 跑完之后如果不知道下一步往哪看,可以参考:
| 现场症状 | 先看 REA 的什么指标 | 常见原因 |
|---|---|---|
| 接口偶发抖动 | CPU sys、上下文切换、IO util、网络重传 | 定时任务竞争、短连接、日志刷太频繁 |
| 并发一高就慢 | 运行队列、上下文切换、线程数 | 线程池设置不合理、锁竞争 |
| 内存持续增长 | 进程 RSS TopN、Swap 增长 | 内存泄漏、缓存无限增长 |
| 磁盘写入慢 | IO util、await、写次数 | 日志量突增、快照、文件系统层问题 |
| 容器里应用卡顿 | cgroup 层 CPU/内存限制 | 容器限额不足,而不是宿主不够 |
这张表不能代替根因分析,但能帮你把排查方向从"十个方向"收敛到"两三个方向"。
5.2 CPU 不高但服务卡,先查什么
有一种非常常见的情况:系统整体 CPU 使用率只有 20%,服务却卡得不行。REA 报告里通常能看到sys高、上下文切换高,或者iowait高。也就是说,CPU 不是在忙业务代码,而是在忙内核态工作:频繁调度、频繁切系统调用、频繁等待 IO 完成。
我遇到过一个案例,某个基础组件为了刷新配置,每隔一秒就创建一个临时线程去拉数据。线程本身不消耗多少 CPU,但它会带来大量的线程创建、销毁和调度,把上下文切换数拉得很高,最终拖慢同一进程里其他业务的响应。REA 的线程模块把上下文切换高和某个进程的线程数变化关联在一起,才让我注意到问题来自线程生命周期,而不是某个具体的占用 CPU 方法。
5.3 内存明明剩很多,为什么还在 Swap
很多人看内存只看 MemFree,觉得空闲内存很大,就不该出现 Swap。但 Linux 的内存分配是"尽量用",页缓存会占用大量内存,等到应用真正分配时系统需要先回收页缓存,这个回收过程如果来不及,就会触发 Swap。MemAvailable 这个指标专门用来描述"假设现在立刻申请内存,系统能给你多少",它比 MemFree 靠谱得多。
在容器里还要特别注意:宿主机 MemAvailable 充足,不代表容器可用。如果 cgroup 限制是 4G,应用却想申请 6G,那容器内照样会卡,甚至被 OOM Kill。REA 增加了--container模式,读取 /sys/fs/cgroup 下的内存限制和当前用量,把容器的真实水位和宿主机的状态分开展示。
5.4 容器环境下采集有哪些坑
容器内的 /proc 和宿主机不完全一样,直接套用裸机思路会踩坑。部分容器看不到宿主机的磁盘设备,因为容器有自己的设备视图;CPU 的使用率如果直接读 /proc/stat,看到的是整个宿主机的累计值,而不是当前容器用的额度。正确做法是优先读 cgroup 里的 cpuacct.stat,算容器自身的 CPU 时间差;内存则读 memory.current 和 memory.limit。
REA 默认会探测容器环境,检测到 cgroup 路径后自动切换采集策略。如果没做这一步,结果往往会误导人:宿主机 CPU 很忙,容器应用却什么也没做,或者反过来。容器排障的第一步永远是先搞清楚"这台机器的指标,到底是宿主机还是容器"。
6. 一次真实的排障日志:接口抖动问题
6.1 从 REA 报告里找到异常尾巴
我拿这个思路处理过一个模拟项目 X 的支付回调接口抖动问题。表现是接口每隔几分钟就会耗时超过 1 秒,正常时段只要几十毫秒。监控平台上 CPU、内存、磁盘都看不出明显异常,线上值班同事只能凭经验猜是第三方依赖慢,但查了外部调用也没发现问题。
我跑到现场后用 REA 连续采集了 30 秒,报告里有两行引起了注意:第一行是 CPU sys 占比达到 25%,在整机 CPU 使用率不高的情况下显得有些突兀;第二行是上下文切换达到每秒 1.8 万次,远远高于正常时的两三千。Top 进程里出现了一个负责定时刷新的基础服务进程,CPU 占用不算高,但它名下的线程数在采样期间持续上涨。
6.2 根因确认与解决
顺着线程数异常,我写了一个临时脚本,对这个进程的线程生命周期做了 10 秒快照。结果发现它每秒钟都会新建数十个线程,线程执行完立即销毁。这些线程本身只是在做一次简单的配置文件状态检查,创建和销毁的开销很小,但它们把整个处理器的调度队列搅乱了,导致同机其他线程的响应时间出现抖动。
解决方案也简单:把临时的"每次新建线程"改成使用固定线程池调度,同时把原来每 1 秒一次的刷新改成每 5 秒一次。改动部署后,我再跑了一次 REA,上下文切换回落到每秒 3000 次,接口耗时也恢复到了几十毫秒。整个定位过程算下来不到半小时,如果只看原有监控平台的 CPU 和内存曲线,根本不可能想到问题出在线程生命周期上。
6.3 经验总结
这次排障给我的一个直接启发是:临时诊断工具不能只做"数据采集和展示",还要做"嫌疑排序"。原始指标摆在屏幕上,人脑很难在短时间内把 CPU、内存、磁盘、线程几十个维度交叉起来,但规则引擎可以。REA 看起来只是个普通的资源分析器,实际上它的核心价值是把一个资深运维的排查思路写成了规则。
最后再分享一个小技巧:给这类采样工具写帮助文档时,一定要写明"第一次读数只当基线,第二次才开始出数",并且最好默认把第一次采样隐藏掉。我有好几次看到同事用类似工具,第一行结果异常高,就以为工具写坏了,实际上只是没有做差值初始化。把这一句说明放进输出提示里,能省掉很多不必要的解释。