编程 Kafka 生产者 P99 从 12ms 冲到 340ms:Page Cache 脏页背压事故复盘与调参记录

2026-09-08 00:04:51

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,真正的落盘由内核异步完成。触发回写有四条路径:

  1. 周期回写wb_check_old_data_flush()dirty_writeback_centisecs(默认 500,即 5 秒)轮询,脏页老化时间由 dirty_expire_centisecs(默认 3000,即 30 秒)决定。
  2. 后台阈值回写:脏页占比超过 dirty_background_ratio(默认 10%)时唤醒 flush 线程,写进程不阻塞。
  3. 前台阻塞回写:脏页占比超过 dirty_ratio(默认 20%)时,写进程在 balance_dirty_pages 里阻塞等待回写完成。
  4. 用户主动触发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_writepagesTOW RITE tag(原文笔误可修:TOWRITE tag)预标记要回写的页,避免与持续写脏的进程"赛跑"导致 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=1060 的事务吞吐高约 35%。

相关运维细节

  • fdatasync 在 inode 只有时间戳变脏时可以跳过 sync_inode_metadatafsync 不会跳过。
  • ext4 延时分配走 ext4_da_aopswritepages 路径,行为与普通 writeback 有差异。
  • 监控脏页看 /proc/meminfoDirty 字段,配合 vmstatcachestatpcstat 观察回写节奏。
  • 不要盲目 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——后者牺牲吞吐。数据库和存储类应用还要避免"应用自带缓存 + 页缓存"的双层缓存,两边的回收行为互相不可见,内存压力下容易一起抖动。

推荐文章

程序员茄子在线接单