一个批处理任务把整机拖垮,复盘通常只写"资源不足"。真正发生的是三件不同的事:抢走 CPU 时间片、吃光页缓存逼别人读盘、把磁盘队列堵成长龙。三者的信号、手段、代价完全不同,用同一种"加机器"应对只是把问题推后。
这篇按争抢形态拆判断链:什么信号 → 上哪一层手段 → 这层能保证什么、保证不了什么。
1. 三种争抢形态与各自的信号
| 形态 | 机制 | 直接信号 | 常见误判 |
|---|---|---|---|
| CPU 抢占 | 时间片按权重分配 | %steal 高、run queue 长 |
把 load 高当成 IO 问题 |
| 内存压力 | 页缓存回收、swap、OOM | memory.current 逼近上限、memory.events 计数 |
只看 free 认为"内存够" |
| IO 排队 | 请求在块层排队 | 块设备 await 与队列深度 | 用 iowait 判断 IO 饱和 |
load average 包含 D 状态任务,会被 IO 与内存回收污染——load 高推不出 CPU 忙。反过来 IO 打满而 CPU 有活干时 iowait 反而不高,它统计的是"CPU 空闲且有未完成 IO"。
2. PSI:比 load 更早的信号
PSI 直接度量"任务因资源不足无法推进"的时间占比,是判断要不要隔离的第一手依据:
$ cat /proc/pressure/cpu /proc/pressure/memory /proc/pressure/io
some avg10=12.40 avg60=8.10 avg300=5.32 total=...
full avg10=2.10 avg60=1.05 avg300=0.84 total=... # cpu 无 full 行
| 文件 | some |
full |
读法 |
|---|---|---|---|
cpu |
至少一个任务在等 CPU | 不存在 | 持续个位数百分比即份额不够 |
memory |
有任务在等回收 | 全部任务卡住 | full 出现即全局停顿 |
io |
有任务在等 IO | 全部任务在等 IO | 与 await 一起看 |
cgroup v2 下每个 cgroup 也有 cpu.pressure 等文件:按 cgroup 看 pressure 才知道谁在挨饿,全局文件只说明整机有人在等。
3. 四层手段与升级判据
| 层 | 手段 | 保证 | 保证不了 | 升级判据 |
|---|---|---|---|---|
| 1 优先级 | nice / ionice |
CPU 权重、IO 类 | 内存、页缓存、峰值 | 只影响"分多少" |
| 2 cgroup | cpu.max、memory.max、io.max |
硬上限与份额 | 内核开销、缓存争用 | 需按服务限额 |
| 3 容器 limit | 编排层 limits | 单容器边界 | 节点级干扰、超卖 | 多服务混布 |
| 4 物理隔离 | cpuset、NUMA、独立盘 | 缓存与带宽 | 成本、弹性 | 延迟敏感 |
判据是代价递增:nice 零成本不保证,cgroup 有观测成本但可执行,容器 limit 需编排配合,NUMA 与独占盘直接花钱。常见错误是跳过 2、3 直接买机器。
4. nice 与 cpunice 的实际效果边界
| 项 | 事实 | 结论 |
|---|---|---|
| 作用对象 | 仅 CFS 的 CPU 分配 | 对 IO、内存、页缓存无效 |
| 权重关系 | 每 +1 nice 权重约乘 0.8,5 级约差 4 倍(按内核权重表核对) | 差 5 级才明显 |
| 空闲时 | 低优先级仍能跑满 | nice 不能限速 |
| 叠加 cgroup | v2 里与 cpu.weight 同级换算 |
别两处都调 |
# 只在"有多余 CPU"时让出,不构成任何上限
$ nice -n 19 ./batch.sh
$ ionice -c 3 -p $(pgrep -f batch.sh) # idle 类,仅在无其他 IO 时执行
$ chrt -b 0 ./batch.sh # SCHED_IDLE,比 nice 19 更彻底
SCHED_IDLE 是更强的"只捡空闲"语义,适合纯后台任务,同样不限 IO 与内存。用 nice 治 IO 干扰,是把油门当刹车。
5. cgroup v2 的三个关键文件与语义
| 文件 | 语义 | 需竞争才生效 | 典型误用 |
|---|---|---|---|
cpu.max |
$QUOTA $PERIOD,200000 100000 即 2 核 |
否,硬顶 | 只设 cpu.weight 以为有上限 |
cpu.weight |
1–10000,默认 100 | 是 | 独占机器时设了没用 |
memory.max |
硬上限,触顶组内 OOM | 否 | 设成峰值,波动即 OOM |
memory.high |
软限,超后节流回收 | 否 | 与 max 同值,失去缓冲 |
io.max |
MAJ:MIN rbps/wbps/… |
否 | 以为能管住所有 buffered 写 |
# 直接走 cgroupfs(v2 统一层级)
$ echo "200000 100000" > /sys/fs/cgroup/batch/cpu.max
$ echo 2G > /sys/fs/cgroup/batch/memory.max
$ echo 1500M > /sys/fs/cgroup/batch/memory.high
$ echo "8:16 rbps=104857600 wbps=52428800" > /sys/fs/cgroup/batch/io.max
memory.high 常被忽略:它让超限的组先被回收节流(变慢)而非直接被杀。给突发留缓冲的姿势是 high 略低于 max。
6. 设了 limit 不等于设了保障
| 想法 | 实际 | 需要的配置 |
|---|---|---|
设了 cpu.max 就有保障 |
只给上限,竞争时仍可能拿不到 | 配 cpu.weight 提份额 |
设了 memory.max 就不被饿 |
保证不吃超,不保证不被回收 | memory.min/low 护工作集 |
| 设了容器 limit 就隔离 | 节点缓存、内存带宽仍共享 | cpuset 或物理隔离 |
$ echo 512M > /sys/fs/cgroup/web/memory.min # 不可回收的保护量
$ echo 1G > /sys/fs/cgroup/web/memory.low # 低水位,压力下优先保留
limit 是天花板,reservation 是地板。 只有天花板时,压力下被牺牲的总是没有地板的一方——这就是"没超 limit 却被拖慢"的来源。
7. 内存压力的连锁反应与 OOM 选谁
顺序几乎固定:页缓存回收 → direct reclaim(延迟陡增)→ 匿名页换出 → 分配失败 → OOM。
| 阶段 | 信号 | 处理 |
|---|---|---|
| 页缓存回收 | file 下降、workingset_refault 上升 |
降 memory.high |
| direct reclaim | memory.pressure 的 full 抬头 |
给关键组 memory.low |
| swap 抖动 | memory.swap.current、pswpout 上升 |
限 memory.swap.max |
| OOM | memory.events 的 oom_kill |
调 oom_score_adj 或 oom.group |
OOM killer 按 oom_score 选择:以进程内存占用(RSS + swap + 页表)归一化打分,再由 oom_score_adj(-1000…1000)调整。容器里的差别是选择范围被限制在 cgroup 内,且整组可能一起被杀:
$ cat /proc/<pid>/oom_score # 当前分数,越高越先被杀
$ echo -500 > /proc/<pid>/oom_score_adj # 保护关键进程(不能到 -1000)
$ echo 1 > /sys/fs/cgroup/batch/memory.oom.group # 整组一起杀,避免半死状态
容器运行时通常给 1 号进程偏高 oom_score_adj,让整体先死而非留下孤儿进程——"为什么死的是 init"由此而来,偏移按 runtime 版本核对。
8. IO 隔离的难点
| 手段 | 约束对象 | 覆盖 buffered 写? | 备注 |
|---|---|---|---|
io.max(blk-throttle) |
已提交的 bio | 部分:只约束回写阶段 | 脏页生成阶段不限速 |
io.weight |
相对份额 | 是(按 blkcg) | 竞争时才生效 |
io.latency |
按延迟目标分配 | 是 | 需内核与调度器支持 |
$ echo "8:16 wbps=52428800" > /sys/fs/cgroup/batch/io.max
# 关键:buffered write 先堆脏页、后由内核回写线程刷出
# 想真正控住写入节奏,要么 O_DIRECT,要么控制 fsync 频率与批大小
这就是"限了写带宽磁盘仍被拖垮"的原因:buffered 写只是往页缓存塞数据,io.max 管的是后来回写的 bio,脏页堆到阈值才回写,冲击仍在。该做的是限制脏页生成速率(批量与 fsync 节奏),而非只靠块层限速。
9. CPU 亲和性、NUMA 与 slice 组织
$ taskset -c 2-5 ./worker # 绑核:减少迁移、提高缓存命中
$ numactl --cpunodebind=0 --membind=0 ./worker # NUMA 绑定:避免远端内存
$ systemctl set-property batch.slice CPUWeight=20 MemoryMax=4G IOWeight=50
| 手段 | 收益 | 代价 |
|---|---|---|
taskset 绑核 |
缓存局部性、抖动下降 | 绑太窄被自己峰值卡住 |
cpuset.cpus |
与批处理物理分离 | 核数刚性、利用率降 |
| NUMA 绑定 | 远端内存访问减少(本地与远端典型差一到两倍,按平台核对) | 内存不均时难调度 |
systemd slice 把这些参数变成声明式配置:CPUWeight、MemoryMax、MemoryHigh、IOWeight、TasksMax 落到 cgroup,重启仍生效,比手写 cgroupfs 可审计。
# /etc/systemd/system/batch.slice.d/limits.conf
[Slice]
CPUWeight=20
MemoryHigh=3G
MemoryMax=4G
IOWeight=50
TasksMax=512
10. 混部取舍与监控口径
| 维度 | 混部 | 分池 |
|---|---|---|
| 利用率 | 高,峰值互补 | 低,按峰值预留 |
| 干扰 | 缓存与带宽争用难隔离 | 物理隔离,可预测 |
| 适用 | 批处理与在线错峰 | 延迟敏感核心链路 |
监控口径必须按 cgroup 聚合:进程会重启、fork、短命,按进程看永远对不上账。
$ cat /sys/fs/cgroup/batch/cpu.stat # usage_usec / throttled_usec
$ cat /sys/fs/cgroup/batch/memory.stat # anon / file / workingset
$ cat /sys/fs/cgroup/batch/io.stat # rbytes / wios
throttled_usec 增长说明 cpu.max 真在卡人;workingset_refault 增长说明工作集被反复换出。这两个数比"使用率 80%"有用得多。
11. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 批处理一跑,在线接口 P99 飙升 | CPU 份额与 IO 均未限 | cpu.max + io.max,或 cpuset 分离 |
| 没超 limit 却被 OOM | 上限按峰值设,回收跟不上 | 降 memory.high 留缓冲 |
load 高但 CPU 空闲 |
D 状态任务在等 IO | 看块设备 await 与 PSI io |
| 容器反复重启无日志 | cgroup OOM | 查 memory.events 与 dmesg |
| 限了写带宽仍被拖垮 | buffered 写先堆脏页 | 控批量与 fsync,或 O_DIRECT |
| 绑核后吞吐下降 | 绑太窄,单核峰值为瓶颈 | 放宽 cpuset |
12. 边界:什么时候不值得做隔离
| 场景 | 更合适 | 理由 |
|---|---|---|
| 单服务独占整机 | 不用 cgroup 细分 | 没有竞争者,限额只增加配置面 |
| 负载稳定且峰值可预测 | 容量规划 + 简单监控 | 隔离的成本高于波动损失 |
| 突发完全不可控(如外部爬取) | 限流 + 排队 | 隔离挡不住流量,限流才挡得住 |
| 延迟要求不高、离线任务 | 错峰调度 | 时间维度的隔离比资源维度便宜 |
| 只有一台机器且预算固定 | 优先级 + 观测 | 物理隔离买不起,先保可观测 |
收益随"干扰频率 × 干扰代价"上升:偶尔一次拖慢,加监控与错峰就够;每天多次且影响收入,才值得上 cpuset 与独立盘。
13. 自测
- load 高但 CPU 空闲说明什么?为何不等于 IO 饱和?
%steal高指向哪一类问题?- nice 19 与
SCHED_IDLE差在哪?共同盲区是什么? cpu.max与cpu.weight各在什么条件下生效?- 设了
memory.max为何还会"没超限却变慢"? io.max为何管不住 buffered 写的瞬时冲击?