一、为什么写回机制是 I/O 栈的"最后一公里"?

在我们之前深入剖析了 Linux 内存管理、页面缓存与回收 以及 块设备层 blk-mq 架构 之后,有一个关键桥梁始终未被展开:脏页写回(Writeback)机制。

当应用程序调用 write() 或 mmap() 修改文件时,内核并不会立即将数据写入磁盘,而是标记页面为"脏"(dirty),然后由后台线程在合适的时机异步回写。这个看似简单的"延迟写入"机制,实际上涉及:

  • 脏页产生与传播:page cache 脏标记、mmap redirty 逻辑、文件系统日志回写
  • BDI 管理架构:Backing Device Info 结构、per-BDI 注册与生命周期
  • 回写线程演进:从 pdflush 到 BDI-flusher 到 wb_workqueue,三代架构变迁
  • 回写策略控制:dirty_ratio、dirty_background_ratio、dirty_expire_centisecs、dirty_writeback_centisecs 四大参数联动
  • 回写工作流:wb_workfn → writeback_single_inode → do_writepages 全链路
  • 性能抖动诊断:direct reclaim 风暴、writeback 拥塞、BDI 饥饿、IOосаждение

本文将从内核源码级别,结合实战调优、eBPF 观测、生产案例分析,彻底拆解 Linux 写回机制。

二、脏页生命周期:从 write() 到 Writeback 的完整路径

2.1 脏页产生——文件写入的时间线

当一个普通 write() 系统调用发生时,数据从用户空间到被标记为脏页的路径如下:

用户态 write(buf, 4096)
  → sys_write()
    → vfs_write()
      → new_sync_write()
        → call_write_iter()         // 文件系统的 write_iter
          → ext4_file_write_iter()  // 以 ext4 为例
            → generic_perform_write()
              → a_ops->write_begin()  // 准备页缓存页面
              → copy_from_user()      // 拷贝数据
              → a_ops->write_end()    // 准备页缓存页面
                → __set_page_dirty()  // 标记 PG_dirty ⭐

__set_page_dirty() 是脏页产生的核心入口。它做了什么?

// mm/page-writeback.c: 核心脏页标记
static void __set_page_dirty(struct page *page, struct address_space *mapping)
{
    // 1. 设置 PG_dirty 标志位
    SetPageDirty(page);
    
    // 2. 将 inode 添加到 per-BDI 的 b_dirty 链表
    if (!TestSetPageDirty(page)) {
        // 脏计数递增(percpu_counter)
        __inc_node_page_state(page, NR_FILE_DIRTY);
        __inc_zone_page_state(page, NR_ZONE_WRITE_PENDING);
        
        // 将 inode 加入 b_dirty 列表
        // 每个 b_dirty inode 仅被加入一次
        mark_inode_dirty(mapping->host);
    }
}

关键数据结构:每个 inode 通过 bdi_writeback 结构挂载到所属 BDI 的三个链表之一:

bdi_writeback
├── b_dirty     // 有脏页尚未开始回写的 inode 链表
├── b_io        // 正在回写中的 inode 链表  (writeback_inodes_wb)
└── b_more_io   // 当前周期回写时又产生新脏页的 inode(用于下一轮处理)

2.2 mmap 脏页的特殊路径

对于 mmap 映射产生的脏页,路径完全不同——它通过缺页异常触发:

CPU 访问 mmap 只读页 → #PF 缺页异常
  → do_page_fault()
    → handle_mm_fault()
      → handle_pte_fault()
        → do_wp_page()              // 写时复制
          → pte_mkdirty()          // 设置页表 Dirty 位
          → SetPageDirty(page)     // 标记 PG_dirty(延迟到刷回时)

注意 mmapped 脏页的特殊性:

  • 页表 PTE 的 Dirty 位由 CPU 自动设置
  • 内核的 PG_dirty 标志通过 folio_mark_dirty_for_io() 在 page writeback 时由反向映射(reverse mapping)确认后设置
  • msync() 系统调用可以强制刷回 mmap 脏页

2.3 四大核心参数:决定脏页何时开始回写

