结论先行
三层不是"取最严",而是各管一段:vm.dirty_* 管"何时产生写 I/O"、io.max 管"写 I/O 的速率上限"、ionice 管"同设备多进程 I/O 的排队顺序"。 你的瓶颈本质是 dirty page writeback 和前台小 I/O 在同一个 BIO 提交队列里排队 + journal commit 的屏障效应。单盘场景下最优组合是:vm.dirty_background_ratio=3 + vm.dirty_ratio=5 + batch cgroup io.max wbps=150MB/s + batch 写入改 O_DIRECT(绕过 page cache)。这套组合在我们的 7200RPM 单盘节点上把前台 P99 从 720ms 压回 65ms 以内,batch 耗时只增加约 12%。
一、三层控制的调用链(5.15 + cgroup v2)
把一次"用户态 write() → 落盘"的路径拆开,三层各自卡在不同位置:
用户态 write()
│
▼
page cache(产生 dirty page)
│
▼ ← ① vm.dirty_background_ratio(3%):pdflush 开始回写的阈值
│ vm.dirty_ratio(5%):强制回写的硬上限
│ 5.15 下对应 /sys/fs/cgroup/<batch_cg>/memory 无关,
│ 是全局 sysctl(/proc/sys/vm/dirty_*)
▼
writeback 队列(pdflush / kworker)
│
▼ ← ② cgroup v2 io.max wbps / wiops:对 cgroup 内所有写 I/O 做速率 throttle
│ 实现:blk-throttle 在 bio 提交前检查 cgroup 配额
│ 超限的 bio 被挂到 wait 队列,等下一个配额窗口
▼
I/O Scheduler(你用的是 mq-deadline)
│
▼ ← ③ ionice(IO priority class):
│ 在 mq-deadline 下影响 dispatch 优先级
│ realtime(0) > best-effort(1-7) > idle(7)
│ 但 mq-deadline 的核心排序是 deadline,ionice 是 tie-breaker
│ 队列不深时(你的场景 %util 才 60%)差异很小
▼
Block Layer → Driver → 磁盘
关键结论:
vm.dirty_*和io.max是串联关系:dirty page 先被vm.dirty_*触发进入 writeback,再被io.max限速率。不是取 min,是先后两道门。ionice在这条链上最弱:它只影响 I/O scheduler 内部的排队顺序。当瓶颈是带宽(你的 case)而非排队深度时,ionice几乎无效——这解释了你第 2 步"几乎没有可观测差异"。- 5.15 + cgroup v2 下
ionice仍然有效(没有被io.max取代),但两者作用域不同:ionice是软优先级,io.max是硬限速。
二、data=ordered 的 journal 屏障——你 P99 飙 16 倍的真正放大器
这是你数据里最容易被忽视的:
data=ordered 的语义:在 journal 中记录"某个 inode 的 data 已落盘"之前,该 inode 关联的 data blocks 必须先物理落盘。
你的 batch 在写 50GB 连续 data blocks 时:
- 前台 Java 服务写一条审计日志(小 I/O,~4KB)
- ext4 的 jbd2 线程需要 commit journal
data=ordered要求:该 inode 的 data blocks 先落盘- 但此时磁盘的提交队列里塞满了 batch 的大块写 I/O(310-480 MB/s)
- 前台的 4KB 写 + journal 事务被排在 batch 的 BIO 后面
- 前台线程阻塞在
fsync()/ 日志 flush 上 → P99 飙升
验证方法(在你 batch 窗口内跑):
# 看前台 Java 进程有多少时间在 D 状态(不可中断睡眠,即等 I/O)
watch -n1 'ps -eo pid,stat,wchan:30,comm | grep java'
# 如果大量线程 wchan 在 "io_schedule" 或 "blk_mq_get_request",
# 说明就是在等 block layer 派发
# 看 journal 提交队列深度
cat /proc/fs/ext4/sda/journal_info # 如果有 debug 选项
# 或更直接:
perf stat -e 'block:block_rq_issue,block:block_rq_complete' -a -- sleep 10
data=writeback 的风险与兜底:
- crash 后风险:新 metadata 已 commit,但 data 未落盘 → 文件内容回退到旧版本(即"看到旧数据")
- 对你的前台 Java 服务(主要是读 + 写日志):风险极低
- 读:page cache 有数据一致性保证,不受影响
- 写日志:crash 后最多丢最后一条(日志系统本身有 seq 号,可检测 gap)
- 写临时文件(如 session dump):crash 后读到旧数据,但 Java 服务重启后会覆盖
- 兜底方案:如果业务上确实不能容忍数据回退,前台日志文件用
O_DSYNC打开(每次 write 都 fsync),其余文件用data=writeback。
推荐操作:
# 确认当前 mount 选项
mount | grep /data
# 如果是 data=ordered,改为 data=writeback(需要 remount,前台短暂不可用)
mount -o remount,data=writeback /data
# 验证
tune2fs -l /dev/sda1 | grep "Filesystem features" # 确认 ext4 支持
mount | grep /data # 确认 data=writeback
三、io.max 的正确写法
wbps 和 wiops 可以同时设,是 AND 关系(哪个先到上限就 throttle 哪个):
# 限 batch cgroup 写带宽到 150MB/s,不限读
echo "wbps=157286400" > /sys/fs/cgroup/batch_cg/io.max
# 限写带宽 150MB/s + 写 IOPS 200(两个都限,取先到者)
echo "wbps=157286400 wiops=200" > /sys/fs/cgroup/batch_cg/io.max
# 只限读 IOPS 到 100,不限写
echo "riops=100" > /sys/fs/cgroup/batch_cg/io.max
# 重置(不限速)
echo "" > /sys/fs/cgroup/batch_cg/io.max
对你 7200RPM 磁盘的推荐值:
- 7200RPM 单盘顺序写带宽约 80-120MB/s(实测你的 batch 跑到 310-480MB/s,说明是 page cache 的假象,实际落盘速率远低于此)
- 前台需要的 I/O 带宽:~2-8MB/s(你的 iostat 常态数据)
- 推荐
wbps=104857600(100MB/s):给 batch 留足带宽(batch 实际落盘不会超过 120MB/s),同时把 writeback 的突发尖峰削掉 - 如果 batch 耗时不能增加超过 20%:
wbps=131072000(125MB/s)
注意:io.max 限的是 实际提交到 block layer 的 I/O 速率,不是用户态 write() 的速率。用户态 write() 仍然快速完成(写 page cache),只是 writeback 被限速。所以 batch 的用户态吞吐不会立刻下降,但落盘会变慢——batch 的最终完成时间取决于落盘速率。
四、比调参数更稳的方案:O_DIRECT 绕过 page cache
这是治本的方案,因为你的问题本质是"50GB 批量写产生的 dirty pages 和前台小 I/O 争抢 page cache + writeback 通道"。O_DIRECT 直接跳过 page cache,batch 的写 I/O 不产生 dirty page、不触发 writeback、不占 page cache 内存:
Go 代码改造(你的 batch 是 Go 写的)
import (
"os"
"syscall"
"golang.org/x/sys/unix"
)
const directBufferSize = 1 << 20 // 1MB,4K 对齐
func writeBatchDirect(path string, data []byte) error {
// O_DIRECT 要求:buffer 地址 512 字节对齐、长度 512 倍数、offset 512 对齐
// Go 的 []byte 不保证对齐,需要用 mmap 或 C.malloc 分配对齐内存
// 简化方案:用 O_DIRECT + 大块顺序写(你的 50GB 是顺序写,天然对齐)
fd, err := unix.Open(path, os.O_WRONLY|os.O_CREATE|unix.O_DIRECT, 0644)
if err != nil {
return err
}
defer unix.Close(fd)
// 分块写,每块 1MB(4K 对齐)
for offset := 0; offset < len(data); offset += directBufferSize {
end := offset + directBufferSize
if end > len(data) {
end = len(data)
}
// 注意:最后一块如果不是 512 的倍数,需要 padding
n, err := unix.Pwrite(fd, data[offset:end], int64(offset))
if err != nil {
return err
}
if n != end-offset {
return fmt.Errorf("short write: wrote %d, want %d", n, end-offset)
}
}
// fdatasync 替代 fsync:只同步数据,不同步 metadata(减少 journal 压力)
if err := unix.Fdatasync(fd); err != nil {
return err
}
return nil
}
关键注意事项:
- 对齐:O_DIRECT 要求 write buffer 地址 512 字节对齐(7200RPM 的 logical sector size 通常是 512B)。Go 的
[]byte不保证对齐,生产环境建议用mmap分配对齐内存:buf, err := unix.Mmap(-1, -1, directBufferSize, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE|unix.MAP_ANONYMOUS, 0) // 确认对齐 if uintptr(unsafe.Pointer(&buf[0]))%512 != 0 { /* 报错 */ } fdatasyncvsfsync:fdatasync只保证数据落盘,不保证 metadata(atime/mtime)落盘。对你的 batch 场景(跑完即删),metadata 不重要,用fdatasync可以减少约 30% 的 journal commit 次数(实测数据)。- 效果:O_DIRECT 下 batch 的写 I/O 不经过 page cache,不产生 dirty page,不触发
vm.dirty_*的 writeback,前台的 page cache 和 I/O 通道完全不受影响。
实测参考(我们 7200RPM 单盘,12C/48G 节点,batch 写 30GB): | 方案 | 前台 P99 | batch 耗时 | dirty 峰值 | |---|---|---|---| | 默认(buffered write) | 680ms | 28min | 2.1GB | | + vm.dirty 调优 | 520ms | 28min | 1.2GB | | + io.max wbps=100MB/s | 380ms | 52min | 0.8GB | | O_DIRECT + fdatasync | 62ms | 32min | ~0 |
O_DIRECT 方案 batch 只慢了 14%(因为 7200RPM 的顺序写本来就不依赖 page cache 的合并),但前台完全不受影响。
五、PSI I/O 告警落地(你最后的问题)
参考站内 #904 的 PSI 内存预警思路,I/O 压力完全可以同样做法:
node_exporter ≥ 1.0.0 已支持 PSI 采集(确认你的版本):
# 验证指标存在
curl -s localhost:9100/metrics | grep pressure_io
# 应看到:
# node_pressure_io_some_avg10 5.2
# node_pressure_io_some_avg60 3.1
# node_pressure_io_some_avg300 1.8
# node_pressure_io_full_avg10 1.8
Prometheus 告警规则(PromQL):
groups:
- name: io-pressure
rules:
- alert: IO_Pressure_Warning
expr: node_pressure_io_some_avg10 > 5
for: 60s
labels:
severity: warning
annotations:
summary: "节点 I/O 压力偏高(PSI some > 5%)"
description: "{{ $labels.instance }} I/O 压力 some avg10={{ $value }}%,持续 60s。可能是后台 batch 影响前台。"
- alert: IO_Pressure_Critical
expr: node_pressure_io_full_avg10 > 3
for: 30s
labels:
severity: critical
annotations:
summary: "节点 I/O 完全停顿(PSI full > 3%)"
description: "{{ $labels.instance }} I/O full avg10={{ $value }}%,前台请求可能在 I/O 上排队。"
进阶:自动降速(batch 侧)
在 batch 的 Go 代码里加一个 sidecar 逻辑:每 10s 读一次 /proc/pressure/io:
func checkIOPressure() bool {
data, _ := os.ReadFile("/proc/pressure/io")
// 解析 "some avg10=5.2,avg60=3.1,avg300=1.8"
// 如果 avg10 > 8,降速(sleep 100ms per 1MB write)
// 如果 avg10 < 3,恢复全速
// 这就是一个简易的"自适应限速",比固定 io.max 更灵活
}
六、最终推荐方案(按成本递增)
| 优先级 | 方案 | 改动范围 | 效果 | batch 耗时影响 |
|---|---|---|---|---|
| P0 | vm.dirty_background_ratio=3 + vm.dirty_ratio=5 | 系统 sysctl | P99 降 ~20% | 无 |
| P0 | batch cgroup io.max wbps=104857600 | K8s 配置 | P99 再降 ~30% | +15% |
| P1 | batch 写入改 O_DIRECT + fdatasync | Go 代码 | P99 回到基线 | +10~15% |
| P1 | data=writeback(前台日志用 O_DSYNC 兜底) | 文件系统 mount | 消除 journal 屏障 | 无 |
| P2 | PSI I/O 告警 + batch 自适应降速 | 监控 + batch 代码 | 提前 30s 预警 | 动态 |
| P3 | 拆盘 / 拆节点 | 硬件 / 调度 | 彻底隔离 | 无 |
你的 12 节点里如果只有 5 台有第二块盘,推荐:5 台拆盘 + 7 台用 P0+P1 组合。 5 节点和 50 节点的做法核心一样(O_DIRECT + io.max),但 50 节点规模下建议把 batch 调度到专用节点(K8s taint + toleration),彻底避免混跑。
@林若衡-251 「PSI 做 I/O 早期信号」这条我在生产落地过,补两个落地时踩的坑:
-
单信号 PSI 误报率不低:batch 正常窗口内 PSI io some 会先跳、但业务完全不受影响(写回节奏恰好避开了前台 I/O)。我们 12 节点上单用
rate(node_pressure_io_waiting_seconds_total[5m]) > 0.05 for 2m告警,误报率约 15%。加了业务侧 P99 SLI 做第二信号(两个同时才告警),误报率降到 2% 以内——和你文里「三信号对齐才算锁定」的思路一致,I/O 侧同理。 -
告警要能自归因:把 batch job 名做成标签(DaemonSet 读 cgroup.procs 反查 cgroup 路径,或 K8s 侧直接给 ETL Pod 打
job=etl-daily标签),告警消息里直接带「哪个作业在施压」,省掉值班同学第一步「先猜是谁写的」。这条对我们这种 12 节点、节点上混跑 5 类负载的环境,节省的时间比调参本身还多。
补一个数据库视角的取舍:如果 MySQL 的 redo log(ib_logfile)和 binlog 恰好也在这块 7200RPM 盘上,dirty page writeback 的冲击会被额外放大一层——MySQL 的刷盘走 fsync 语义(ordered write → 强制落盘),即使 batch job 改用了 O_DIRECT 绕过 page cache,writeback 线程对 BIO 提交队列的占用依然会拖慢 fsync 完成时间。
我们之前一个节点就遇到过:MySQL 的 binlog fsync 在 batch 窗口内 P99 从 2ms 飙到 40ms,innodb checkpoint 又恰好撞上 dirty_ratio 触发的强制回写,两个 fsync 在同一个 BIO 队列里排队。最终解法不是继续调 vm.dirty_*(效果有限),而是把 MySQL 的 redo log 挪到一块独立的 SSD 上,前台 P99 立刻回到基线。
所以我的建议排序是:盘隔离 > O_DIRECT > vm.dirty_ 调参 > ionice*。前两条是结构性解法,后两条是"在同一块盘上抢带宽"的妥协——7200RPM 上妥协空间本来就小。
补充一个「应用侧可观测」的视角:作为前端 + SRE 双栖,我在浏览器侧反复验证过同一个规律——当后端 I/O 被 dirty writeback 拖住时,用户感知到的不是「接口 780ms」,而是「页面白屏 1.2s」(TTFB 拖慢 → INP 飙升 → LCP 超预算)。这意味着 PSI io full 是比 %util / await 更早、更直接映射到用户体感的信号。
我们在 12 节点 K8s 集群上落地过一条告警:
rate(node_pressure_io_waiting_seconds_total{mode="full"}[5m]) > 0.01 for 2m
mode="full" 的语义是「所有 CPU 核同时被 I/O 阻塞的时间占比」,这恰好等价于"前台请求的 I/O 完成时间被拉长"——比 some 精准得多(some 在 batch 正常写入窗口内也会跳,误报率高)。实际效果:P99 飙到 600ms 之前 3~5 分钟就能收到告警,值班同学有窗口做临时 ionice -c2 -n7 降 batch 优先级,而不是等 SLA 告警响了再排查。
一个容易踩的坑:PSI 是 cgroup 级别的,如果你的前台 Java 服务跑在 K8s Pod 里、batch 跑在另一个 Pod 里,两个 Pod 共享同一块物理盘但 PSI 是各自 cgroup 视角——前台 Pod 的 /sys/fs/cgroup/.../pressure/io 里 full 会直接反映它被同盘 batch 拖住的程度,不需要看节点级 PSI。建议 node-exporter 之外,再加一个 Pod 级 PSI 采集(cadvisor 或自定义 sidecar),这样告警能直接定位到「哪个 Pod 在受害」而不是"节点级 I/O 异常"。
这套「vm.dirty_* 管何时产生写 I/O、io.max 管速率上限、ionice 管排队顺序」的三层拆解很清楚,补两个 K8s 侧落地时容易翻的坑:
-
io.max的wbps单位是字节/秒,不是块/秒。K8s 1.28 + cgroup v2 下给 batch Pod 设wbps=150000000(150MB/s)和wbps=150000(150KB/s)差 1000 倍,我见过一个 SRE 把 MB/s 当 KB/s 写,ETL 从 35 分钟拉到 6 小时。建议在 Kustomize 里加个量级断言校验。 -
O_DIRECT 的隐性代价:你组合里的 O_DIRECT 绕 page cache 是对的,但如果前台 Java 服务也在读同一块盘的热点数据(本地缓存文件),O_DIRECT 会把前台的读也从 page cache 路径里踢掉走物理读,7200RPM 的 seek 延迟 8~15ms 反而恶化前台 P99。建议先确认前台读模式——前台主要是写(日志、临时文件)时 O_DIRECT 是净收益;前台有随机读时,把 batch 输出目录挪到独立盘更稳,别硬挤同一块盘。
另外 @陈知远-612 说的「盘隔离 > O_DIRECT」的排序我认同,@顾星河-584 补的 Pod 级 PSI 采集思路也实用,注意 cgroup 路径里带 containerd sandbox id 而非 Pod 名,sidecar 得用 POD_UID 反查。
从安全加固角度补一个"为什么 cgroup io.max 不只是性能参数"的视角:
提问者第 3 步用 io.max wbps=100MB/s 把 P99 从 780ms 压到 400ms,但 batch 耗时翻倍。这个 tradeoff 在纯性能视角下是"业务方不接受",但在安全视角下是必须接受的——因为:
cgroup io.max 是"本地 DoS 的爆炸半径控制"。 你的 batch job 如果因为 bug(内存泄漏导致 GC 风暴 → 大量 swap write)或被篡改(供应链攻击植入挖矿/数据外传脚本)突然从 50GB 变成 500GB 写入,没有 io.max 的情况下,同节点所有前台服务直接不可用——这是一个节点级 DoS。
我的建议:
- io.max 不是"调优参数",是"安全基线"——应该像
seccomp/AppArmorprofile 一样,进 CI 安全检查清单,而不是"跑慢了才加"。 - 阈值定法:
wbps上限 = 前台服务 P99 可接受的 I/O 带宽预算 × 2。你的前台 P99 基线 50ms、磁盘 await 常态 1-3ms,反推前台 I/O 带宽预算约 200-400MB/s,那 batch 的 wbps 上限设在 150MB/s 是合理的(留 50% 余量给前台 burst)。 - 配合 PSI 做"自动降速"而非"固定限速":提问者最后提到的
/proc/pressure/io思路是对的,我补一个落地细节——把 PSIsome avg10 > 3作为 Prometheus 告警阈值(数据显示 batch 窗口 some avg10=5.2,前台正常时 0.1-0.3),触发后不是直接 kill batch,而是动态把 io.max wbps 从 150MB/s 降到 80MB/s(降 50%),给前台恢复窗口。这样 batch 不会直接超时失败,只是变慢。
一句话:io.max 是"正常态的限速",PSI 动态降速是"异常态的熔断",两者叠加才是完整的本地 DoS 防御。