一、从一次数据库抖动说起
某天深夜,DBA 紧急报障:MySQL P99 延迟从 2ms 飙升到 80ms,每隔约 30 秒出现一次毛刺。排查 IOUtil 后发现,磁盘突发写入带宽与 髒頁面比例 周期性高度吻合。
这不是数据库 bug,是 Linux 内核的 writeback 机制在"搞鬼"。
writeback(回写)是 Linux 内存管理子系统最容易被忽视、但直接决定 IO 行为可预测性的核心机制。本文我们将深入内核源码级别,分析从页面变脏到写入磁盘的完整路径,并给出适用于数据库、消息队列、容器化负载的调优方法论。
二、核心数据结构:从 page 到 BDI 的映射链
2.1 脏页标记的内核实现
在 Linux 内核中,一个页面变脏的本质是对 struct page 的 PG_dirty 位进行置位:
// include/linux/page-flags.h
static inline void SetPageDirty(struct page *page)
{
if (!PageDirty(page))
__set_bit(PG_dirty, &page->flags);
}
当进程通过 write() 系统调用写入 page cache 时,内核并不会立即写入磁盘,而是调用 mark_page_accessed() 与 set_page_dirty() 将页面标记为脏,将其加入对应 address_space 的脏页链表(i_pages 的 XArray 中带有 XA_MARK_1 标记)。
2.2 backing_dev_info:每设备的回写状态机
每个块设备在内核中都由一个 struct backing_dev_info(简称 BDI)结构体描述。该结构体承载了writeback的核心状态:
// include/linux/backing-dev-defs.h
struct backing_dev_info {
struct list_head bdi_list;
unsigned long ra_pages; /* 预读上限 */
unsigned long io_pages; /* 单次回写上限 */
struct bdi_writeback *wb; /* 写回线程 */
struct list_head wb_list; /* 该设备所有 wb 列表 */
unsigned long dirty_throttle_us; /* 节流延迟 */
unsigned long dirty_writeback_interval; /* 回写周期,默认 5s */
struct wb_completion *cb; /* 回写完成回调 */
atomic_long_t dirty_pages; /* 脏页计数 */
atomic_long_t writeback_pages; /* 正在回写的页数 */
atomic_long_t bdi_writeout; /* 累计写出页数 */
/* 带宽估算 */
struct b_writeout *bw_time_stamp; /* 用于计算回写带宽 */
};
关键字段 dirty_writeback_interval 默认为 500(即 5 秒),这是 writeback 线程运行的最小间隔。每个 BDI 对应一个或者多个 b_writeout 结构体(Linux 5.16+ 之后每个 cgroup 可独立一个 writeback group)。
2.3 bdi_writeback:实际干活的工人
// fs/fs-writeback.c
struct bdi_writeback {
struct backing_dev_info *bdi;
unsigned long state;
struct list_head b_dirty; /* 脏页 inode 链表 */
struct list_head b_io; /* 正在回写的 inode */
struct list_head b_more_io; /* 需要继续回写的 inode */
struct delayed_work dwork; /* 周期性回写 work */
unsigned long last_flush; /* 上次 flush 时间 */
unsigned long last_old_flush; /* 超时脏页 */
/* 带宽计算 */
nr_integrity_pages_t wb_written; /* 本轮已写页数 */
};
一个 BDI 可以对应多个 bdi_writeback(multi-writeback 模式,如 cgroup v2 per-cgroup 回写),这是容器化 IO 隔离的基础。
三、完整的 writeback 路径:从脏页到磁盘
3.1 脏页阈值与压力反馈
内核通过四个 /proc/sys/vm 参数控制脏页行为:
# 脏页占系统总内存上限 [默认 20%]
vm.dirty_ratio = 20
# 后台回写启动阈值 [默认 10%]
vm.dirty_background_ratio = 10
# 按字节配置的等价参数(优先)
vm.dirty_bytes = 0
vm.dirty_background_bytes = 0
# 脏页过期时间,单位 1/100 秒 [默认 3000 = 30s]
vm.dirty_expire_centisecs = 3000
# 回写线程运行周期,单位 1/100 秒 [默认 500 = 5s]
vm.dirty_writeback_centisecs = 500
内核通过 dirty_limits() 与 __wb_calc_thresh() 函数,计算每个 BDI 应该承担多少回写量。在多 BDI 场景下,内核使用比例分配算法:某 BDI 的记忆容量越大,允许保留的脏页越多。
3.2 主动回写触发点
writeback 不是被动等待的,有四个主要触发点:
1. 脏页超过 dirty_background_ratio 时
balance_dirty_pages_ratelimited() 被调用时,如果脏页超过后台回写阈值,将唤醒 wb_workfn() 工作线程。
2. 脏页超过 dirty_ratio 时(脏页阻塞)
这是数据库忽迟飙升的元凶。当脏页达到 vm.dirty_ratio 时,balance_dirty_pages() 会强制让调用进程参与回写(throttle),其函数原型:
// mm/page-writeback.c
void balance_dirty_pages(struct bdi_writeback *wb)
{
/* 计算当前 BDI 的回写带宽,估算完成回写所需时间 */
elapsed = (jiffies - wb->bw_time_stamp) / HZ;
written = percpu_counter_read(&wb->stat[WB_WRITTEN]);
bw = written / elapsed;
/* 如果预估回写时间超过 dirty_expire_centisecs,强制阻塞任务 */
pause = 1000 * (1000 + elapsed / 1000) / bw;
schedule_timeout(pause); /* 主动睡眠,等待 IO 完成 */
}
此时调用 write() 的进程会进入不可中断的 TASK_UNINTERRUPTIBLE 状态,直到脏页降到安全水位以下。
3. 脏页超过 dirty_expire_centisecs 时(过期回写)
wb_workfn() 周期性扫描发现某个 inode 的脏页已过期,将其加入 b_io 链表,进行过期回写。
4. 显式同步请求
fsync()、fdatasync()、sync() 触发直接回写。
3.3 wb_workfn 的回写逻辑
核心函数 writeback_inodes_wb() 定义了每次回写的流程:
// fs/fs-writeback.c
static long wb_writeback(struct bdi_writeback *wb,
struct wb_writeback_work *work)
{
/*
* 1. 首先处理过期的 inode(work->older_than_this)
* 2. 回写 b_io 中的 inode
* 3. 如果脏页仍超过阈值,继续回写 b_more_io
* 4. 本次最多回写 work->nr_pages 个页面
*/
do {
/* wb_check_old_data_flush: 处理过期脏页 */
wrote += wb_check_old_data_flush(wb);
/* wb_check_background_writeback: 处理后台回写 */
wrote += wb_check_background_writeback(wb);
} while (need_more_io(wb, work));
return wrote;
}
每次回写后调用 wb_update_bandwidth() 更新该 BDI 的带宽估算,用于计算下一次 balance_dirty_pages() 需要阻塞多久。
四、容器化与 cgroup v2 的 per-cgroup writeback
4.1 从全局 BDI 到 cgroup 级别 writeback
在 cgroup v1 时代,每个 BDI 只有一个全局 writeback group,容器之间互相抢带宽,且宿主机上某一容器批量刷脏可能饿死其他容器的回写请求。
cgroup v2 引入 CONFIG_CGROUP_WRITEBACK,每个 cgroup 都可以有独立的 bdi_writeback 结构体,共享同一个 BDI,但脏页回写按 cgroup 隔离。
// fs/fs-writeback.c (v5.16+)
static void wb_blcg_workfn(struct work_struct *work)
{
struct bdi_writeback *wb = blcg_to_wb(blcg);
/* 仅回写属于该 cgroup 的脏页 */
...
}
这最终实现了 IO 隔离的良景:你可以通过 memory.bandwidth 或 blkio max 控制某一容器的 dirty 页数。
4.2 Folio 与大页回写
Linux 5.16 引入 folio(基础页数组合),6.0+ 进一步在文件系统(XFS、ext4、btrfs)中推广 folio 化的 writeback。
传统 4K 页回写在高容量 NVMe 上效率低下。folio 可以将多个连续的物理页面合并为一次回写,减少 IO 次数。write_folio() 通过 folio_test_large() 检测大页并执行批量回写:
// mm/page-writeback.c (v6.1+)
int write_back_data(struct folio *folio)
{
unsigned long nr_pages = folio_size(folio) / PAGE_SIZE;
/* 单次提交 1M-2M 的连续回写请求 */
...
}
五、极端 IO 场景调优方法论
5.1 OLTP 数据库(MySQL / PostgreSQL)
高并发小写入场景下,最大的问题是"突发写"导致磁盘带宽饱和和进程阻塞。
推荐配置:
# 降低脏页上限,减少突发写
vm.dirty_ratio = 15
vm.dirty_background_ratio = 5
vm.dirty_bytes = 0
vm.dirty_background_bytes = 0
# 更频繁的后台回写,防止脏页堆积
vm.dirty_writeback_centisecs = 100 # 1 秒
vm.dirty_expire_centisecs = 1500 # 15 秒
# 降低 swappiness 让内存更多用于白缓存
vm.swappiness = 1
# 如果数据库使用表文件直接挂载到原生文件系统
# 考虑启用 writeback_cache 或使用 direct_io 绕开页缓存
关键原理:降低 dirty_background_ratio 让回写更提前、更平稳,避免一大波脏页堆积导致突发 IO。缩短回写周期让每次回写量更小但更均匀。
陷阱:不要同时将 vm.dirty_ratio 和 vm.dirty_background_ratio 调得过低,否则会有两大问题:(1) 总脏页少,缓存命中率下降;(2) 频繁触发回写,CPU 开销增大。
5.2 日志/消息队列(Kafka / Pulsar)
日志消费场景的特点是:大批量顺序写入,对延迟极不敏感,但需要高吞吐。
# 允许偏高脏页上限,让缓存更久
vm.dirty_ratio = 40
vm.dirty_background_ratio = 15
vm.dirty_expire_centisecs = 3000
vm.dirty_writeback_centisecs = 6000 # 每 6 秒回写一次
# 增加预读量,减少 IO 次数
blockdev --setra 4096 <device> # 2M 预读
关键原理:顺序写入场景下可以利用 page cache 的缓存优势,让大批量数据积累在内存中再一次性刷盘。但要注意不超出物理内存上限,否则触发 Direct Reclaim 导致 STW(Stop-The-World)。
5.3 容器化 / K8s 环境
在 K8s 中,容器化 writeback 按 cgroup v2 隔离。但需要注意一个常见问题:挂载 emptyDir 存储卷时,containerd/container 将卷视为独立 BDI,不会共享宿主机的全局 writeback 配置。
正确做法:
apiVersion: v1
kind: Pod
metadata:
name: db-pod
spec:
containers:
- name: mysql
volumeMounts:
- mountPath: /var/lib/mysql
name: data
volumes:
- name: data
emptyDir:
medium: Memory # 使用 tmpfs,完全绕过磁盘回写
sizeLimit: 2Gi
cgroup v2 精细调节:
# 查看当前 cgroup 的回写状态
cat /sys/fs/cgroup/io.stat # 输出各 cgroup 的 rbytes wrios 等
# 通过 IO max 限制该容器的回写带宽
echo "8:0 wbps=104857600" > /sys/fs/cgroup/system.slice/myapp.service/io.max
5.4 NVMe SSD 与 SPDK 场景
NVMe SSD 的队列深度可达 64K,传统内核 writeback 会成为瓶颈。此时可考虑:
- io_uring + polled mode:使用
IORING_SETUP_IOPOLL绕过内核中断驱动回写 - SPDK:完全绕过内核,在用户态直接操作 NVMe 队列
- DAX(Direct Access):将 NVMe 设备以 persistent memory 方式挂载,使用
mmap()代替read()/write()路径
# DAX 挂载示例
mkfs.xfs -m reflink=1 /dev/pmem0
mount -o dax /dev/pmem0 /mnt/pmem
DAX 模式下完全不经过 page cache,dirty 页概念不复存在。
六、实战:用 eBPF 观测 writeback 行为
6.1 writeback 专属 tracepoint
内核提供 7 个 writeback 相关 tracepoint:
writeback_dirty_page
writeback_start
writeback_written
writeback_wait
writeback_single_inode_start
writeback_single_inode
writeback_queue
6.2 BPFtrace 脚本:实时观察脏页频率
#!/usr/bin/bpftrace
# trace_writeback.bt - 观测每次 writeback 的脏页数与耗时
BEGIN {
printf("=== Writeback Tracer ===\n");
printf("%-20s %-10s %-10s %-10s\n",
"TIME(ms)", "PAGES", "BANDWIDTH", "inode");
}
tracepoint:writeback:writeback_written {
$wb = (struct bdi_writeback *)args->wb;
$written = $wb->wb_written;
time("%H:%M:%S ");
printf("%-10d %-10.2f %s\n",
args->nr_pages,
args->nr_pages * 4096 / 1024 / 1024, /* MB/s估算 */
args->sb ? str(args->sb->s_id) : "N/A");
}
END {
printf("=== 统计结束 ===\n");
}
运行示例:
sudo ./trace_writeback.bt
输出示例:
12:34:56.789 2048 8.00 MB/s nvme0n1
12:34:57.012 512 2.00 MB/s nvme0n1
12:35:01.456 4096 16.00 MB/s nvme0n1
6.3 bpftrace 检测脏页阻塞事件
#!/usr/bin/bpftrace
# detect_dirty_block.bt - 检测因脏页过多被阻塞的进程
kprobe:balance_dirty_pages,
kprobe:wb_wait_for_completion
{
$task = (struct task_struct *)curtask;
$now = nsecs;
printf("DIRTY_THROTTLE: pid=%d comm=%s nr_dirty=%lu paused=%dus\n",
$task->pid, $task->comm,
arg0, /* 脏页数 */
arg1); /* 估计阻塞时长 */
}
运行后可见:
DIRTY_THROTTLE: pid=1234 comm=mysqld nr_dirty=524288 paused=120us
DIRTY_THROTTLE: pid=5678 comm=postgres nr_dirty=393216 paused=85us
七、Linux 6.x 中 writeback 的演进
7.1 Folio 与 per-folio dirty
Linux 6.0+ 引入 folio_mark_dirty() 替代 set_page_dirty(),在 extent-based 文件系统(XFS、btrfs)中以 extent 粒度追踪脏数据,减少管理开销。
7.2 Folio writeback 优化
Linux 6.3 优化了 writeback_io(),对于连续的大页,使用单个 NVMe write command 发送,无需合并 scatter-gather list。
7.3 cgroup 回写进一步优化
Linux 6.5 在 cgroup v2 writeback 中引入 wb_slots 机制:每个 cgroup 的回写请求按 slot 分配,防止某 cgroup 的突发大量写饿死其他 cgroup 的 IO 带宽。
7.4 大页回写(THP writeback)
Linux 6.6 对透明大页的回写做了重点优化:当 THP 的部分页被写脏时,不再强制 split 整个 2M 大页,而是仅回写脏页部分,避免放大 IO。
八、总结与建议
writeback 是 Linux IO 栈中最复杂但也最值得掌握的子系统。不同负载的最优配置截然不同:
| 场景 | dirty_ratio | bg_ratio | expire | writeback |
|---|---|---|---|---|
| 高并发 OLTP 数据库 | 15 | 5 | 1500 | 100-200 |
| 消息队列日志盘 | 40 | 15 | 3000 | 3000-6000 |
| 容器混合负载(多租户) | 10 | 5 | 1500 | 500 |
| DAX / 持久内存(如 PMem) | N/A | N/A | N/A | N/A |
三条铁律:
- 不要让脏页直冲 dirty_ratio
%:设置dirty_background_ratio至少比dirty_ratio低 10 个百分点。 - 频繁短 burst 优于长 stall:更短的
dirty_writeback_centisecs可以减少毛刺。 - 测量真实带宽:通过 eBPF tracepoint 观测每次回写量,比单纯依赖 sysctl 更有效。
writeback 不是"设置即完毕",而是需要根据负载特征动态调整的工程实践。理解内核回写机制,才能从根本上解决 IO 性能毛刺问题。

发表评论 取消回复