Linux 通过 /proc/sys/vm/ 下的四个参数控制回写行为:

参数默认值(典型)含义
dirty_background_ratio10脏页占总内存达到此比例时,后台回写启动(异步)
dirty_background_bytes0与 ratio 互斥,字节级控制
dirty_ratio20脏页占总内存达到此比例时,进程被阻塞同步刷回(同步)
dirty_bytes0与 ratio 互斥,字节级控制
dirty_expire_centisecs3000(30秒)脏页过期时间,超过此时长的脏页标记为回写候选
dirty_writeback_centisecs500(5秒)wb_writeback 线程唤醒周期

参数优先级:bytes 系列优先于 ratio 系列。如果设置了 dirty_bytes 非零,则 dirty_ratio 被忽略。

典型的生产环境推荐值(16GB 内存数据库服务器):

# 高写入负载场景:延缓同步阻塞,减少突发延迟
dirty_background_ratio = 5       # 800MB 即开始后台回写
dirty_ratio = 10                 # 1.6GB 触发同步阻塞
dirty_expire_centisecs = 3000
dirty_writeback_centisecs = 1000  # 更频繁的巡检

三、BDI 架构:backing_device_info 深度解析

3.1 BDI 的诞生与设计目标

在 Linux 2.6 之前,pdflush 线程数量为固定 2~8 个,所有设备共享这些线程。这导致高速 NVMe 和低速 USB 共用同一回写队列,互相拖慢——这被称为"BDI 饥饿"问题。

Linux 2.6 引入了 per-BDI flusher 概念(commit),每个块设备分配独立的 backing_dev_info 结构:

// include/linux/backing-dev.h
struct backing_dev_info {
    u64 ra_pages;                     // 预读窗口大小
    unsigned long io_pages;           // 正在等待的 I/O 页数
    
    struct bdi_writeback wb;          // ⭐ 核心:per-cpu 回写状态
    struct list_head b_dirty;         // 脏 inode 列表
    struct list_head b_io;            // 正在回写的 inode 列表  
    struct list_head b_more_io;       // 待下一轮处理的 inode 列表
    spinlock_t list_lock;             // 三个链表的锁
    
    unsigned long last_old_flush;     // 上次过期检查时间 jiffies
    struct wb_writeback_work work;    // 回写工作项
    
    struct device *dev;               // 关联设备
    char bdi_id[BDI_CAPACITY_NAME];   // BDI 标识符
    
    unsigned long min_ratio;          // 最小回写比例
    unsigned long max_ratio;          // 最大回写比例
    unsigned long max_prop_frac;      // 最大比例分数
    
    struct bdi_writeback_congested *wb_congested; // 拥塞状态
} ____cacheline_aligned_in_smp;

3.2 BDI 注册流程

BDI 的注册在块设备挂载或设备驱动 probe 时发生:

// 驱动调用典型路径:
blk_alloc_queue()
  → bdi_alloc()              // 分配 backing_dev_info
→ bdi_register()             // 注册 BDI 到全局 bdi_list
  → device_add(&bdi->dev)    // 注册为设备模型中的设备
  → sysfs 创建 /sys/class/bdi/<device>/ 属性文件

每个块设备在 sysfs 中暴露的属性:

$ ls /sys/class/bdi/8:0/
max_ratio                   # 回写带宽上限比例
min_ratio                   # 回写带宽下限比例  
dirty_limit                 # 脏页限制
read_ahead_kb               # 预读窗口(KB)
stats/                      # 统计目录
  ├── BdiReclaimable        # 可回收回写页数
  ├── BdiDirty              # 当前脏页数
  ├── BdiWriteback          # 正在回写页数
  ├── BdiWritten            # 累计已回写页数
  └── avg_write_bandwidth   # 平均写带宽

四、三代回写线程架构演进

4.1 第一代:pdflush(Linux 2.4/2.6 早期)

pdflush(page dirty flush)是最早的回写线程方案。内核创建 2~8 个 pdflush 线程,扫描整个系统所有设备的脏页:

