把项目代号定成 rea其实全称是 Resource Efficiency Analyzer。我先承认一点这不是什么大项目只是我为了回答“某个进程这半小时到底干了什么”而写的一个轻量级资源画像工具。它用一个主循环轮询进程的 CPU、内存、IO、线程数、文件句柄统计再把结果落库生成趋势报告全程不要求 root 权限也不需要往目标服务里塞任何探针。起因很简单。之前帮一个后端团队排查问题那个服务每隔一段时间就会出现 CPU 飙到接近 200%但等我们登录机器打开 top 时指标已经回落了屏幕上唯一能做的就是把头一歪盯着刷新页干瞪眼。top 适合看当下却没法回答“十分钟前那波尖峰是怎么起来的”。我也试过 pidstat能看单帧快照但要自己拼日志再画时序图实在太费事。于是我就用半天时间写了 rea。这篇文章适合谁看后端开发、运维、SRE或者任何正在被“内存只涨不降”“CPU 周期性飙高”这类问题折磨的人。我会把 rea 的结构、实现思路、踩过的坑、一次完整定位过程都写出来你完全可以照着做一个自己的精简版。重点不是让代码直接拉起来用而是理解它每一步为什么这么设计。1. 先从“为什么不直接装个监控”说起rea 的诞生动机如果你有全套监控体系看到这篇文章可能会觉得多此一举。但现实是很多团队根本没有统一的 APM 平台就算有对单进程的细粒度排查也未必好使。rea 的目的从来不是替代监控系统而是补上“现场快照”和“事后复盘”之间那段空白。1.1 现成工具到底缺在哪我先把常见工具的特点列一下后面再展开说工具视角是否适合长时间趋势部署成本top / htop单机全局瞬时刷新不适合没有历史记录零成本pidstat单机单进程快照需要自己拼日志很痛苦低Perf / 性能剖析器内核级采样、调用栈适合短时 profiling不适合常驻中高APM 全家桶分布式调用链、指标面板适合集群但太重高需要改造业务Prometheus exporter指标采集、告警适合常驻指标但进程级细粒度不全中top 最大的问题是“过站不留痕”。你看到 CPU 飙了等敲完命令可能已经回落到 5%那波异常就像没发生一样。pidstat 能按进程输出但默认是间隔采样的文本流连续跑半小时会产生大量文件不好归并成曲线。Perf 很强能拿到内核视角的调用栈但它更适合“一段时间的深度剖析”而不是 7×24 小时挂着当哨兵。APM 系统适合宏观趋势但为了查一个内部服务的资源抖动引入探针和 Agent 的成本实在让人犹豫。我也想过直接用 ps 循环抓但 ps 每次执行都要 fork 一个进程采样频率一高反而干扰目标机器频率低又抓不到瞬时尖峰。最后决定自己写一个 Python 脚本所有数据都在当前进程内读取不开子进程把采样和计算拆成独立的模块。1.2 项目目标轻量、单文件可跑、结果自解释rea 的设计目标很简单不侵入目标进程只读/proc或系统接口不需要 root普通用户能跑输出一份人类能直接看懂的 Markdown 或 CSV 报告采样完自动退出也能常驻后台持续监控单文件脚本优先方便丢到任意服务器执行我没有把“Web 控制台”“仪表盘”这类功能纳入第一版因为排查问题时最大的摩擦力不是缺少图表而是频繁确认“我现在的数据是否能复现当时的问题”。一份带时间戳的 CSV 配合简单绘制脚本已经能解决九成场景。1.3 为什么用 Python 而不是 Go 或 Rust选型时被问最多的问题就是“Python 这么慢能采准吗”我理解这个顾虑但也要看清应用场景。rea 做的是低频轮询默认 2 秒一次采样每次读几个/proc文件。这种负载下Python 进程自身的开销可以控制在 1% CPU 以内完全不影响目标指标的准确性。用 Python 的好处是迭代快psutil库把/proc的底层细节都封装好了几行就能拿到进程 CPU、内存、IO、线程数。对于需要快速部署、临时排查的工具开发效率远比纳秒级性能更重要。如果真要在生产环境做 10ms 级高精度采样那我建议直接上 Go 或 eBPF 工具而不是用脚本语言硬顶。2. rea 的三层实现拆解采集、归并、报告rea 整体上分成三块采集器负责从系统拿原始数据存储层负责把数据变成时间序列分析器负责把数字翻译成“有问题 / 没问题”的结论。每一层都不复杂但边界要清晰否则后面加功能会很痛苦。2.1 采集器psutil 的 oneshot 模式是个关键细节采集层最核心的代码长这样import psutil import time def sample_process(pid: int, create_time: float): try: p psutil.Process(pid) # 校验 PID 是否复用 if p.create_time() ! create_time: return None with p.oneshot(): cpu p.cpu_percent(intervalNone) mem p.memory_full_info() io p.io_counters() threads p.num_threads() fds p.num_fds() if hasattr(p, num_fds) else 0 conns len(p.connections(kindinet)) return { ts: time.time(), cpu_percent: cpu, rss: mem.rss, vms: mem.vms, uss: mem.uss, read_bytes: io.read_bytes, write_bytes: io.write_bytes, threads: threads, fds: fds, conns: conns, } except (psutil.NoSuchProcess, psutil.ZombieProcess, psutil.AccessDenied): return Noneoneshot()是个很实用的接口。没有它的话每调用一个p.cpu_percent()、p.memory_info()都会重新读取一次/proc/[pid]下的文件相当于同一秒内对同一个进程做了多次系统调用。开启 oneshot 之后所有这些属性会基于同一批内部数据计算出来既减少开销又保证数据一致性。cpu_percent(intervalNone)返回的是自上次调用以来的 CPU 使用率不是瞬时值。这个接口在第一次调用时一定会返回 0.0因为缺少“上一刻”的基准点。我用下面的方式处理p.cpu_percent(intervalNone) # 预热一次丢弃结果否则你会看到采样的第一帧总是 0%横在曲线上非常难看。2.2 存储层为什么最终选了 SQLite 而不是 CSV第一版我直接往 CSV 里写表面上看没问题但跑一段时间就发现两个痛点一是排查时经常需要按时间段过滤CSV 得全量载入内存二是进程退出后 PID 被回收历史记录难以做二次分析。后来我改成 SQLite单文件、零配置、Python 标准库自带sqlite3不需要额外安装服务。表结构也很简单CREATE TABLE metrics ( ts INTEGER NOT NULL, pid INTEGER NOT NULL, name TEXT NOT NULL, cpu REAL, rss BIGINT, vms BIGINT, uss BIGINT, read_bytes BIGINT, write_bytes BIGINT, threads INTEGER, fds INTEGER, conns INTEGER ); CREATE INDEX idx_metrics_ts ON metrics(ts); CREATE INDEX idx_metrics_pid ON metrics(pid);写入时用executemany批量插入每批 50 条左右落盘压力很小。查询端则只需要一条 SQL 就能拿到时间区间内的趋势SELECT ts, cpu, rss, uss FROM metrics WHERE pid? AND ts BETWEEN ? AND ? ORDER BY ts;比起 CSVSQLite 的好处是查询干净、支持并发连接后续还能直接接 Grafana 的数据源。2.3 分析器和报告器把数字变成结论光有数据还不够一个没有结论的工具用起来总觉得缺了什么。rea 内置一个简单的分析器主要做三件事计算曲线平均值和方差识别异常抖动对比前后窗口的斜率判断“持续上涨”还是“瞬时尖峰”触发阈值后输出高亮标记默认规则是CPU 连续 5 次采样超过 80%或 RSS 在 10 分钟内增长超过 20%就标记为疑似异常。报告器输出两类东西终端里打印实时表格结束时再生成一份 Markdown 报告包含曲线数字摘要、异常时段、对应进程名与 PID。有个容易被忽略的点进程名会变化但 PID 不变。报告里我同时保存了pid和name方便事后按名称模糊搜索否则进程重启之后就很难对上号。3. 采样过程中最容易翻车的三个坑写 rea 的初期我踩过几个很隐蔽的坑每一个都直接导致数据失真。如果你打算自己实现类似工具下面三条请务必注意。3.1 预热与间隔CPU 百分比最容易采集出“假数据”psutil.Process.cpu_percent(intervalNone)的机制是“计算两次调用之间的差值”。第一次调用永远返回 0.0跟实际负载毫无关系。如果采样循环里不做预热整条曲线就会比真实负载“低半拍”。更麻烦的是cpu_percent(interval1.0)这种写法。它内部会阻塞当前线程 1 秒让整个采样循环卡在那里。如果你同时还想采集内存和 IO这种阻塞式调用会把采样节奏彻底打乱。正确做法是第一次调用cpu_percent(intervalNone)做预热之后每次都用intervalNone的非阻塞模式主循环用time.sleep(interval)控制统一节奏这样所有指标都在同一个瞬间完成快照时间戳对齐分析时才不会出现“CPU 是 10 秒前的内存是 1 秒前的”这种尴尬情况。3.2 PID 复用你以为的进程可能已经不是它了Linux 下 PID 是会复用的。一个进程退出后新起的进程完全可能拿到同一个 PID。如果你的采样逻辑只按 PID 过滤就会把两个不同进程的数据混在一起画出来的曲线完全没法解释。解决方案是同时记录进程的create_time()单位是秒源于系统启动后经过的秒数。每次采样前先比对当前进程的创建时间与最初的创建时间不一致就丢弃并标记该 PID 已失效。if p.create_time() ! expected_create_time: logger.warning(fPID {pid} 已被复用停止采集) return None这种方法配合psutil.NoSuchProcess异常基本可以保证数据归属正确。3.3 RSS 会“骗人”看 USS 才能定位真实内存增长排查内存问题时最容易被误导的指标就是 RSS。RSS 包含了进程私有的内存和共享的库页面。比如两个进程用了同一个动态库库的物理页面是共享的RSS 在统计时会把完整的库大小算在你的头上。在容器环境里这种偏差尤其大。如果我用 RSS 画内存曲线经常看到曲线整体抬高一大截但实际私有内存并没有增长多少。此时应该看 USSUnique Set Size也就是进程真正独占的物理内存部分。psutil 里对应的是memory_full_info().uss底层从/proc/[pid]/smaps中汇总而来。访问它比memory_info()稍慢一点但对于秒级采样完全不是问题。默认情况下 rea 同时采集 RSS 和 USS分析时优先看 USSRSS 只作为辅助参考。info p.memory_full_info() rss info.rss # 包括共享页面 uss info.uss # 仅私有物理内存我在实战部分会演示一个例子只看 RSS 会得出“内存没涨多少”的错误结论但 USS 曲线已经明显阶梯式上升这才是真正的泄漏。4. 拿 rea 定位一次“内存只涨不降”的完整过程理论讲再多不如直接还原一次排查经历。下面这个案例是我调了很久才发现问题所在的可疑场景细节已经做过脱敏但排查链路是完全真实的。4.1 现场现象某服务进程的 RSS 在运行 6 小时后达到 3.2GB重启后降到 400MB再过 6 小时又涨回 3GB。业务上没有任何明显的流量高峰代码最近也没有关于内存的改动。最迷惑人的是CPU 曲线很平几乎看不出异常。第一步我没有直接上 rea而是先简单验证了一下基线手动重启进程记录启动时的 USS 大约 380MB。然后每隔 5 分钟用ps -o pid,psr,rss,vsz抓一次发现 RSS 确实在涨但涨的节奏不是线性而是每隔 10 分钟左右突然跳升 100MB然后平台期。这种“台阶式”增长比线性增长更容易定位因为它暗示有周期性任务在批量创建对象。4.2 用 rea 做长时间采样我写了一个 30 分钟的采样任务间隔 2 秒python rea.py --pid 12345 --interval 2 --duration 1800 --output report.md跑完后打开报告里的 USS 趋势。为了兼容没有图形界面的环境rea 会输出一个 ASCII 缩略图同时把数据导出 CSV方便本地拉下来用任意工具画图。USS 曲线的关键特征是每 5 分钟出现一次陡峭上升之后平台期几乎一动不动。RSS 曲线同样上升但叠加了不少毛刺不干净。这个对比立刻排除了“操作系统缓存影响”的嫌疑因为如果是页缓存或者共享库加载造成的USS 不会同步上涨。4.3 缩小范围先看线程和 FD再看调用栈看到 USS 周期性上涨后我调出了 rea 顺带保存的线程数和文件描述符数。线程数一直稳定在 50 个左右但 FD 数也在同步台阶式上涨每次上升大约 30~40 个。此时基本锁定方向有周期性任务在持续创建新对象既包括内存对象也占用了文件描述符。这类问题的常见原因包括某个定时任务不断向全局字典里写数据从不清理连接池在异常时不断创建新连接旧的没释放缓存组件开启后写入量超过淘汰量我用py-spy dump --pid 12345抓了两次栈时间点分别在 FD 上升前和上升后。通过对比栈顶内容发现周期性任务每次都往一个全局配置缓存里追加小对象而配置缓存没有设置上限。问题代码在业务初始化阶段把“全量配置预热”写成了“每次更新都往缓存里 add”预期只跑一次的逻辑落到了定时任务里。4.4 修复与验证修复方式很简单把追加改成覆盖式写入并给缓存增加容量上限。重新部署后我又跑了一次 30 分钟采样USS 曲线从 800MB 涨到 810MB基本持平FD 数在 50~60 之间波动不再阶梯攀升报告标记为“无异常”整个过程从定位到验证最大的价值在于 USS 和 FD 两个指标帮我们把“内存泄漏”和“资源未释放”这两个可能原因快速分开。没有这两个维度的曲线你很可能会在两个方向上反复试错。5. rea 的边界与可扩展方向工具做得再顺手也要清楚它的适用范围。rea 在“单机单进程”场景下很好用但它天然不适合做全局监控也不适合做混合链路归因。5.1 它不适合做什么容器编排平台级监控有 Prometheus 这类成熟方案内核级热点定位需要 perf、eBPF 这些更深层的工具分布式调用链分析rack 或领域专属 tracing 系统更合适判定标准很简单当你需要关注多台机器、多个进程的关联关系时单机采样脚本的体量就不够了。它更适合在问题发生时快速挂载到具体机器上以最小成本回答“是不是这台机器、这个进程、这个时间段的资源行为有异常”。5.2 未来扩展时我会优先考虑的方向用下来的经验是真正能提升 rea 实用性的是下面三点第一存储层改成标准时序数据库接口。现在 SQLite 适合单机但如果要多台机器一起分析我会选择 InfluxDB 或 Prometheus remote write这样可以直接复用 Grafana 做展示。第二增加更灵活的告警规则。现在的规则是写死的CPU 连续 5 次超过 80%RSS 十分钟内增长 20%。实际场景中阈值差异很大最好是让用户通过配置文件指定规则甚至可以自定义一段 Python 表达式。第三采集频率自适应。默认 2 秒采一次但如果目标进程的负载很高采集本身也会对系统产生压力。可以设计成CPU 正常时拉长到 5 秒接近阈值时自动缩短到 1 秒。这种动态调整既降低长期开销又能在异常时保持足够的采样密度。5.3 给想自己实现的人一些提醒如果你决定用半天时间写一个精简版 rea我给三条经验采样线程和报告输出线程要分离。我第一版把输出写文件放到主循环里结果磁盘慢的时候拖慢了采样节奏数据时间戳出现明显间隙。注意时区问题。采集的时间戳统一用 Unix 时间戳存储显示层再做格式化。本地时间 服务器时间混在一起排查跨时区问题时会产生不必要的混乱。采样间隔不要低于 200ms。间隔太短会放大系统的瞬时噪音比如 GC 暂停、调度切换都会让曲线变得非常毛躁反而不利于判断趋势。除非你在做极短窗口的实时分析否则秒级采样足够。最后再分享一个实际操作中的习惯rea 每次跑完我都会把生成的 Markdown 报告连同当时的代码版本号一起归档。等第二次、第三次出现类似问题时直接对比历史报告就能快速判断是新增回归还是老问题复发。你不需要一个多么“智能”的工具只要能让数据有迹可循排查效率就已经提升一大截了。