p99.9 从 178ms 到 0.43ms:机械盘上的 Raft

主流共识是:Raft 的 WAL 不要落在机械盘上。各家分布式系统的文档把这条写成了成文的门槛:

  • etcd:官方硬件建议是”尽可能用 SSD 支撑存储”;若用机械盘,要用”尽可能快的”(15,000 RPM 级),并要求至少 50 个顺序 IOPS,重载集群建议 500。更严格的运维口径(Red Hat 的 etcd 实践,被广泛引用)直接列出禁项:避免机械盘、避免 NAS/SAN/iSCSI/Ceph RBD 等网络存储、用独占盘;判据是可验证的——块设备要能以 8KB 顺序写加 fdatasync、在 10ms 内完成至少 50 IOPS(早期文档口径 20ms),生产监控标准是 WAL fsync 时长的 p99 < 10ms,并给出用 fio 实测验证的方法。
  • TiKV:生产数据盘要求 NVMe PCIe SSD;Raft 日志建议放在独立的盘上(raftdb.wal-dir 不得与数据 WAL 同目录);NVMe 建议配 none/deadline IO 调度器(none 与 io_uring 搭配)。
  • CockroachDB:磁盘要达到 500 IOPS + 30 MB/s 每 vCPU;存储引擎会周期性检查 WAL 是否已同步,停滞超过阈值(默认 20 秒)时进程直接自杀——因为”盘 fsync 不了,就无法保证持久性,继续接收写是在承认可能活不过崩溃的操作”;它的 WAL fsync 延迟监控口径是 p99 < 10ms 健康、> 50ms 值得警惕、> 200ms 严重。
  • FoundationDB:依赖”快速且确定的 fsync”;企业级 7,200 RPM 机械盘单次 fsync 10–25ms,而它强制 5 秒的事务寿命(超时报 transaction_too_old)——~20ms 的停顿会让内部队列指数积压并不断撞上这条线;此外多个节点共用一块 SSD 是明确反模式(一块设备只有一个物理队列、同一时刻只做得了一个 fsync,两个节点以上延迟会翻倍上涨)。

这些规则背后的原因也很物理:机械盘一次寻道 + 旋转就位就是十几毫秒,而 Raft 的心跳/选举窗口只有百毫秒级——慢盘不会被”等待”,它会被当成故障信号

但这篇要讲的不是”所以我们也不用 HDD”,而是:我们把 HDD 做到了可用——用虚拟文件系统 + DIO + WT,而且不影响 Raft 的共识

关键结论先说:这件事在普通文件这一层做不到,必须由虚拟文件系统承接语义。

先看量级:为什么默认路径必然失败

我们的实测(一块桌面机械盘)把这条规则量清楚了:

  • 每写一次元数据要 fsync:15–30ms;同盘裸刷盘 17–21ms——与 FoundationDB 文档说的 10–25ms 同一量级;
  • 按 Raft 选举时间窗口(150ms 下界)量测元数据持久化:机械盘默认缓冲模式 p99.9 = 102ms(临界,方差大就翻线);绕过缓存的 DIO 单独用 p99.9 = 178ms——对照上面那 10ms 的运维标准,超了近 18 倍;
  • 单条提交形态(每写都要应答)更惨:默认模式 41.7ms / 22 条每秒

“22 条每秒”就是”不可用”的真身——不是调优能救的,是物理地板的税。

更深一层:为什么我们能做”组合”

上面那些坑能填上,前提是一条更根本的选择:我们没有把持久化建在操作系统文件系统上,而是自己写了一个虚拟文件系统——用一个自研的文件系统替代”系统文件系统 + 文件”这个组合。

(它是文件系统层,不是 raw 那类”零元数据扇区转储”的镜像格式;布局是我们自己的结构,物理载体既可以是一个 .tier 文件,也可以直接是块设备/裸分区——“一份逻辑布局,两种物理形态”。)

原因不复杂:系统文件系统对我们来说是一层”只能用、不能改”的东西。

  • 它只保证单次系统调用内部原子,不保证多次调用之间的协调——两个 pwrite 谁先谁后、并发追加会不会撕裂,它不管。把协调甩给系统 = 并发语义不可控 = 数据撕裂;
  • 命名、删除、移动、元数据通道这四件事,在平台之间语义分裂:Windows 上删不掉被打开的句柄、路径长度与大小写规则各不相同、改名原子性不一样、扩展元数据要么 xattr 要么 ADS 要么边车文件;
  • 开缓冲的时候,写线程不是你的,缓冲页也不是你的:内核的回写线程什么时候写、写多少、什么顺序,只有少数旋钮能间接影响;页缓存是多个进程共享的,你自己的热页可能被别人的流式读写冲掉;
  • 更关键的是:元数据不在自己手里——在文件系统上,稀疏、预分配、几何这些性质是”探测所得”(靠系统支持、靠特权、靠平台);在自研布局里,它们是”构造所得”。

