Linux 内存压力与 PSI:Pressure Stall Information 的诊断实践

为什么需要 PSI:从「还剩多少」到「停了多久」

Linux 内存压力与 PSI 的诊断实践

传统内存监控回答的都是「还剩多少」:free 看 available,vmstat 看 si/so,监控平台给 swap 换页速率设个阈值。这套思路有一个根本缺陷——剩余量不等于痛苦程度。一台 available 只剩 300MB 的机器,如果只是文件页缓存被挤掉、没有任何任务因为等内存而停顿,业务完全无感;反过来,available 还有 2GB 的机器,也可能因为持续的直接回收(direct reclaim)让每个请求都在页分配上排队等内存。监控真正想捕捉的是时间:系统里的任务到底因为等待资源而浪费了多少执行时间。PSI(Pressure Stall Information,压力停摆信息,Linux 4.20 引入)把这个量做成了内核原生的计量:调度器在任务休眠与唤醒的路径上识别「这次停顿是不是在等内存 / IO / CPU」,把停摆时间按 10 秒、60 秒、300 秒三个滑动窗口折算成占比输出。它测的是痛苦本身,而不是痛苦的间接指标——Meta(Facebook)最早用它替代「看 free 排障」的传统,Kubernetes 的节点压力驱逐体系也在把 PSI 纳入驱逐信号,systemd 把它做成了 oomd 止损守护进程的触发器。对应用工程师,这是内存监控这些年最重要的一次范式切换:从「库存管理」切换到「体验管理」。

flowchart TD
    A["任务申请资源<br/>分配内存 / 等待 IO / 排队 CPU"] --> B{"资源立即可得?"}
    B -- "是" --> C["正常推进<br/>不计入 PSI"]
    B -- "否,进入等待" --> D["调度器把任务标记为停摆"]
    D --> E{"此刻还有其他任务在运行吗?"}
    E -- "有 → some 停摆<br/>局部痛苦,系统仍在前进" --> F["累计进 some 计数"]
    E -- "没有 → full 停摆<br/>全局停摆,真事故" --> G["同时累计进 some 与 full"]
    F --> H["/proc/pressure/memory · io · cpu<br/>some/full × avg10 · avg60 · avg300 · total"]
    G --> H

some 与 full:两种粒度,三种资源

PSI 有两条正交的维度。第一条是停摆广度,用 some 与 full 两个档位表达:some 表示「至少有一个任务因为等资源而停摆」——局部痛苦,系统里还有别的任务在推进;full 表示「所有非自愿运行的任务都在等同一个资源」——全局停摆,就算抢到 CPU 也没活可干。两者的包含关系是 full ⊆ some:full 计时的每一刻 some 也在计时,反过来不成立——some 高而 full 低,说明部分任务受影响但系统整体仍在前进。监控策略因此分两层:some 是「早发现」信号,full 是「真事故」信号。第二条维度是资源类型:CPU、memory、IO 各有独立的压力文件与独立的 some/full 统计。CPU 只有 some 有意义——分时调度里「所有任务都在等 CPU」不成立(排队总有任务在跑),新内核输出的 cpu full 行恒为零,只为统一解析格式;memory 的 full 是内存问题的最高级信号,意味着内核把能回收的都回收完了、任务连回收等待都过不去;IO 的 full 意味着所有任务都在等块设备,常见于存储饱和或触顶限流。三资源 × 双粒度 × 三窗口展开有 18 个观测量,日常真正要盯死的核心是四个:memory 的 some/full 与 io 的 some/full。

读取与解读:/proc/pressure 的真实输出

接口就是三个文本文件,cat 即读:

$ cat /proc/pressure/memory
some avg10=24.11 avg60=9.86 avg300=2.41 total=2149583601
full avg10=7.98 avg60=3.02 avg300=0.61 total=708923177
$ cat /proc/pressure/io
some avg10=0.52 avg60=1.13 avg300=0.44 total=96119092
full avg10=0.00 avg60=0.21 avg300=0.08 total=18341812
$ cat /proc/pressure/cpu
some avg10=35.44 avg60=28.71 avg300=12.50 total=477218651
full avg10=0.00 avg60=0.00 avg300=0.00 total=0

每行两段:avg10/avg60/avg300 是对应窗口内「至少一个任务(some)」或「全部任务(full)」处于停摆的时间占比;total 是开机以来累计的停摆时长,单位微秒。上面这份样例读出来就是:过去 10 秒里 24% 的时间至少有一个任务在等内存,其中 8% 的时间是全局停摆——这是正在发生的内存事故;IO 的 some 同时抬到 0.5% 以上,通常伴随内存压力出现(回收脏页要写盘);CPU 的 some 35% 只说明机器忙,与故障无关。解读经验有三条:avg10 看当下、avg300 看趋势——avg10 突刺而 avg300 平稳是瞬时毛刺(GC、备份批处理),avg300 持续抬升是趋势性问题(内存泄漏、缓存失配);full 出现非零值就该立刻介入;total 留给事后复盘——对比事故前后差值,就是这次事故浪费掉的机器时间。

