结论先行
三层不是"取最严",而是各管一段: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),彻底避免混跑。