// mm/pdflush.c(已移除的代码)
static int pdflush()
{
    // 每个 pdflush 线程循环执行
    for (;;) {
        // 遍历全局 inode_unused 链表
        // 找到脏 inode 后调用 writeback_inode()
        // 通过 balance_dirty_pages_ratelimited() 控制速率
    }
}

致命问题:

  • 所有设备共享 pdflush 线程池
  • 无法做 NUMA 感知的 CPU 绑定
  • 高速设备和低速设备相互干扰
  • 线程数量动态调整算法不合理

4.2 第二代:BDI-flusher per-device(Linux 2.6.32+)

2.6 内核引入 per-BDI 机制,每个有脏页的 BDI 分配一个 bdi_writeback 结构:

// 每个 BDI 的回写线程:flush-<major>:<minor>
// 例如:flush-8:0(sda),flush-8:16(sdb)
struct task_struct *bdi->wb.task;  // per-BDI 回写线程

优势:每个磁盘独立线程,消除了设备间干扰。但新问题:

  • 每多一个块设备就多一个线程,数百个 LVM 逻辑卷时线程量爆炸
  • 线程创建/销毁开销大
  • 空闲设备的 pdflush 线程空转消耗 CPU

4.3 第三代:wb_workqueue workqueue 化(Linux 5.0+)

Linux 5.0+ 彻底重写了回写线程管理,引入 wb_workqueue 机制:

// fs/fs-writeback.c: 核心数据结构
static struct workqueue_struct *bdi_wq;  // 核心回写 workqueue

struct bdi_writeback {
    struct backing_dev_info *bdi;        // 所属 BDI
    
    unsigned long last_switch_time;      // 上次切换时间
    struct wb_writeback_work work;       // 回写工作项
    
    struct list_head b_dirty;            // 脏 inode 链表
    struct list_head b_io;               // I/O 中 inode 链表
    struct list_head b_more_io;          // 待处理 inode 链表
    spinlock_t work_lock;                // 工作项锁
    
    unsigned long dirtied_stamp;         // 脏页产生时间戳
    unsigned long written_stamp;         // 回写完成时间戳
    
    unsigned long write_bandwidth;       // 当前写带宽估算
    unsigned long avg_write_bandwidth;   // EWMA 平均写带宽
    
    struct delayed_work dwork;           // ⭐ 延迟调度(替代固定线程)
    struct percpu_counter stat[NR_WB_STAT_ITEMS]; // 统计计数器
};

核心变化:回写线程由 workqueue 动态分配,不再每个设备固定一个线程。空闲时 workqueue 线程回收,繁忙时动态扩展。

五、wb_writeback 全链路详解

5.1 回写触发的四种时机

┌─────────────────────────────────────────────────────────────┐
│                    回写触发源                                │
├────────────┬────────────────────────────────────────────────┤
│ 1. 后台阈值│ balance_dirty_pages() 检测                     │
│ (background)│ → 脏页超过 dirty_background_ratio              │
│            │ → 唤醒 wb_workfn 异步回写                      │
├────────────┼────────────────────────────────────────────────┤
│ 2. 前台阻塞│ balance_dirty_pages_ratelimited()             │
│ (foreground)│ → 进程脏页超过 dirty_ratio                    │
│            │ → 进程被阻塞,直接调用 writeback_single_inode   │
├────────────┼────────────────────────────────────────────────┤
│ 3. 过期回写│ wb_workfn() 周期巡检                           │
│ (periodic) │ → dirty_expire_centisecs 到期                  │
│            │ → 无论脏页量多少都扫描过期页                   │
├────────────┼────────────────────────────────────────────────┤
│ 4. 显式同步│ sync() / fsync() / fdatawrite() / write_inode()│
│ (explicit) │ → 绕过所有阈值,强制刷回                       │
└────────────┴────────────────────────────────────────────────┘

5.2 wb_workfn:回写的核心函数

wb_workfn 是每个 BDI 回写的入口,由 workqueue 调度执行:

