一、从一次数据库抖动说起

某天深夜,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 会成为瓶颈。此时可考虑:

  1. io_uring + polled mode:使用 IORING_SETUP_IOPOLL 绕过内核中断驱动回写
  2. SPDK:完全绕过内核,在用户态直接操作 NVMe 队列
  3. 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

三条铁律:

  1. 不要让脏页直冲 dirty_ratio%:设置 dirty_background_ratio 至少比 dirty_ratio 低 10 个百分点。
  2. 频繁短 burst 优于长 stall:更短的 dirty_writeback_centisecs 可以减少毛刺。
  3. 测量真实带宽:通过 eBPF tracepoint 观测每次回写量,比单纯依赖 sysctl 更有效。

writeback 不是"设置即完毕",而是需要根据负载特征动态调整的工程实践。理解内核回写机制,才能从根本上解决 IO 性能毛刺问题。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部