Kafka 生产者 P99 从 12ms 冲到 340ms:Page Cache 脏页背压事故复盘与调参记录
某 Kafka 集群升级后,生产者写入延迟 P99 从 12ms 骤升到 340ms。硬件没动、QPS 没变,只有版本变了。排查结果是新版本修改了日志段 flush 策略,大量脏页在 Page Cache 积压。脏页总量超过 dirty_background_ratio 阈值后,后台刷盘线程虽然启动,但积压过大,一轮刷盘持续了约 8 秒。期间所有需要分配 Page Cache 的写入请求都阻塞在 balance_dirty_pages 上。调整四个 dirty 内核参数后,P99 回到 15ms。
事故根因:write 只写 Page Cache,落盘是异步的
用户态 write() 并不落盘,它只把数据拷进 Page Cache、把页面标记为 PG_dirty,真正的落盘由内核异步完成。触发回写有四条路径:
- 周期回写:
wb_check_old_data_flush()按dirty_writeback_centisecs(默认 500,即 5 秒)轮询,脏页老化时间由dirty_expire_centisecs(默认 3000,即 30 秒)决定。 - 后台阈值回写:脏页占比超过
dirty_background_ratio(默认 10%)时唤醒 flush 线程,写进程不阻塞。 - 前台阻塞回写:脏页占比超过
dirty_ratio(默认 20%)时,写进程在balance_dirty_pages里阻塞等待回写完成。 - 用户主动触发:
fsync/fdatasync/msync/sync。
写入路径上,generic_perform_write() 会调用 balance_dirty_pages_ratelimited() 检查当前脏页率。事故里的 8 秒连续刷盘意味着脏页率长期压在前台阈值之上,每个写入批次都要在 balance_dirty_pages 里睡着等回写,P99 直接被拉爆。
回写线程模型的演进
- 2.6 时代用
pdflush+kupdated全局线程,所有块设备的脏页混在一起回写,全局锁竞争严重。 - 3.10 起引入
bdi_writeback(backing device info)框架,每个 BDI 一个独立回写线程,Linux 6.1 仍沿用并完善。
现代内核的回写调用链:wb_workfn → wb_do_writeback → write_cache_pages → clear_page_dirty_for_io → writepage → 块层 bio。
两个关键同步点:
- 回写前
page_mkclean()会清掉页表脏位并给页面加写保护,回写期间阻塞对该页的新写入;写回完成后清PG_writeback,页面恢复可写。 write_cache_pages()配合tagged_writepages的TOW RITEtag(原文笔误可修:TOWRITEtag)预标记要回写的页,避免与持续写脏的进程"赛跑"导致 livelock。WB_SYNC_ALL模式保证数据完整性,不漏写。
调优:先分清场景,再动四个参数
默认参数是按"内存占比"算的,不是绝对值。64GB 节点上 dirty_background_ratio=10 意味着脏页攒到约 6.4GB 才启动后台刷盘,而 Kafka/ES 这类顺序写对 NVMe 来说远未到瓶颈。
针对事故集群先确认现状再改:
cat /proc/sys/vm/dirty_background_ratio
cat /proc/sys/vm/dirty_ratio
cat /proc/sys/vm/dirty_expire_centisecs
cat /proc/sys/vm/dirty_writeback_centisecs
grep Dirty /proc/meminfo
场景一:高吞吐顺序写(Kafka 日志段、ES bulk)
让 Page Cache 多缓冲写入、减少小批刷盘带来的随机 I/O:
sysctl -w vm.dirty_background_ratio=30
sysctl -w vm.dirty_ratio=50
sysctl -w vm.dirty_expire_centisecs=3000
sysctl -w vm.dirty_writeback_centisecs=500
代价是宕机时丢失数据窗口变大——Kafka 这种依赖副本复制、日志段可重建的场景可以接受;如果数据只有单份且允许丢失有限,这个方向要谨慎。
场景二:低延迟在线交易
反方向调,小批量高频刷盘,避免脏页积压后一次性释放造成 I/O 毛刺:
sysctl -w vm.dirty_background_ratio=5
sysctl -w vm.dirty_ratio=10
sysctl -w vm.dirty_expire_centisecs=1500
sysctl -w vm.dirty_writeback_centisecs=500
swappiness 不是"内存用到多少才 swap"的阈值
Linux 5.8+ 的 swappiness 范围是 0–200,它决定内存回收时优先回收 Page Cache 还是优先换出匿名页的权重。数据库场景一般设 10–20,让内核优先回收可丢弃的 Page Cache,而不是把进程匿名页换去磁盘。有测试数据显示,内存压力下 swappiness=10 比 60 的事务吞吐高约 35%。
相关运维细节
fdatasync在 inode 只有时间戳变脏时可以跳过sync_inode_metadata,fsync不会跳过。- ext4 延时分配走
ext4_da_aops的writepages路径,行为与普通 writeback 有差异。 - 监控脏页看
/proc/meminfo的Dirty字段,配合vmstat、cachestat、pcstat观察回写节奏。 - 不要盲目
echo 3 > /proc/sys/vm/drop_caches当运维手段,drop 完脏页压力立刻回到应用路径上。
边界与教训
调参只是缓解放大因素,不是修复 bug。这次事故的直接诱因是升级后 flush 策略行为变了,脏页参数决定的是"积压到多少才动手"。如果验证升级时就把脏页曲线和 P99 一起观察,问题会在一个小版本内暴露,而不是等生产 P99 冲到 340ms。默认参数本身没有错,把 dirty_background_ratio 调高只适用于"回写能力大于业务写入速率"的节点;回写能力跟不上时,调高阈值只是把阻塞往后推迟。
另一条边界:Page Cache 是 writeback 策略,不是 write-through。需要强持久性语义的场景应该用 fsync/fdatasync 显式落盘,或走 O_DIRECT/O_SYNC 绕过 Page Cache——后者牺牲吞吐。数据库和存储类应用还要避免"应用自带缓存 + 页缓存"的双层缓存,两边的回收行为互相不可见,内存压力下容易一起抖动。