// fs/fs-writeback.c
static long wb_workfn(struct work_struct *work)
{
    struct bdi_writeback *wb = container_of(work, ...);
    long pages_written;
    
    set_user_nice(current, 0);  // nice=0, 适当优先级
    
    while (!kthread_should_stop()) {
        // 第一阶段:处理 b_more_io 链表(上一轮遗留)
        pages_written = writeback_inodes_wb(wb, &work, WB_REASONS_MORE_IO);
        
        // 第二阶段:检查是否有过期脏页
        if (list_empty(&wb->b_dirty) || time_before(jiffies, wb->last_old_flush + dirty_expire_centisecs * HZ / 100)) {
            // 无脏页或还没到过期时间,进入睡眠
            goto sleep;
        }
        
        // 第三阶段:回写过期脏页
        pages_written = wb_writeback(wb, &work);
        
        // 如果还有未完成的脏页,继续下一轮
        if (pages_written > 0)
            continue;

sleep:
        // 任务排入延时工作队列
        queue_delayed_work(bdi_wq, &wb->dwork,
            wb->last_old_flush + dirty_writeback_centisecs * HZ / 100 - jiffies);
    }
}

5.3 wb_writeback:选择要回写的 inode

wb_writeback(wb, work)
  │
  ├── 如果 work->for_kupdate:只回写指定时间前的脏页(fdatawrite 触发)
  ├── 如果 work->for_background:只回写过期脏页
  │
  ┌── for_each_inode_in_list_entry_safe(&wb->b_dirty)
  │     │
  │     ├── inode 有 I/O 错误?跳过
  │     ├── inode 过期检查:inode->dirtied_when < jiffies - expire_interval
  │     │     └── 是 → 加入回写列表 writeback_list
  │     │
  │     └── writeback_single_inode(inode, work)
  │           │
  │           ├── mutex_lock(&inode->i_lock)
  │           ├── 检查 I_DIRTY 标志
  │           │
  │           ├── 如果有 I_DIRTY_PAGES(有脏页):
  │           │     └──do_writepages(mapping, wbc)
  │           │         └──a_ops->writepages() ← 文件系统的页回写方法
  │           │           └──ext4_writepages()
  │           │             ← 扫描 extent tree,构建 bio
  │           │             ← 调用 mpage_submit_page() → submit_bio()
  │           │
  │           ├── 如果有 I_DIRTY_SYNC(inode 自身脏):
  │           │     └──write_inode(inode, wbc)
  │           │       └──ext4_write_inode()
  │           │         ← 写回 inode 元数据
  │           │
  │           └── 成功?从 b_dirty 移除,加入 b_io 临时列表
  │
  └── 完成所有 inode 后:
        └── wb_check_start_all(&wb->bdi->wb_list) 检查是否需继续

5.4 ext4_writepages 深度剖析

ext4 文件系统的 writepages 方法是一个复杂的引擎,它需要高效地扫描所有脏页并合并成一个大 bio:

// fs/ext4/inode.c
static int ext4_writepages(struct address_space *mapping,
                           struct writeback_control *wbc)
{
    struct ext4_inode_info *ei = EXT4_I(inode);
    ext4_io_end_t *io_end;
    
    // 1. 通过 mpage_scan_extent() 扫描 extent tree
    //    找到包含脏页的逻辑块 → 物理块映射
    ext4_ext_map_blocks(NULL, inode, &map, 0);
    
    // 2. 按物理块地址排序脏页,最大化连续回写
    //    → 生成 struct mpage_da_data
    
    // 3. 调用 ext4_io_submit() 批量提交 bio
    //    ← 这里我们完成的循环!
    //    从用户 write() → page cache → balance_dirty_pages → 
    //    wb_workfn → wb_writeback → writeback_single_inode →
    //    ext4_writepages → submit_bio() → blk-mq → NVMe → 磁盘
    
    // 4. 等待 I/O 完成
    ext4_end_bio() → unlock_page() → end_page_writeback()
    
    return ret;
}

六、拥塞控制与 QoS

6.1 BDI Congestion:回写反压机制

当某个 BDI 的回写速度跟不上脏页产生速度时,需要通过拥塞机制反压到写入进程:

// include/linux/backing-dev-defs.h
struct bdi_writeback_congested {
    unsigned long state;                // WB_congested_* 状态位
#define WB_async_congested  0           // 异步拥塞(后台回写跟不上)
#define WB_sync_congested   1           // 同步拥塞(阻塞写入)
    struct timer_list congestion_wait;  // 拥塞解除等待
};