PSI Trigger:把轮询变成事件驱动

轮询的痛点是采样间隔与响应延迟的折中:10 秒采一次最多错过 10 秒,1 秒采一次监控本身开始消耗资源。PSI 提供内核级的触发器机制:往 /proc/pressure/资源 写一行「类型 阈值微秒 窗口微秒」,内核在该资源的窗口内停摆时长越过阈值时,通过这个文件描述符的 poll 返回 POLLERR 通知用户态——监听进程平时完全休眠,零轮询成本。约束有三条:窗口下限 500ms;每个资源每种档位最多注册两个触发器;触发后自动重新武装,窗口滑出阈值即恢复待命,可以再次触发。

# psi_watch.py —— 常驻触发器:窗口 1 秒内内存 some 停摆 ≥ 50ms 即告警
import os, select, time

fd = os.open('/proc/pressure/memory', os.O_RDWR)
os.write(fd, b'some 50000 1000000')   # 类型 some,阈值 50000us,窗口 1000000us
while True:
    select.select([fd], [], [])       # 平时阻塞,触发时 POLLERR 唤醒
    line = os.pread(fd, 128, 0).splitlines()[0].decode()
    print(time.strftime('%F %T'), '内存 some 压力越限:', line.strip())
    # 生产上这里接分诊动作:抓 Top 内存进程、dump memory.current、通知值班

触发器的正确用法是「把告警推到现场」:生产机常驻一两个触发器(比如 memory some 50ms/1s 与 memory full 100ms/1s),触发瞬间自动抓取现场证据——/proc/pressure 全量、Top 内存进程表、所属 cgroup 的 memory.current 与 memory.events——事故复盘不再缺第一现场。这是它比监控轮询不可替代的地方:轮询看到的是「事后曲线」,触发器拿到的是「事中快照」。

systemd-oomd:PSI 的第一个生产级消费者

触发器机制最著名的消费者是 systemd-oomd:它常驻监控各 cgroup 的内存压力(读的是 cgroup 维度的 pressure 文件,与 /proc/pressure 同一套机制),某 cgroup 的内存压力越限并持续一段时间,就向内核申请终止这个 cgroup——而不是等内核 OOM Killer 在全系统范围里挑牺牲品。它要求 cgroup v2 统一层级与 PSI 开启,Ubuntu 22.04 起默认启用。配置分全局与单位两级:

# /etc/systemd/oomd.conf(节选,取值为示例)
[OOM]
SwapUsedLimit=90%                        # swap 用到 90% 即动作
DefaultMemoryPressureLimit=10%           # 内存压力阈值
DefaultMemoryPressureDurationSec=20s     # 越限需持续的时间

# 单位级覆盖:给具体服务挂更紧的止损(也可用 systemctl set-property)
[Service]
ManagedOOMMemoryPressure=kill
ManagedOOMMemoryPressureLimit=5%

动作发生时,日志像一份判决书——谁被杀、因为哪个 cgroup、压力是多少、超阈值多久,一行写全:

$ journalctl -u systemd-oomd --since -1h | grep -i killed
Nov 05 14:22:31 host systemd-oomd[812]: Killed /system.slice/myapp.service due to memory pressure for /system.slice/myapp.service being 12.03% > 10.00% for > 20s

与内核 OOM Killer 的分工:OOM Killer 是内存真正耗尽(回收无可回收)时的最后保险,视野是全系统;systemd-oomd 是压力越限时的主动止损,视野可以收窄到单个 service/slice,终止后 systemd 还能按 Restart 策略自动拉起。运维要点:阈值要结合自身基线调(见后文),过紧会出现「高峰期被误杀」——oomd 杀的是「压力最大」的 cgroup,不一定是「罪魁」。

与传统工具的对比:free、vmstat 与内核 OOM Killer

工具 回答的问题 关键盲区
free -h 还剩多少可用内存 分不清「被页缓存占着」与「真正紧张」;available 低不等于任务在停摆
vmstat 的 si/so 换页活动量 换页发生不等于系统痛苦;空闲页回收也会产生换页流量
内核 OOM Killer 内存耗尽时杀谁 最后保险,触发前系统往往已停摆很久;牺牲者未必是压力源
PSI 任务因等资源停摆了多久 不指认「谁」在等,定位仍要靠 top 与内存分析;内核 4.20+ 才有

