
最近一直在折腾一台部署了六个业务的服务器每到下午负载就会莫名飙到峰值可每次登录上去只能看到 top 里一片杂乱真正的问题进程像躲猫猫一样藏在一堆无关进程底下。为了解决这个痛点我写了一个叫 rea 的命令行小工具——Resource Efficiency Analyzer用来定时采集进程资源、算效率得分、给优化建议。它不依赖监控平台一条命令跑完就能看到到底是哪个进程在稳定地吃空 CPU 或内存。这篇文章就把 rea 从设计到落地包括那几次让人血压升高的排错过程完整分享出来适合正在做运维、搞脚本或者想自己搭轻量监控的朋友参考。1. 为什么我会写这个叫 rea 的工具运维监控的最后一公里先说一下我遇到的现实问题。大部分人在排查服务器性能问题时肌肉记忆就是登录上去敲 top。top 确实能告诉你在按下按键的那一瞬间谁在最前面但你刚看到某个 Java 服务的 CPU 冲到 120%下一秒它可能就掉下去了你根本不知道这个尖峰是偶发还是周期性出现的。htop 虽然加了颜色和交互排序但也只是换个姿势看同一时刻的数据。pidstat 可以持续采样不过它是“逐进程输出”数据一多就成了流水账没有人帮你把这些数字归纳成一条可执行的结论。重型监控平台我也不止一次接入过。为了看一台机器的负载要专门部署采集器、时序数据库、告警规则和一套 Web UI等到全部跑通可能已经过了大半天。何况很多临时接手的服务器根本没有预留端口和 DNS装 agent 都要跟别人反复确认。所以我需要的不是另一个监控系统而是一个“平时扔在 /usr/local/bin 底下异常时跑一下能直接告诉我重点在哪里”的小工具。rea 就是在这样的背景下被我从零写出来的。1.1 现成工具的两块短板先说第一块短板所有实时交互式工具都缺少“历史对比维度”。top 和 htop 只能展示当前采样周期的瞬时值如果某进程每 10 分钟才抖一次你手速再快也未必能抓到它。更麻烦的是瞬时值会被系统噪声影响比如一次随机的磁盘 IO 等待让某个 Java 进程的 CPU 突然飙到 90%你很难判断这是业务高峰期正常波动还是真的有死循环在空转。第二块短板更隐蔽自动化场景下没有一套统一的“结论语言”。用 pidstat 采到了数据还要人去读、去排序、去汇总用 nmon 还要借助其他工具解析它的日志格式。我希望这个东西跑完以后直接给出一个带着 WARN 或 CRIT 标记的名单这样不管是人看还是脚本后续处理都能第一时间锁定问题簇。这也是 rea 把“效率得分”作为核心输出的原因它不是一个单纯的采样器而是一个带判断力的分析器。1.2 rea 三个核心能力与不做什么rea 这个名字里的 Analyzer 不是随便挂上去的它必须完成三件事第一按固定频率持续采集系统里的进程资源而不是只看某一个瞬间。第二把采集到的原始样本转换成“稳定消耗”和“瞬时尖峰”两类指标也就是计算 P95、P99 这种带统计意义的数据。第三把结果输出成人和机器都能读的格式表格给管理者看JSON 给脚本消费。同时我一开始也列了一个“绝不做什么”的清单。不往时序数据库里写数据、不做 IPC 级别的网络监控、不搞 Web UI、不弄分布式。原因很简单做了这些它就不是 rea 了又会变成一个臃肿的系统和企业里现有的监控生态重复。一个工具能稳定跑十年往往不是因为它功能多而是因为它做的那几件事足够深。砍掉不必要的能力反而是对它最好的维护。2. rea 的核心架构从 /proc 原始数据到效率评分的完整链路rea 的所有样本都来自 /proc这是 Linux 系统状态的核心虚拟文件系统。最初我也图省事想去解析 top -b -n 1 的输出后来发现 top 的输出列会因为终端宽度变化而缩进而且它内部已经做了一层聚合很多原始字段被丢掉了。直接读 /proc 反而更稳定只是要自己处理两次采样的差值。2.1 采集层为什么选择直接读 /proc 而不是解析 topCPU 时间片来源于 /proc/stat 的第一行它把系统里的 user、nice、system、idle、iowait 等状态累加成 tick 值。进程自己的 CPU 时间则放在 /proc/[pid]/stat 的第 14 个字段和第 15 个字段分别表示该进程在用户态和内核态耗掉的 tick 数。一段时间内 CPU 使用率的计算公式为delta_total delta_process_ticks / delta_total_ticks / 核心数 * 100%注意这里要除以核心数因为整个系统的总 tick 是所有核心累加后的数值而进程占用的 tick 是单核视角。我在第一版就犯过这个错误把多核机器上的 CPU 占用率算成了原来的四倍。import os, time def read_cpu_total(): with open(/proc/stat) as f: line f.readline().strip() fields [float(x) for x in line.split()[1:]] return sum(fields) def read_proc_ticks(pid): try: with open(f/proc/{pid}/stat) as f: parts f.read().split() # utime(14) stime(15)索引从 0 计数 return int(parts[13]) int(parts[14]) except (FileNotFoundError, IndexError): return None调用时先读取一次 CPU 总 tick 和进程 tick等待一个采样间隔再读取第二次两次差值相除就得到这个进程在当前间隔内的平均 CPU 使用率。这种方式比解析 top 输出稳定得多因为 /proc/stat 的字段格式由内核固定不会随 locale 或终端宽度变化。整个采集流程可以拆成四步初始化样本桶、记录起始时间点、按间隔周期性采样、计算差值并更新指标。rea 内部没有用多线程而是一个简单的循环因为单线程顺序读 /proc 反而能减少对系统状态的扰动。2.2 计算层为什么核心指标要使用 P95 而不是平均值计算层是整个 rea 的核心因为“瞬时值”和“稳定值”是两个完全不同的事情。举个例子某个后端服务每隔 10 秒做一次全量缓存刷新刷新那一下 CPU 会跳到 180%其余时间只有 5%。如果你用 1 秒采一次样然后算平均得到的结果可能是 22.5%你很难判断它到底忙不忙。但如果你把每次采样得到的 CPU 使用率按从低到高排序看第 95 百分位就会发现那次刷新峰值被保留了下来而偶发的 5% 空闲样本也不会拉低判断。rea 内部维护了一个长度为 30 的环形缓冲每次采样把当前 CPU 使用率推入然后对缓冲内的所有样本计算 P95 和 P99。P95 的含义很简单在最近这段观察窗口里有 95% 的时间它的 CPU 使用率低于或等于这个值。这比平均值更能体现“稳定消耗的上界”。内存使用则直接取当前 RSS 的均值因为内存波动不像 CPU 那样剧烈频繁读 /proc/[pid]/status 反而会增加系统开销。效率评分则是对几个维度做加权映射。我给的默认公式是EFF_SCORE 0.55 * min(100, CPU_P95) 0.30 * min(100, MEM_AVG/2) 0.15 * 运行时长得分。运行时长得分会随着进程存活时间超过 7 天而衰减目的是让那些“一直在重跑、反复出问题”的任务排到前面。这个公式不代表绝对正确但它足够直观而且每个权重都可以通过 rea 的配置文件覆盖。2.3 输出层同一份数据两种消费方式输出层我做了两套视图。默认情况下 rea 会在终端打印一张类似 top 但更聚焦的表格PID USER COMMAND CPU_P95 MEM_AVG EFF_SCORE FLAG 1234 app java -Xmx2g -jar svc-a.jar 68.5 812.4 42.3 4567 root python3 report_generator.py 89.2 110.2 78.1 WARN 8910 app /usr/lib/jvm/.../java 12.3 2044.2 25.4EFF_SCORE 综合了 CPU、内存、存活时间等多个维度分数越高表示“这个地方越值得去查”。光看 CPU 和内存有时候会误判比如某个进程占着 2GB 内存但它是做缓存的另一个进程 CPU 不高但每个请求都卡在 D 状态加权之后前者排名会靠后后者会浮上来。同时 rea 支持 --format json 输出方便 cron 或 CI 消费{ host: legacy-app-03, sampled_seconds: 30, processes: [ {pid: 4567, cpu_p95: 89.2, mem_avg_mb: 110.2, flag: WARN} ] }为了避免 JSON 输出没法直接看rea 的退出码也做了约定0 表示没有异常1 表示存在提示项2 表示有关键告警。这样外部脚本只需要判断 $? 就能决定是否触发下一步动作。3. 实现过程中的关键决策参数设计、阈值判定与扩展性参数设计是整个工具最能体现经验的地方。我见过不少同类工具把采样间隔定死在 1 秒短任务没跑完一个完整周期长任务又浪费资源。rea 开放了两个基本参数让使用者根据实际情况调整。3.1 采样频率和采样次数的取舍rea 的两个核心参数是-i / --interval控制采样间隔默认 1 秒。-n / --count控制采样次数默认 30 次。用 -i 1 -n 30 跑 30 秒适合临时排查问题既能看到 1 秒级别的波动又不会等太久。如果想让 rea 变成一个常驻巡检命令我一般会把它放到 cron 里用 -i 5 -n 288 跑 24 分钟这样 CPU 占用采集能覆盖大多数业务的下一次小时级波动。对于那种开起来几分钟就退出的短任务建议把 -i 降到 0.2 秒并把 -n 提高到 500但注意别在太老的机器上跑否则 rea 自身的读取频率反而会干扰系统状态。场景建议参数原因临时排查单机问题-i 1 -n 30平衡响应速度与样本量常驻巡检、定时报告-i 5 -n 288降低采样对系统的干扰覆盖长期趋势捕捉短生命周期任务-i 0.2 -n 500短任务可能秒级退出需要更高频采样默认值选 1 秒还有一个原因在大多数 Linux 系统上/proc 文件读取的粒度就是 jiffy也就是 10ms 到 1ms 级别太高的采样频率拿到的数据往往会在进程上下文切换时产生锯齿状波动。1 秒间隔已经能平滑掉大部分噪声又不会让用户等太久。3.2 阈值判定静态 80% 是陷阱用“基线变化比”更可靠我第一版做阈值判断的时候很天真认为 CPU 占用超过 80% 就是异常。后来发现某台机器只有 2 核跑一个单机版数据处理程序CPU 长期 90% 以上其实是业务预期反过来一台 32 核机器上某个进程从 2% 涨到 8%占用率绝对值并不高但变化了 4 倍多半是有问题的。rea 因此引入了“历史基线变化比”ratio 当前 EFF_SCORE / 历史 EFF_SCORE 基线默认情况下 ratio 大于 2.0 就给出 WARN大于 3.0 则给出 CRIT。历史基线会存在 ~/.rea/history.json 里同一个主机名、同一个完整命令行路径会归为一个键。这样每次跑的分数才有比较的意义。如果你想临时关掉基线对比可以加 --baseline off。基线文件本身很小但有一个隐藏好处它能记录某个进程在正常时期的稳定消耗。等下次同样的进程悄悄开始异常增长rea 不需要你重新理解业务就能直接给出变化倍数。对没有专职黑盒监控的团队来说这份历史文件就是最轻量级的“黄金指标”。3.3 扩展点数据源接口与最小实现rea 的采集层没有写成一团逻辑而是拆成了数据源接口。每个数据源需要提供 start() 和 sample() 两个方法。start 负责打开文件句柄或准备缓存sample 返回当前时间点的所有指标字典。目前内置了 CPU 数据源和内存数据源以后想加 GPU 使用率只需要实现一个新的 GPUDataSource 并注册到插件列表里主程序的采样框架不变。class DataSource: def start(self) - None: raise NotImplementedError def sample(self) - dict: raise NotImplementedError我刻意没有上依赖注入框架或者 ABC 元类那一套只用一个普通基类。原因很简单这个工具只有两个数据源堆砌设计模式只是给自己找麻烦。未来真到了需要支持 GPU 或磁盘 IO 的时候再按相同接口写一个类接进去就行。插件注册表也只是一个 dict按名称存数据源实例运行到对应阶段时统一调用没有魔法没有框架。4. 实测效果从一台混乱的服务器到一眼定位问题工具好不好用必须拿到真实环境下检验。下面这个案例是我前几天在一台实际上线服务器上碰到的我给它起的主机名叫 legacy-app-03上面同时跑了两个 Java 服务、三个 Python 脚本和一个第三方闭源程序的采集代理。现象是每天下午 14:30 开始 load 慢慢爬到 12但 top 里每次看到的都是不同进程冒头一会儿是 Web 容器一会儿是日志压缩任务一会儿又变成 Python 脚本。整台机器就像堵了车的高架每个路口都亮黄灯。4.1 现场还原6 个业务进程挤在同一台机器上我用 rea 跑了 5 分钟rea -i 1 -n 300 --format table命令执行期间 rea 一共采集了 300 个样本。最突出的不是任何 Java 进程而是 PID 4567 的 Python 脚本 report_generator.py它的 CPU P95 达到了 89.2%而它对应的瞬时 CPU 在 top 里经常只在 5% 和 30% 之间徘徊。原因是这个脚本里有一个 while True 的循环每次处理完一小批数据后会 sleep 0.2 秒但某个第三方数据源的返回偶尔会卡住重试逻辑又写得不对导致循环在无数据时也拼命空转。rea 在最终报告里把 report_generator.py 标记为 WARN因为它的 CPU P95 比历史基线高了两倍多。这个标记给了我们一个很明确的切入点不再需要在六个业务进程之间来回猜测。接下来要做的就是用更细的工具验证这个问题到底出在哪一层。4.2 用 P95 找到嫌疑后再用 pidstat 和 strace 做二次确认rea 的分数只能告诉我们“重点在哪里”并不能直接告诉我们“为什么”。因此定位过程还没有结束。我下一步用 pidstat 对该进程做了逐秒采样pidstat -p 4567 1 60输出里 PID 4567 的用户态 CPU 平均 87.7%内核态只有 1.2%几乎全是用户态空转。再用 strace 观察系统调用strace -f -p 4567 -e tracenetwork,read,write -o /tmp/report_gen.strace跑了 20 秒看到日志里密密麻麻全是 read(0, , 4096) 之类的空读说明它在无数据输入时仍然反复检查标准输入。最后翻代码果然那一段逻辑少了“没有新数据就 sleep 到下一个重试周期”的分支。把 sleep 时间从固定的 0.2 秒改成指数退避之后CPU P95 直接从 89% 掉到 7%机器负载也跟着回落。这给我非常深的一个印象P95 和平均值之间的差异恰恰是很多隐蔽性能问题的照妖镜。如果只依赖 top 的瞬时值你可能永远不知道一个进程其实是在用 90% 的 CPU 假装忙碌。4.3 把 rea 接进 cron 和 CI轻量巡检的两种用法rea 设计成命令行工具最大的好处就是可以被任何调度系统调用。我在那台机器上加了这样一条 cron0 10 * * * /usr/local/bin/rea -i 5 -n 288 --format json /var/log/rea/report.json 21 || exit 2这条任务每天十点跑一次持续 24 分钟把结果落到 JSON 日志里。如果 rea 检测到 CRIT 级别的问题会以退出码 2 结束这时 cron 的 MAILTO 会给我发一封交互提醒。不需要额外的 agent 或数据库。CI 场景也可以复用。我会在构建服务器上跑rea -i 1 -n 60 --baseline off --format json /tmp/rea-check.json然后让脚本从 JSON 里取出 EFF_SCORE 最高的进程如果某个测试进程在构建期间出现 CPU P99 超过 90%就判定为性能回归并失败。这比只看构建时长要准确得多因为有的回归并不会让整体构建延迟太显眼但会造成资源浪费。5. 我在 rea 上踩过的坑以及把它变好用的几条经验最后聊一聊在开发这个工具时遇到的那些坑。每个坑都让我对 /proc 的理解又深了一层也让我意识到一个看起来简单的工具涉及的边界情况远比想象中多。5.1 进程名被截断不要用 /proc/[pid]/comm要用 cmdline第一个坑来自 Linux 内核的 comm 字段限制。/proc/[pid]/comm 只能保存 15 个字符比如 java -Xmx2g -jar 这种长命令行进程comm 只会显示 java多个不同 Java 服务根本分不清。rea 一开始就把这个字段当展示名结果在测试环境同时跑 A 服务和 B 服务时输出表里全是 java、java、java完全没法看。解决方法是优先读取 /proc/[pid]/cmdline把它按 \0 分割后组装成完整命令行然后取第一个参数做 basename如果 cmdline 为空通常是内核线程或僵尸进程才回退到 comm。遇到同名进程还要在末尾追加 pid 后缀。处理后的核心逻辑大概是def process_display_name(pid): try: with open(f/proc/{pid}/cmdline, rb) as f: raw f.read() args raw.split(b\x00) args [a.decode(utf-8, replace) for a in args if a] if not args: raise ValueError cmd os.path.basename(args[0]) return f{cmd}[{pid}] except Exception: with open(f/proc/{pid}/comm) as f: return f.read().strip()这个改动看起来很小却直接影响使用体验。想象一下你在一台跑着 8 个 Java 应用的机器上做排查如果工具把所有进程都标成同一个名字它和 top 就没什么区别了。5.2 进程在采样期间退出了怎么处理才不刷屏第二个坑是进程的生命周期比采样周期短。rea 开始采样时记录了所有存在的 PID但下个采样点可能有进程已经退出此时去读 /proc/[pid]/stat 会得到 FileNotFoundError。最粗暴的做法是直接崩溃但没人想在一个诊断工具里看到 Traceback。我当时的处理是读不到进程文件就跳过同时维护一个“消失进程计数”在最终报告里显示“采样期间有 N 个进程退出”让用户知道这个波动不是测量误差。另外一个更隐蔽的问题是 PID 复用。上一秒进程 A 退出下一秒系统把它的 PID 分配给了进程 B如果你只看 PID 就会认为 A 的 CPU 突然暴涨。rea 的策略是把进程的启动时间/proc/[pid]/stat 第 22 字段starttime保存下来如果同一 PID 的 starttime 变了说明进程已经不是原来那个必须重建样本桶。这个细节花费了不少精力但也确实是区分好坏工具的关键。5.3 容器环境里的 /proc 隔离在宿主上跑才能看到全部进程第三个坑和容器有关。我一开始图方便把 rea 打包进一个很小的容器镜像里直接在 Kubernetes pod 中跑。结果发现它只能看到容器空间里的十几个进程宿主机上其他业务一概看不到。原因很直接没有共享宿主的 /proc容器内的 pid namespace 是隔离的。如果确实要在容器里用需要把宿主 /proc 以只读方式挂载进来并且使用 hostPID例如docker run --rm -v /proc:/host_proc:ro --pidhost rea --proc-path /host_proc我实测这种方式能看到所有宿主进程但要注意某些路径下的信息会因为权限问题出现空缺所以 rea 允许用 --proc-path 覆盖默认的 /proc 路径。更稳妥的做法还是直接在宿主机上运行毕竟它本身占用极小不需要常驻。5.4 保持工具的“小”一条可以随时扔掉的命令说实话rea 到目前为止也只有几百行很多功能是刻意不做而不是想不到。我见过太多从一个小脚本慢慢膨胀成平台的案例最后维护成本远超它节省的时间。如果你想模仿这种思路做自己的巡检工具我的建议是先只做采集和展示不要在第一天加入告警推送和趋势图等你在真实故障里用到它三次以上再根据痛点补功能。工具是拿来解决问题的不是用来晒技术栈的。对我个人来说rea 最大的价值是让我在排查问题的时候多了一个“值得信任的第三方视角”而不是另一个需要伺候的诊断器。