// 拥塞检测方法
static bool mapping_congested(struct address_space *mapping, int sync)
{
    // 检查 BDI 的拥塞状态
    if (test_bit(WB_async_congested, &congested->state))
        return true;
    
    // 检查是否是同步拥塞
    if (sync && test_bit(WB_sync_congested, &congested->state))
        return true;
    
    return false;
}

// 写入时检查拥塞
void throttle_vm_writeout(gfp_t gfp_mask)
{
    // 1. 等待拥塞解除
    // 2. 调用 congestion_wait(BLK_RW_ASYNC, HZ/10)
    // 3. 超时后直接触发同步回写
}

6.2 cgroup v2 writeback 控制

Linux 5.2+ 引入 cgroup v2 的 writeback 控制,允许按 cgroup 限制回写带宽:

// 每个回写的 bio 现在携带 cgroup 信息
struct bio {
    struct bio_cgroup_css *bi_css;  // 指向 cgroup 控制结构
};

// cgroup v2 通过 io.max 控制回写速率
$ echo "8:0 wiops=10000" > /sys/fs/cgroup/mygroup/io.max
# 限制 /dev/sda 的写 IOPS 为 10000

七、生产环境调优实战

7.1 数据库场景(MySQL/PostgreSQL)

数据库通常使用 O_DIRECT 或自有缓存管理,但仍需关注 filesystem writeback:

# /etc/sysctl.d/99-database.conf

# WAL/journal 写入路径不能因拥塞而阻塞
vm.dirty_background_ratio = 3
vm.dirty_ratio = 5

# 更频繁的回写巡检,减少瞬时的 I/O 峰值
vm.dirty_writeback_centisecs = 1000

# WAL 过期时间不要太快(ext4 journal 写回频繁)
vm.dirty_expire_centisecs = 5000

# MySQL 双写缓冲和 redo log 除外
# innodb_flush_method = O_DIRECT 绕过 page cache

7.2 日志收集场景(ELK/Loki)

日志收集的特征是大量顺序写、对延迟容忍度高:

/etc/sysctl.d/99-logging.conf

# 允许更多脏页,提高写合并效率
vm.dirty_background_ratio = 15
vm.dirty_ratio = 30

# 建议配合使用(明确字节值)
# vm.dirty_background_bytes = 4294967296  # 4GB
# vm.dirty_bytes = 8589934592             # 8GB

# 过期时间可以较长(日志最终持久化即可)
vm.dirty_expire_centisecs = 6000

# 较长的巡检间隔,减少 CPU 消耗
vm.dirty_writeback_centisecs = 1000

7.3 高性能计算/AI 训练场景

GPU 训练的 checkpoint 通常是大文件异步写回:

/etc/sysctl.d/99-hpc.conf

# 大内存机器(512GB+),ratio 值应该按比例放大
# 或者直接使用 bytes
vm.dirty_background_bytes = 16106127360   # 16GB
vm.dirty_bytes = 32212254720              # 32GB

# Checkpoint 通常是 SSD 阵列,不需要太频繁
vm.dirty_expire_centisecs = 3000
vm.dirty_writeback_centisecs = 2000

# ⭐ 关键:对 NVMe 设备启用 write_cache
$ cat /sys/block/nvme0n1/queue/write_cache
# 确保是 write back 模式(有掉电保护时)

7.4 K8s 容器场景

容器环境下需要使用 cgroup v2 io.max 控制回写带宽,避免 noisy neighbor:

# Pod 资源限制片段
resources:
  limits:
    # 内核 cgroup v2 io.max 格式
    # <device> wbps=<limit> 或 riops=<limit>

# 宿主机侧设置(CRI-O/containerd 的 baseline)
# OCI hook 注入 cgroup io.max:
$ echo "8:0 wbps=524288000" > /sys/fs/cgroup/kubepods.slice/.../io.max
# 500MB/s 写带宽限制

八、eBPF/bpftrace 观测写回

8.1 跟踪脏页产生速率