三条结论。第一,PSI 与旧工具是互补不是替代:PSI 负责告警与「痛不痛」的判定,free/ps/内存分析负责定位「谁在痛」,监控大盘上 PSI 是第一行,排障命令行里 free 是第一步。第二,告警直接用 PSI,不要用内存水位:水位告警的误报率(缓存波动)与漏报率(水位健康但持续回收)都很高,PSI 的停摆占比与业务痛感的关联是直接的。第三,OOM Killer 依然要懂:oom_score_adj 调整牺牲者优先级、组内 OOM 与全局 OOM 的区别、dmesg 里 OOM 报告的读法——PSI 体系把 OOM 从「常客」变成「罕见事故」,但罕见事故发生时你依然需要读懂它。

监控接入:node_exporter 指标与告警规则

node_exporter 自带 pressures 采集器,指标族是 node_pressure_*:waiting 对应 some 的累计停摆秒数,stalled 对应 full(node_pressure_memory_waiting_seconds_total / node_pressure_memory_stalled_seconds_total,IO 与 CPU 同构)。PSI 的 avg 窗口在 PromQL 里用 rate 自己算——rate 就是「单位时间停摆占比」:

groups:
  - name: psi-memory
    rules:
      - alert: MemorySomePressureHigh
        expr: rate(node_pressure_memory_waiting_seconds_total[5m]) > 0.1
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "内存 some 压力 >10% 持续 10 分钟({{ $labels.instance }})"
      - alert: MemoryFullStall
        expr: rate(node_pressure_memory_stalled_seconds_total[5m]) > 0.05
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "内存 full 停摆 >5%:系统级停摆,立即介入({{ $labels.instance }})"

Grafana 面板的标准画法是 some/full 两条曲线乘以 rate(1m),再用 rate(5m)/rate(15m) 折出趋势参考线;语义约定俗成:some 抬升先看趋势线决定轻重,full 出峰直接转值班。若指标缺失,排查顺序是:内核 PSI 是否开启(ls /proc/pressure)→ node_exporter 是否禁用了 pressure 采集器 → 版本是否够新(pressures 采集器 0.19 起内置)。

工程实践:基线、分级与止损动作

PSI 是相对指标,没有基线就没有阈值。不同业务的正常态天差地别:编译/构建机的内存 some avg10 常态就在 5%~10%(编译器密集分配与回收是「健康的痛」),数据库主机的 some avg10 常态低于 1%,跑批机器的 IO some 常态偏高也属正常。落地步骤:新业务上线先只观测一周,记录 some/full 三个窗口的 p50/p95 分布,以「常态 + 余量」定阈值,而不是抄别人的数字。告警分级建议:warning 用 some avg10 持续越限(10 分钟以上),critical 只留给 full(avg10 > 1% 即可警惕,> 5% 直接 page 值班);avg10 的瞬时刺突只记事件不告警,避免告警疲劳。止损动作要预先演练:PSI 告警的 runbook 第一行永远是「看谁在停摆」——按第 1、2 篇的工具链看进程与内存回收状态、看 cgroup 的 memory.events——而不是条件反射式重启。最后是开启条件:/proc/pressure 不存在时,确认内核 4.20+ 且 CONFIG_PSI=y,部分发行版默认关闭、需在启动参数补 psi=1(检查 /proc/cmdline);CentOS 8+/Ubuntu 20.04+ 的内核都已带上。

为什么你的应用会受这个影响

四条落地反射。其一,排障第一步加一条命令:cat /proc/pressure/memory——它 3 秒内告诉你「有没有人真的在等内存」,避免团队在 free 的 available 数字上空转争论。其二,容量评估从「剩多少」换成「停多久」:压测报告里 PSI 的 some 曲线比内存水位线更能说明容量上限——some 的拐点就是该扩容的信号,水位线只告诉你「快满了」,不告诉你「已经卡了」。其三,给批次任务设独立 cgroup 加压力阈值:编译、备份、报表这类内存大户单独成组,挂上 systemd-oomd 的 ManagedOOMMemoryPressure,失控时止损范围就是它自己,不再连坐整机。其四,老内核别硬来:PSI 是 4.20+ 的能力,CentOS 7(3.10 内核)没有它,监控退回 vmstat 加 swap 监控即可——用户态工具造不出内核没统计的停摆数据。

小结

PSI 的核心价值:它回答的不是「还有多少内存」,是「系统因为内存而浪费了多少时间」——等待时间比剩余量更准确——some/full 两粒度 + 三窗口设计让内存监控从「被动 OOM」变成「主动预防」——检查你的系统 cat /proc/pressure/memory。

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?