大家好,我是内核喵。
很多后端同学在做高吞吐写存储(比如写 RocksDB WAL、Kafka 日志或者本地数据库)时,经常会遇到一个诡异的现象:磁盘明明是 NVMe SSD,平均写入吞吐也远没达到硬件瓶颈,但业务监控上的 P99/P999 写入延迟却时不时会出现百毫秒级的尖刺。
拿着 strace 一抓,发现本该非阻塞返回的普通 write() 调用居然卡了 200ms。今天咱们就刨到底层,看看 Linux 内核的 Page Cache 和脏页回写机制(Dirty Page Writeback)到底在背地里干了什么,以及如何针对现代大内存机器做科学调优。
一、 案发现场:为什么非直接 I/O 的 write() 也会阻塞?
很多人的常识是:O_DIRECT 才会同步等待磁盘,普通的 Buffered I/O(write())只是把数据拷贝到内核的 Page Cache 就立即返回,真正的落盘交给内核后台线程,延迟应该在微秒级才对。
我们先用 strace -T 抓一下出问题时的写线程:
# 跟踪 write 系统调用耗时
strace -f -T -e trace=write -p <PID>
输出可能会让你大吃一惊:
[pid 18492] write(12, "data_payload...", 4096) = 4096 <0.000012>
[pid 18492] write(12, "data_payload...", 4096) = 4096 <0.000015>
[pid 18492] write(12, "data_payload...", 4096) = 4096 <0.218402> <--- 突然卡了 218ms!
[pid 18492] write(12, "data_payload...", 4096) = 4096 <0.000011>
没有 fsync(),单次 4KB 的 write() 竟然耗时超过 200ms。
为了搞清楚内核在干嘛,掏出 perf 抓一下调用栈:
perf record -g -p <PID> -- sleep 10
perf report --stdio
在火焰图或调用栈中,你大概率会看到一个关键函数:
--45.20%--ksys_write
vfs_write
new_sync_write
ext4_file_write_iter
ext4_buffered_write_iter
generic_perform_write
balance_dirty_pages_ratelimited
|
---balance_dirty_pages
io_schedule
真凶找到了:balance_dirty_pages。
当系统脏页占比突破内核设定的上限时,内核不再允许业务进程继续“白嫖”内存缓存,而是直接把当前写入线程挂起(Throttling),强迫前台进程参与同步写盘!
二、 内核脏页回写模型:从后台平滑到前台暴击
Linux 内核管理脏页回写主要依赖两组核心阈值(位于 /proc/sys/vm/):
- 后台回写线(Background Threshold)
vm.dirty_background_ratio(默认通常是 10%)或vm.dirty_background_bytes- 触发点:当脏页总量达到这个值时,内核唤醒
kworker/flush-*线程在后台异步刷盘,不会阻塞用户进程的write()。
- 前台限流线(Throttle Threshold)
vm.dirty_ratio(默认通常是 20%)或vm.dirty_bytes- 触发点:如果后台线程刷盘速度赶不上应用写入速度,脏页量继续堆积冲破这条线,调用
balance_dirty_pages()的进程就会被强制阻塞,必须自己同步将脏页刷盘,直到脏页量回落到安全水位。
此外还有时间维度的保底刷盘参数:
vm.dirty_expire_centisecs(默认 3000,即 30 秒):数据变脏超过这个时间,后台线程强制刷盘。vm.dirty_writeback_centisecs(默认 500,即 5 秒):后台flusher线程被唤醒的周期。
为什么默认参数在大内存机器上是灾难?
假设你的机器有 256GB 内存:
dirty_background_ratio = 10%$\rightarrow$ 脏页堆到 25.6GB 才会唤醒后台线程。dirty_ratio = 20%$\rightarrow$ 脏页堆到 51.2GB 才会触发前台限流。
平时风平浪静,一旦瞬时写入流量增大,50GB 的脏页瞬间涌入内存。等到触发限流时,内核突然要把几十 GB 的数据压给存储控制器。即使底层是企业级 NVMe SSD,在几万 IOPS 的瞬时冲击下队列也会迅速打满,产生严重的排队延迟。这不仅搞崩了写入的 P99,还会因为争抢通道让读请求的延迟一起暴毙。
三、 观测工具:用 bpftrace 捕获脏页限流
想确认你的服务是不是正在经历 balance_dirty_pages,写个一行 bpftrace 脚本最直观:
bpftrace -e 'kprobe:balance_dirty_pages { @[kstack] = count(); }'
如果你看到控制台在持续打印堆栈计数,说明业务进程已经频繁陷入前台写盘泥潭。
也可以监控系统的实时脏页内存大小:
watch -n 1 'cat /proc/meminfo | grep -E "Dirty|Writeback"'
Dirty: 已经在内存中修改、尚未刷到磁盘的内存量。Writeback: 正在往磁盘写入的内存量。
四、 调优策略:平滑刷盘,避免断崖式延迟
在高性能后端场景下,调优的核心思想是:尽早触发后台回写,把突发写入削平,严禁脏页堆积到触发前台限流。
1. 废弃 Ratio,改用 Bytes 精确控制
在大内存($\ge$ 64GB)机器上,务必禁用百分比配置,改用固定字节大小。
在 /etc/sysctl.conf 中追加:
# 当脏页达到 128MB 时,立即唤醒后台 flusher 线程刷盘
vm.dirty_background_bytes = 134217728
# 当脏页达到 512MB 时,才阻塞前台进程(给后台留足缓冲空间)
vm.dirty_bytes = 536870912
注意:设置
*_bytes后,内核会自动将对应的*_ratio置为 0,反之亦然。两组参数是互斥的。
2. 缩短脏页生命周期与检测周期
默认的 30 秒过期时间太长了,累积的数据过多。我们可以把老化时间和检查间隔缩短:
# 脏页存活 5 秒即视为过期(单位:百分之一秒)
vm.dirty_expire_centisecs = 500
# 内核每 1 秒唤醒一次后台回写线程进行检查
vm.dirty_writeback_centisecs = 100
执行生效:
sudo sysctl -p
3. 应用程序层面的配合
光靠内核调优还不够,应用程序需要配合:
- 主动
sync_file_range:如果你在写自己的存储引擎(如 Append-Only 日志),不要傻等文件写完才fsync()。可以每写满 2MB~4MB 主动调用一次非阻塞的sync_file_range(fd, offset, size, SYNC_FILE_RANGE_WRITE),引导内核提前把这段数据加入异步写队列,最后关闭文件时再调用fdatasync()。 - 避免大块
posix_fallocate的锁争用:在 EXT4/XFS 上提前分配磁盘空间时,尽量分块(Chunk)分配,避免长时间持有 inode 锁影响并发写。
五、 调优效果验证
应用上述调优后,再次执行写入压测并用 perf 观察:
cat /proc/meminfo中的Dirty内存始终稳定在 100MB ~ 300MB 之间浮动,不再出现几十 GB 暴涨的情况。bpftrace监测balance_dirty_pages调用次数归零。strace显示业务写线程的write()延迟稳定在 5~20 微秒级别,消除了长达数百毫秒的异常阻塞抖动,P99 曲线恢复为一条平滑的直线。
总结
对于低延迟要求的后端系统,内存并不是越大就越能无脑塞缓冲。默认的通用 Linux 配置优先考虑吞吐量(Throughput)而非延迟确定性(Tail Latency)。早回写、小步快跑、控死脏页上限,才是干掉 P99 I/O 尖刺的关键。
大家在生产环境遇到过哪些奇葩的 I/O 抖动?欢迎在评论区贴出你的 trace 结果一起交流!
许可协议:CC BY-NC 4.0
更新于 12 小时前
觉得文章有帮助?点个赞吧!
0 条评论