于是我们做了一个决定:把布局层收回来。收益是可测的——元数据面 Open 35×、Stat 176×、枚举 18.7×(对文件系统介质),因为那些操作在自研布局里是纯内存操作、零系统调用。

但物理特性一步不让。 这一点必须说清楚,否则这篇就成了吹牛:自研文件系统能重排软件栈、能消除平台税、能把屏障放到正确的层——但它一分也拿不到盘的物理性能。我们专门写过一份”写路径性能极限论证”,结论是:单写者顺序追加钉在盘写速地板(实测就是盘的 0.53–0.56 GB/s)、追加 + 逐次刷盘钉在 fsync 硬件地板(单次屏障 564µs ≈ 同一块盘裸 fsync 的 548µs)。报告的总结句很清醒:

本层的职责是不在地板之上再加协议税。

外加一句更狠的自我证伪:我们曾估算”小写还有 15–25% 的软件优化空间”,随后做了两组实验(字典减次、原子移除)——实测零提升,结论是”不是’残余’,是下界本身”。

所以正确的说法是”组合”,不是”解决”:物理特性消除不掉,只能选一组旋钮把它绕过去——而之所以能”选旋钮”,前提是我们拥有整条栈。

核心:这套能力在文件这一层做不到

我们最终让它达标的组合是 虚拟文件系统 + DIO + WT。有意思的地方在于:如果你把 DIO 和 WT 当成普通的文件句柄选项去用,结果反而更糟。

虚拟卷不是一块裸盘——它内部有自己的写入管线:写数据 → 日志记录 → 提交。所以当你在”卷文件”这个句柄上打开写穿时,它只会把每一次写都变成一次”逐写日志提交”——写数据、写日志记录、再 fsync,多轮下来:

  • 组提交形态:p50 31.3ms
  • 单条提交形态:49.9ms——比默认缓冲还差

在文件层加写穿,是负优化。 因为它把屏障加在了错误的位置:屏障加在”卷文件的写”上,而真正需要保证的是”数据抵达载体”。

正确的做法是把语义下沉一层到载体:让虚拟卷的载体句柄自己以写穿方式打开,于是每一次写完成就已是稳定存储——日志提交不再需要独立的 fsync(写穿完成即单屏障),引擎的刷盘对已写穿的数据直接短路:

// 载体句柄按挂载档打开:写穿 → 每写完成即达稳定存储
var options = FileOptions.Asynchronous
            | (_carrierWriteThrough ? FileOptions.WriteThrough : 0);   // Linux 侧映射为 O_SYNC

用实现里的一句话概括:“写数据 + 日志 + fsync”三段压成一次写穿。

效果(同一块盘、同一台机器):

实现层次模式p50判定
虚拟卷 · 句柄级写穿(逐写日志提交)80.1ms不达标
虚拟卷 · 句柄级DIO19.5ms不达标
虚拟卷 · 句柄级DIO + 写穿17.5ms不达标
虚拟卷 · 载体写穿档默认缓冲0.198ms达标
虚拟卷 · 载体写穿档写穿0.179ms达标(对齐本地文件系统)
虚拟卷 · 载体写穿档DIO + 写穿0.169ms达标

同一块介质、同一组选项,只是把语义从”句柄级”挪到”载体级”,p50 从 17.5–80ms 掉到 0.17ms——而改造前四格的 p99.9 全在 150ms 以上(不达标),改造后全部 ≤1.74ms。

这就是为什么必须在虚拟文件系统层解决:普通文件 API 表达不了”载体写穿”这个语义——它只能表达”我给这个句柄加个标志位”,而屏障该加在哪一层,取决于你自己的写入管线长什么样。

配套还有四件同样”文件层表达不了”的事

一、稀疏标记。 Windows 上非稀疏的文件扩展 = 即时簇分配——实测一次扩容造成 11.5 秒的 p99.9 巨刺(修复就是给载体打上稀疏标记,吞吐从 8.6K 升到 27.2 万条/s)。这是文件系统层的知识,业务代码看不见。

二、在途写记账。 我们踩过最凶险的一个坑:某段时间 WAL 应答 p50 = 0.02–0.05ms,四模式一致”达标”——好得不真实。追下去发现是假象:写绕路径把整块数据直落载体却不标脏,随后提交看到”脏字节为 0”就走了空转快道,零 fsync——刷盘返回时数据只在内核页缓存里(30 秒回写窗口),断电即丢。修复是把”载体在途写字节”计入刷盘判据,p50 回到 0.155ms(与同盘裸刷盘的 0.108ms 同量级——“真的落盘了”)。

三、DIO 的读写分开定策。 我们把 DIO 设为 WAL 默认,但实测证实写侧不能硬切:文件载体硬切直写后缓冲档从 1330 MB/s 掉到 80–300 MB/s(直写同步小写每写一次设备往返;64KB 是甜点、1MB 是灾难段)——报告里那句结论很关键:

单线程排干顶替不了内核电梯调度;缓冲档能平权裸盘的存在条件 = 内核回写吸收。

