ionice 与 cgroup IO 权重:备份拖慢网站的排查改造

备份脚本把网站拖慢了三小时,元凶是 ionice

有个站点的备份任务一直放在凌晨三点跑,用 rsync 把几十万张附件同步到对象存储。这套配置跑了两年没什么问题,直到某个周末晚上十一点,我临时把备份改到了这个时间点,因为第二天要迁移服务器,想拿到最新的完整数据。

结果十一点零五分,监控开始报警:首页 TTFB 从平时的 180ms 涨到 4 秒,接着是 504,然后 Nginx 的 error log 里出现大量 upstream timed out (110: Connection timed out) while reading response header from upstream。我第一反应是备份吃满了带宽,于是把 rsync 限速调到 5MB/s。没用。CPU 也正常,内存也没爆。最后登录机器敲了 iostat -xz 2 5,看到 %util 贴近 100,aqu-sz(平均队列长度)三十多,w_await 涨到一百多毫秒。磁盘被打满了,而罪魁祸首不是带宽,是 rsync 对几十万个小文件做 stat 和读取时产生的随机 IO。

这次事故之后我才认真去研究了 ionice 这个一直在 man 手册里被我跳过的小工具。它解决的问题很具体:当多个进程同时抢一块磁盘时,谁先拿到。这篇文章就把它讲清楚,包括它的三个调度类、为什么默认设置基本没用、以及它在 cgroup v2 时代还剩下多少价值。

Linux IO 调度的三层结构:先搞清楚 ionice 作用于哪一层

很多人以为 ionice 是控制磁盘 IO 速度的,其实它只控制队列里的优先级,不控制速度上限。想限速要用 rsync --bwlimit、cgroup io.max 或者块设备的 io.throttle。

一块磁盘上的 IO 请求要经过三层:

层组件作用观察方式
块层调度none / mq-deadline / bfq / kyber决定队列里哪个请求先发cat /sys/block/sda/queue/scheduler
IO 优先级ionice / ioprio_set给进程打优先级标签ionice -p PID
IO 限速cgroup v2 io.max / 云盘 IOPS 配额硬性限制吞吐与 IOPScat /sys/fs/cgroup/.../io.max

关键点:ionice 打上的标签,只有在底层调度器支持并启用了 IO 优先级时才生效。老式的 CFQ 调度器是支持的;none(也就是 noop,NVMe 盘默认)和 mq-deadline 基本不看这个标签,只把它当作一个极弱的参考。云服务器上的 SSD 盘绝大多数是 none 或 mq-deadline,这意味着你在这些盘上敲 ionice -c3,实际效果接近于零——这就是我那次调了优先级却毫无改善的原因。

# 看当前磁盘用的什么调度器
cat /sys/block/vda/queue/scheduler
# 输出形如:[none] mq-deadline  表示当前是 none
#         mq-deadline [bfq]     表示当前是 bfq

# 临时切换(重启失效)
echo bfq > /sys/block/vda/queue/scheduler

# 永久切换(udev 规则,注意设备名可能是 vda/sda/nvme0n1)
# /etc/udev/rules.d/60-iosched.rules
# ACTION=="add|change", KERNEL=="vd*", ATTR{queue/scheduler}="bfq"

换调度器要谨慎:bfq 对机械盘和低速 SSD 改善明显,但对 NVMe 可能反而降低吞吐。生产机上换调度器之前,用 fio 跑一次前后对比,别凭感觉。

三个调度类的真实含义

ionice 有三个类,类和「级别」是两个概念,很多人在这里混淆:

类参数级别范围适用场景
Idle-c3无只有在没有其他进程要用磁盘时才跑
Best-effort-c2-n0 最高 ~ -n7 最低默认类,适合大部分服务
Real-time-c1-n0 ~ -n7几乎不该用,会饿死其他进程

Idle 类(-c3)是备份、索引、日志压缩这类任务的正确归属。它的语义不是「低优先级」,而是「用到磁盘空闲时才跑」。理论上它完全不会影响前台服务,但实际上有两个副作用要先知道:

第一,Idle 的进程可能被饿死。如果你的磁盘长期处于忙碌状态(比如一个写日志很凶的站),备份进程会一直等,最后备份任务超时或者干脆一整晚没跑完。我就遇到过 Idle 类备份连续三天没完成,日志里只有一行 rsync 的「connection timed out」,因为它在等磁盘。

第二,在 none 调度器上 Idle 不被识别,退化成 Best-effort,等于没设。所以「我设了 ionice -c3 但备份还是拖慢网站」这个现象,先去看调度器,别怀疑 ionice 写错了。