#!/usr/bin/env bpftrace
#!/usr/bin/bpftrace

// 跟踪 set_page_dirty 调用栈
kprobe:__set_page_dirty_nobuffers,
kprobe:folio_mark_dirty_for_io
{
    @cnt[comm] = count();
}

// 统计每秒脏页产生
s:1
{
    print(@cnt);
    clear(@cnt);
}

// 使用示例:找出产生脏页最多的进程
// $ sudo bpftrace trace_dirty.bt
//
// Attaching 2 probes...
// @cnt[rsyslogd]: 4521
// [ 1s ]
// @cnt[journalctl]: 82341
// [ 1s ]
// @cnt[postgres]: 234891    ← 发现 PostgreSQL 是脏页主力

8.2 跟踪 wb_writeback 执行状态

#!/usr/bin/bpftrace

// 跟踪回写单次处理时间
kprobe:wb_writeback
{
    @start[tid] = nsecs;
}

kretprobe:wb_writeback
/@start[tid]/
{
    $wb = (struct bdi_writeback *)arg0;
    $bdi = $wb->bdi;
    $duration = (nsecs - @start[tid]) / 1000;
    
    // 打印回写耗时
    time("%H:%M:%S");
    printf("wb_writeback pid=%d pages_written=%d time_us=%d\n",
           pid, $wb->work.nr_pages, $duration);
    
    delete(@start[tid]);
}

// 跟踪每个 inode 的回写延迟
kprobe:writeback_single_inode
{
    $inode = (struct inode *)arg0;
    $dirtied = $inode->i_dirtied_when;
    @inode_start[tid] = $dirtied;
}

kretprobe:writeback_single_inode
/@inode_start[tid]/
{
    $latency = (jiffies - @inode_start[tid]) * 1000 / HZ;
    // latency = 从 dirtied_when 到回写开始的延迟(ms)
    @latency_ms = hist($latency);
    delete(@inode_start[tid]);
}

8.3 BCC 工具集

BCC 提供了多个现成的写回观测工具:

# 1. 查看每个 BDI 的写带宽
$ bcc-wbabs 
BDI                    Write(KB/s)    Write(%)    Congested
8:0 (sda)              125432         87.3        0
259:0 (nvme0n1)        45231          100.0       1     ← 拥塞中

# 2. 跟踪 writeback 页提交
$ btrfstat -p 1

# 3. 脏页年龄分布直方图
$ bpftrace -e '
kprobe:wb_start_writeback {
    $wb = (struct bdi_writeback *)arg0;
    @age = hist((jiffies - $wb->dirtied_stamp) * 1000 / HZ);
}'

九、故障排查清单

9.1 问题一:write 调用偶发卡顿

症状:应用出现 100ms~数秒级别的写入延迟尖峰。

根因:触发同步回写(foreground writeback),进程在 balance_dirty_pages_ratelimited() 中被阻塞。

排查流程:

# 1. 检查是否触发了同步回写
$ dmesg | grep -i dirty
"Write-out is starting"
"Free swap: 0kB"

# 2. 查看当前脏页量
$ grep -E "Dirty|Writeback|NFS_Unstable" /proc/meminfo
Dirty:         5242880 kB        # 5GB 脏页!
Writeback:     2048000 kB

# 3. 检查 dirty_ratio 配置
$ sysctl vm.dirty_ratio vm.dirty_background_ratio
vm.dirty_ratio = 20              # 内存 64GB 时限制 12.8GB
vm.dirty_background_ratio = 10