所以最终是写侧保持缓冲 + 读侧补专用直接读通道(补上后顺序读 -78% → +2~+7%)。

四、fsync 的顺序铁律。 审计抓到过一次严重缺陷:提交链里元数据的 fsync 排在数据之前——断电时元数据会标出一个数据尚未落盘的提交点,丢已提交数据。修复后固定为两段式:

FlushUntil(commitTarget);   // ① 数据帧先落盘(含 fsync)
CommitCore();               // ② 之后才写元数据并提交

(顺带一个便宜:Linux 上 fdatasyncfsync22%。)

结果:不影响 Raft 共识

这套组合做下来,机械盘上 Raft 的契约全部达标。挑两个最相关的:

指标机械盘默认机械盘 DIO + 写穿
元数据持久化(判据 150ms)p99.9 = 102ms(临界)p99.9 = 0.429ms(余量约 350×)
单条提交(每写应答)41.7ms / 22 条/s0.520ms / 1782 条/s(约 80×)

余量 350× 是关键:选举窗口的判据是 150ms,我们做到 0.429ms——不是”勉强压线”,是远离危险区

而且共识的正确性不是靠推理,是靠测试守着的。有一条真盘 HDD 回归门专门跑这个形态:150–300ms 选举窗不放宽(其余全部产品默认参数),断言复制 + 压缩并存期间任期号增量 ≤ 2(换届风暴检测)、集群收敛单 leader。

还有个意外收获:HDD + DIO + 写穿这个组合,反而成了更严苛的测试环境——它暴露了共识层一个换届风暴缺陷(内存/SSD 上跑不出来,HDD 上必现)。“让 HDD 可用”的过程顺带提高了共识实现的健壮性。

另外要说清楚:那条”卡 fsync 的 leader 必须被废黜”的红线没有被删掉——它是安全机制(病态检测),必须保留。我们做的是让触发条件不再发生:fsync 不再是慢的那个环节。安全性没有让步,只是不再被误触发。

代价与边界

不吹:

  • 机械盘依然不是好介质——单条提交吞吐 1782 条/s vs 内存介质 9083 条/s(5× 差);组提交仍有一次性的提交刺(我们这两格 88–139ms,全矩阵 36–369ms);读侧要靠自管页缓存吸收;
  • “可用”的准确含义:跨过 Raft 的选举时间窗口契约,不是”和 NVMe 一样快”;
  • 未验证面:4Kn 真扇区设备、Windows 载体、故障注入矩阵(都标注”需要特定环境”);叠瓦盘、寻道/旋转延迟的专门分析本项目没做,这里不编;
  • 一个诚实的例外:内存映射路径的写绕不走写穿短路——msync 屏障不可省,仍要走全 fsync。

还有个反例存档值得提一句:我们有一份案例文档专门纠正过”HDD 背锅”的误判——某次测试挂死被归因为”HDD 磁盘慢”,查下来真因是资源泄漏。环境标签不是取证的替代品——这条纪律和上面的每个数字同样重要。

结论

行业说”Raft 的 WAL 别放 HDD”,这个结论我们验证了:按默认路径(普通文件 + fsync、句柄级选项)机械盘确实不可用——178ms 的 p99.9 就是证据。

但”默认路径不可用”不等于”不能做到可用”。我们的答案分两层:

第一层:为什么我们有资格做组合——因为文件系统是自己写的。 系统文件系统只保证单次调用原子、不保证跨调用协调,命名/删除/移动/元数据通道又按平台分裂,而且写线程与缓冲页都不归你管;于是我们把布局层收回自己手里。收益是可测的(元数据面 35×/176×/18.7×),但物理性能一分没多拿。

第二层:在自研栈上做组合。 虚拟文件系统(把”写即达稳定存储”的语义下沉到载体层)+ DIO 读写分开定策 + 载体写穿 + 引擎层的持久化纪律(刷盘顺序、在途写记账、稀疏标记、组提交)。

结果是:机械盘元数据持久化 p99.9 = 0.429ms,对 150ms 的选举窗口有 350× 余量,Raft 共识不受影响——并且有一条不放宽选举窗口的真盘 HDD 回归门守着这个结论。

一句话收:物理特性消除不掉,只能选一组旋钮把它绕过去——不是 HDD 变快了,是屏障终于加在了该加的那一层。


本文事实来自 TC.Tier 的虚拟文件系统文档、WAL 性能契约、IO 基准与归档的设计/台账材料。业界门槛分别引自:etcd 官方硬件建议(etcd.io 运维文档)与 Red Hat 的 etcd 运维实践(含 8KB/fdatasync 与 p99 门槛、fio 验证方法)、TiKV 部署与调优文档、CockroachDB 生产环境建议与磁盘停滞处理文档(含 storage.max_sync_duration 与自杀机制)、FoundationDB 官方配置文档与运维问答(含 5 秒事务寿命与同盘多节点反模式)。

评论

← 全部文章