Real-time 类(-c1)在数据库场景里是个陷阱。有人为了「保证 MySQL 的 IO 优先」给 mysqld 设了 ionice -c1 -n0,结果备份跑不起来、日志写不进去、连 dpkg 都卡住。Real-time IO 在部分内核实现里会抢占整个队列,几个进程都用 RT 就等于互相饿死。除非你在做实时音视频采集这种硬实时的场景,否则永远不要用。

# 常见用法
# 1) 直接跑一条命令
ionice -c3 rsync -a --delete /data/uploads/ /backup/uploads/

# 2) 改已经跑起来的进程(必须知道 PID)
ionice -c2 -n7 -p 12345

# 3) 查进程当前的 IO 优先级
ionice -p 12345
# 输出:best-effort: prio 4  /  idle  /  none: prio 0

# 4) 用 systemd 给服务固化(推荐,重启后仍生效)
# /etc/systemd/system/backup.service.d/io.conf
# [Service]
# IOSchedulingClass=idle
# IOSchedulingPriority=7

systemd 的 IOSchedulingClass 取值是 none/realtime/best-effort/idle,注意不是数字。idle 对应 -c3。如果一个服务是 timer 触发的,要靠 drop-in 文件而不是在 crontab 行首写 ionice——因为 timer 每次启动的是一个新 unit,命令行里的 ionice 只对那个 shell 生效,跑完就没了。

比 ionice 更可靠的做法:cgroup v2 的 io 控制

既然 ionice 在新调度器上效果有限,那现代系统上该用什么?答案是 cgroup v2 的 IO 控制器,它是在块层真正做权重和限速,不依赖调度器支持优先级标签。Debian 12 默认就是 unified cgroup v2。

# 确认是 cgroup v2
stat -fc %T /sys/fs/cgroup
# 输出 cgroup2fs 就是 v2

# 给备份脚本单独跑在一个 slice 里,限制权重
systemd-run --slice=backup.slice --uid=root \
  --property=IOWeight=10 \
  --property=IOReadBandwidthMax="/dev/vda 20M" \
  rsync -a /data/uploads/ /backup/uploads/

# 事后查这个 slice 的实际 IO 统计
cat /sys/fs/cgroup/backup.slice/io.stat
# 每行形如:253:0 rbytes=... wbytes=... rios=... wios=... dbytes=... dios=...

IOWeight 默认是 100,范围 1~10000。备份给 10 意味着它只拿到大约前后台服务十分之一的带宽份额。这比 ionice 的「空闲才跑」更实用:备份不会被饿死,但也不会抢占前台。

IOReadBandwidthMax 和 IOWriteBandwidthMax 是硬上限。IOMax 是复合语法,可以按设备分别写读写 IOPS 和带宽。要注意设备名要写 /dev/vda 这种主块设备,不能写分区 /dev/vda1。

对 MySQL 这类服务,我现在的做法是:mysqld 放在 system.slice(默认权重 100),备份放到 backup.slice 权重 10 并加上写带宽上限。这样即便备份在高峰期跑,慢查询里也不会出现「磁盘等 200ms」的记录。具体的 slice 定义文件:

# /etc/systemd/system/backup.slice
[Unit]
Description=Low priority backup slice

[Slice]
IOWeight=10
IOWriteBandwidthMax=/dev/vda 30M
MemoryHigh=512M
TasksMax=32

把 MemoryHigh 和 TasksMax 也一并设上,是因为备份脚本除了 IO,还会吃 page cache 和进程数。我用 rsync 同步百万级小文件时,它真的能把系统上的 dentry cache 堆到几个 G,然后其他服务的 vmstat 里 free 掉到零,kswapd 开始狂扫。这类问题 ionice 管不到,cgroup 能管。

排查「IO 被抢占」的完整链路

遇到页面变慢、怀疑是 IO 争用时,按这个顺序查,十分钟能定位:

# 1) 磁盘整体压力:先看队列长度和 await,别只看 %util
iostat -xz 2 5
# 关注:r/s w/s rkB/s wkB/s aqu-sz w_await %util
# aqu-sz(平均队列长度)持续 >4 说明排队严重
# SSD 上 %util 100% 不一定是瓶颈,要看 w_await 是否随之上涨

# 2) 谁在打 IO(按进程累计)
iotop -oPa
# -o 只显示有 IO 的,-P 按进程而非线程,-a 累计
# 注意 iotop 需要 root 和 CONFIG_TASK_IO_ACCOUNTING