# 4. 查看哪个设备在拥塞
$ cat /sys/class/bdi/*/congested 

# 5. 解决方案
echo 10 > /proc/sys/vm/dirty_ratio
echo 5 > /proc/sys/vm/dirty_background_ratio

9.2 问题二:sync/fsync 异常慢

症状:fsync() 调用耗时从 10ms 突然升高到 5s+。

排查流程:

# 1. 确认需要回写的脏页数量
$ grep "Dirty" /proc/meminfo
Dirty:        16777210 kB    # 16GB 脏页需要一次性刷回

# 2. 检查 fsync 的内核路径耗时
$ bpftrace -e '
kprobe:ext4_sync_file {
    @start[tid] = nsecs;
}
kretprobe:ext4_sync_file /@start[tid]/ {
    printf("fsync pid=%d time_us=%d\n", pid, (nsecs-@start[tid])/1000);
    delete(@start[tid]);
}'

# 3. 检查是否有并发 sync 竞争同一个 inode
$ strace -p $PID -e trace=fsync,write -T 2>&1

9.3 问题三:BDI 饥饿(多设备场景)

症状:NVMe 设备上写入速度远低于预期,而 SATA HDD 满负载。

# 检查每个设备的回写状态
for bdi in /sys/class/bdi/*/; do
    echo "=== $(basename $bdi) ==="
    echo -n "Dirty: "; cat ${bdi}stats/BdiDirty
    echo -n "Writeback: "; cat ${bdi}stats/BdiWriteback
    echo -n "Bandwidth: "; cat ${bdi}stats/avg_write_bandwidth
done

# 解决方案:调整各设备的 max_ratio
# 让 NVMe 获得更高回写带宽配比
echo "50" > /sys/class/bdi/259:0/max_ratio   # NVMe
echo "30" > /sys/class/bdi/8:0/max_ratio     # SATA

十、NUMA 感知与多队列优化

10.1 跨 NUMA 节点的回写代价

在多路服务器上,如果 BDI 的 wb_writeback workqueue 线程运行在错误的 NUMA 节点上,会导致内存访问远程延迟:

// 查看 workqueue 运行在哪个 CPU
$ grep -H . /sys/bdi/*/stats/ | head

// 在 AMD EPYC 4路服务器上优化:
// 将 workqueue 绑定到与 BDI 设备 PCIe 連接相同的 NUMA 节点
$ cat /sys/block/nvme0n1/device/numa_node
0

// workqueue NUMA 绑定(Linux 5.8+)
$ chrt -r 1 taskset -c 0-7 wb_workqueue_override_affinity=0

10.2 memcg 与写回压力传播

cgroup v2 的 memory controller 与 writeback 的交互:

// 当 cgroup 超过 memory.high 限制时
// 1. cgroup 内的进程在写入时触发 direct reclaim
// 2. reclaim 路径发现脏页 → 触发 writeback
// 3. writeback 启动 → 脏页回写

// 这种机制保证 cgroup 的写入不会无限制产生脏页
// 而是被 memory.high 平滑节流

十一、内核参数速查表

参数默认最佳场景建议影响范围
vm.dirty_background_ratio10DB: 3, 日志: 15, HPC: 5全局
vm.dirty_ratio20DB: 5, 日志: 30, HPC: 10全局
vm.dirty_expire_centisecs3000DB: 5000, 日志: 6000, 实时: 1500全局
vm.dirty_writeback_centisecs500通用: 1000, 实时: 200全局
vm.dirtytime_expire_seconds43200默认即可脏时间累计
fs.xfs.speculative_prealloc_lifetime300XFS 文件预分配XFS only

十二、总结与展望

Linux 写回机制从最初的 pdflush 到现代化的 wb_workqueue,经历了三代架构演进。理解写回机制的核心,需要掌握:

  • 脏页标记与 BDI 管理:每个脏页通过 inode 挂载到 BDI 的 b_dirty 列表
  • 三级触发:后台阈值(异步)、前台阻塞(同步)、过期巡检
  • wb_workfn 核心循环:delayed_work 驱动 → 选择 inode → writepages → submit_bio
  • 拥塞反压:通过 BDI congestion state 限制写入速率
  • cgroup v2 io 控制:per-cgroup 的 writeback 带宽限制

写回机制仍在持续演进中:

  • writeback throttling v2 (wbt):基于 rq_qos 的精确 I/O 延迟控制
  • per-IOs-cgroup accounting:更细粒度的回写成本归属
  • FUSE/passthrough writeback 优化:用户态文件系统的零拷贝回写
  • iomap writepages 标准化:ext4/xfs/btrfs/iomap 统一的 writepages 框架

掌握写回机制,是你成为 Linux 存储性能调优专家路上的最后一块拼图。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部