# 3) 比吞吐更重要的指标:IO 等待时间
pidstat -d 2 5
# 看 iodelay 列(毫秒),这才是「进程被迫等 IO」的时间

# 4) 如果是 MySQL 慢,看它到底在等什么
# 在 MySQL 里:
# SELECT * FROM performance_schema.events_waits_summary_global_by_event_name
#  WHERE EVENT_NAME LIKE 'wait/io/file/%' ORDER BY SUM_TIMER_WAIT DESC LIMIT 10;

# 5) 确认是不是 page cache 被挤出去了
vmstat 2 5
# si/so 非零 = 在换页,说明内存压力导致重新读磁盘

这里有个特别容易误判的点:%util 在 SSD 和云盘上会严重失真。NVMe 盘可以同时处理几百个并发请求,即使 %util 显示 100%,实际还有余量。真正的瓶颈信号是 await 和 aqu-sz 一起上涨。反过来,机械盘上 %util 到 100% 基本就是满了。

另一个误判:iotop 显示的 DISCO/IO> 列,很多人以为是速度,其实是「IO 优先级」的简写,D 表示默认(default,即 best-effort)。IO> 后面的数字是级别的图形化表示,不是字节数。这个误解很普遍,我第一次用的时候就盯着它纳闷了很久。

五个实战中总结的规则

规则一:先看调度器,再谈 ionice。在 none 或 mq-deadline 上,ionice 的 Idle 类和级别基本无效。想用 ionice 就要先确认磁盘用的是 bfq 或 CFQ 系。

规则二:备份限速比调优先级更管用。对 rsync,--bwlimit=20000(单位 KB/s)能直接解决带宽问题。但注意它限制的是网络吞吐,不是磁盘随机 IO——同步百万小文件时真正拖慢系统的是 inode 查找和 stat,这时候要用 --max-size、分批同步,或者换成 restic/borg 这类打包式的工具,把随机 IO 变成顺序 IO。

规则三:小文件是随机 IO 的来源。我那次事故里,附件目录有几十万张图片,每张几十 KB。rsync 对每个文件做 stat + open + read,每一次都是一次随机寻道。同样的数据量打成 tar 包顺序读,速度快十倍、对前台的影响也小得多。所以备份附件目录时,先在源端做一次 tar 或者用 rsync --files-from 分批,比调任何优先级参数都有用。

规则四:把备份放到从库或独立磁盘。结构上隔离比软件层调度更彻底。备份读从库(加 --single-transaction 避免锁),文件从独立的挂载点读,物理上就不和主库、网站的磁盘抢队列。这也是我后来在几台机器上做的改造:数据盘和系统盘分开,备份只读数据盘的挂载点。

规则五:宁可用 cgroup,不要赌调度器。cgroup v2 的 IOWeight 是内核里真正生效的机制,与调度器无关。写进 systemd slice 文件里,一次配置长期有效,也方便事后查 io.stat 核对。

一个可直接套用的备份服务配置

把上面这些整合起来,一个既能限 IO 又能限内存、还能按时段跑的备份 unit 大概长这样:

# /etc/systemd/system/site-backup.service
[Unit]
Description=Low priority site backup
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
Slice=backup.slice
IOSchedulingClass=idle
IOSchedulingPriority=7
Nice=19
ExecStart=/usr/local/bin/site-backup.sh
TimeoutStartSec=6h

# /etc/systemd/system/site-backup.timer
[Unit]
Description=Run site backup nightly
[Timer]
OnCalendar=*-*-* 03:30:00
RandomizedDelaySec=600
Persistent=true
Unit=site-backup.service

[Install]
WantedBy=timers.target

RandomizedDelaySec=600 让多台机器的备份错开十分钟,避免同时开跑造成对象存储或带宽的瞬时峰值。Persistent=true 保证机器在 3:30 关机、开机后会补跑一次。这两项都是小成本、长期受益的设置。

脚本本身也要防呆:set -euo pipefail 是基线,每次备份结束核对输出文件大小和退出码,把结果写进一个状态文件。没有「备份成功」的确认,就等于没有备份。

回到开头那次事故,最终的处置是三条:把备份挪回凌晨、给备份加了 IOWeight=10 的 slice、以及给附件目录改成先打包再同步。三件事做完之后,同样的备份任务在主站流量高峰跑,首页 TTFB 也稳定在 200ms 上下。问题的根从来不是「优先级没设对」,而是「把几十万次随机 read 塞进了同一块盘的队列」——ionice 只是让这个错误没那么快暴露出来而已。

Last modification:October 23rd, 2026 at 12:24 pm

Leave a Comment