深挖 Weka:这次带着实现者的眼睛
重读 Weka 架构白皮书 —— 学完前三个阶段,你会看到完全不同的东西:每个设计决策背后是哪个你踩过的坑。
学完这节你能做到
- 解释 Weka 前端/计算/驱动分层与 CPU 核绑定的原因
- 说清 bucket 虚拟元数据服务器的扩展与迁移机制
- 列出 forgefs 要仿制的四个机制和放弃的三个
重读白皮书:这次你带着伤疤
L0 的第一课你解剖过 Weka 的三层架构 —— 那时它对你是「宣传材料」。 现在不一样了:你写过会被 kill -9 的 WAL(L1),用 io_uring 打满过一块 NVMe(L2), 在三节点集群上处理过成员抖动、EC 重建和 Raft 选主(L3)。 带着这些伤疤重读 Weka 白皮书,你会看到完全不同的东西:每一个设计决策, 都对应一个你已经亲手踩过、或者运维时半夜处理过的坑。
这节课把 Weka 的四个核心机制拆到「可以照着实现」的深度,最后圈定 forgefs 的仿制范围 —— L4 的五节课就按这张图施工。
进程模型:独占 CPU 核和网卡队列的账
L0 讲过 Weka 把机器切成 Frontend/Compute/Drive 三类进程,每个独占一个 CPU 核。 当时一笔带过的问题现在要算清:独占到什么程度,为什么值得。
Weka 的每个核心进程是一个单线程事件循环,busy polling,永不 sleep:
- CPU 核:用 cgroup/isolcpus 从内核调度器手里整个拿走。没有上下文切换,
没有别的进程来污染 L1/L2 cache。你在 L2 测过:一次上下文切换约 1
3 微秒, 而 NVMe 一次读只要 80100 微秒 —— 调度抖动足以让 P99 翻倍。 - 网卡队列:每个进程绑一个独立的网卡硬件队列(SR-IOV 虚拟功能或 DPDK 独占),
收发包不过内核协议栈,也不和别的进程抢中断。你运维时调过
irqbalance和 RSS, Weka 的答案是:根本不让内核碰这些包。 - 单线程无锁:既然一个核只跑一个逻辑单元,核内就不需要任何锁。 跨核通信走消息传递(无锁队列),不共享可变状态。
对照 forge:forge-node 跑在 tokio 上,work stealing 意味着任务会在核之间迁移,
锁和原子操作到处都是。这一档差距我们认了,但要能说清代价:
tokio 方案的延迟抖动主要来自调度和锁竞争,大约让 P99 比中位数高 510 倍;
Weka 的模型能把这个比值压到 23 倍。L5 调优时你会亲手量这笔账。
Redis、Seastar/ScyllaDB、DPDK 应用全走这条路:thread-per-core + 消息传递。 逻辑越是元数据这种「小对象、高频次、强顺序」的负载,单线程模型赢得越多 —— 锁的开销和缓存行弹跳在这种负载下是主要成本。记住这个结论,下一段马上用到。
bucket:元数据切 64k 份的深意
L0 讲过 bucket 是「虚拟元数据服务器」,现在从实现者角度追三个问题。
第一问:为什么是切碎,而不是多活? CephFS 的多活 MDS 你运维过:按目录子树动态分区, 听着美好,实际上热点目录一出现就得靠 balancer 迁子树,迁移本身又造成抖动 —— 你见过 MDS 在 rank 之间来回倒腾一个热目录的惨状。Weka 反着来:不做动态智能, 做静态切碎。整个元数据空间按哈希切成 64k 个 bucket,一个热目录的文件天然散在 大量 bucket 里,热点被哈希拆掉了,根本轮不到 balancer 出场。
第二问:为什么是 64k 这个量级? bucket 数远大于核数(64k 对几百个 Compute 核), 好处有三:负载均衡的粒度够细(每核摊几百个 bucket,单个 bucket 忙不影响大局); 故障接管的单位够小(一个节点挂了,它的 bucket 分给所有幸存节点,而不是砸给某一个 —— 和你熟悉的 Ceph PG 对 OSD 的关系同构);扩容时只搬 bucket 归属关系,不用重切数据。
第三问:bucket 内部怎么并发? 不并发。一个 bucket 同一时刻只被一个 Compute 进程的单线程持有,bucket 内的操作天然串行 —— 于是 inode 更新不需要锁, 目录项插入不需要锁,一致性由「单写者」保证。这就是上一段单线程模型的兑现: 把并发问题转化成放置问题,用切碎 + 单写者取代锁。GPFS 的 token manager 为每个 inode 的锁令牌全局协调,你见过它在几亿文件的目录树下被打爆; Weka 直接取消了这个协调者。
bucket 的持久化走 Weka 自己的 EC 条带(元数据也是数据),接管时新持有者从共享的 盘上状态重放日志即可 —— 元数据服务是无状态可搬运的,状态永远在盘上。
CephFS 多活 MDS 和 Weka bucket 都能横向扩展元数据,本质差异在哪?
数据通路:条带、EC、聚合写
数据面你在 L3 已经实现了一半:reed-solomon 4+2、条带布局、故障域约束。 Weka 在这条通路上多做的三件事,是 L4 striping 课的施工图:
- 文件到条带的地址翻译。对象存储里一个 key 对应一个 blob;文件系统里 一个文件是可以在任意偏移读写的字节流。Weka 把文件切成固定大小的 chunk, 若干 chunk 凑一个条带做 EC。你要实现「文件偏移 → 条带号 → 节点集合」的翻译层。
- 满条带写与小写的分流。整条带的写直接 EC 落盘,零惩罚;不满一条带的小写 如果原地更新,就要读旧数据 + 旧校验块再算 —— 这是你运维 Ceph EC 池时 小 IO 慢 10 倍的根源(读改写惩罚)。
- 日志式小写聚合。Weka 的答案:小写先以日志形式追加落盘(持久化即返回), 后台再把日志里的数据聚合成满条带写下去,回收日志空间。眼熟吗? 这就是你 L1 写的 Bitcask 模型 —— 所有写都是追加,后台 GC —— 只是这次追加的目的地是分布式条带,不是本地文件。
rebuild:为什么节点越多重建越快
你运维 RAID 的年代,一块 20T 盘重建要两天,期间第二块盘再挂就全军覆没。 根源是重建带宽收敛在单点:热备盘的写带宽就是天花板。
Weka 的重建是「众包」的,你在 L3 已经见过雏形,这里把 scaling 逻辑说透:
- 每块盘上的条带 chunk,其兄弟 chunk(同条带的其余 5 个)均匀散布在全集群所有盘上
- 一块盘挂掉,它上面每个 chunk 的重建 = 读 4 个兄弟 + 算 RS + 写 1 个新 chunk, 而这些读写均匀摊给所有幸存盘
- 100 块盘的集群,每块盘只承担约 1% 的重建流量;200 块盘就是 0.5% —— 重建时间随集群规模近似线性下降,和 RAID 恰好相反
- 优先级上,Weka 先重建「已经掉了 2 个分片」的条带(再掉一个就丢数据), 后重建只掉 1 个的 —— 风险排序,不是顺序扫描
L4 的 rebuild 课会把这套逻辑实现成声明式循环:目标状态减现状等于任务队列, 像 K8s controller 一样对账 —— 你运维 K8s 时天天受益的模式,这次自己写一个。
forgefs 范围圈定
对照实现者视角的 Weka,forgefs 明确仿制四个机制:
| 机制 | Weka 原版 | forgefs 等价物 | 在哪节课 |
|---|---|---|---|
| 虚拟元数据服务器 | 64k bucket,单写者 | inode 哈希分片到 forge-node,分片内串行 | fs-metadata |
| 条带化数据通路 | chunk → 条带 → EC | 复用 L3 的 4+2,新增地址翻译层 | striping |
| 日志式小写聚合 | 日志追加 + 后台聚合 | 同思路,粒度更粗 | striping |
| 众包式重建 | 全集群分摊 + 风险优先级 | 声明式重建循环 + 令牌桶限速 | rebuild |
明确放弃三个:内核客户端(用 FUSE,慢 5~10 倍,但两周能跑起来)、 DPDK/SPDK 用户态 IO 栈(继续用 tonic + io_uring)、快照与分层(纯工程量,教学价值低)。 放弃不等于装看不见 —— milestone 课要写一份诚实的差距清单,每一项标注差多少、为什么。
新增两块代码:forge-fs(文件系统逻辑:inode、目录、分片、条带地址翻译,纯逻辑, 不碰网络)与 forge-fuse(FUSE 客户端进程,把 forge-fs 接到内核)。 forge-fs 不碰 FUSE —— 文件系统逻辑必须能脱离挂载点单独测试,这是接口边界的老规矩。
写代码之前先让 AI 出设计提案,你来当评审 —— 这是 L4 起必须养成的习惯: 系统越大,返工成本越高,评审文档比评审代码便宜十倍。你不需要会写 Rust 才能评审这份提案,你需要的是追问到它没法含糊:依赖方向说反了没有、 非目标清单是不是真的敢砍、最后那两个「最可能返工的决定」和你的判断一致吗 —— 不一致的地方,恰好是你该深挖的。它每给一个数字,就问一句「这个数字哪来的」。
目标:拿到一份 forgefs 的设计提案文档,让我在动手之前就能评审整体架构。背景是 forge 这个 Rust 分布式存储项目已经有了 gossip 成员管理、一致性哈希放置、reed-solomon 4+2 纠删码、Raft 元数据,现在要在它上面长出文件系统层。 行为规格: 1. 代码分成两块并说清依赖方向:文件系统逻辑(inode、目录、分片、条带地址翻译)是纯逻辑,不碰网络也不碰挂载点,必须能脱机测试;FUSE 客户端是一个独立进程,把前者接到内核。说明这两块为什么必须分开,绑在一起会付出什么代价; 2. 元数据方案:inode 按哈希分片到各存储节点,对照 Weka 的 bucket 说明哪里相同、粒度差多少倍、这个差距会在什么负载下先暴露出来; 3. 数据方案:文件切 chunk 凑条带走已有的 4+2 纠删码通路;小写不做原地更新,先追加进日志、后台再聚合成满条带; 4. 明确的非目标清单:内核客户端、用户态 IO 栈、快照、分层、配额。每一项写清为什么不做,只允许两种理由 —— 教学价值低,或工程量超范围; 5. 每个设计点标注风险和验收方式。最终验收是 pjdfstest 子集 + 挂载后完整编译一个真实开源项目 + 编译中途杀节点。 交付:一份 1~2 页的 docs/design/forgefs.md,只写设计不写代码;外加一节「最可能返工的两个决定」,写明理由和返工代价。分几个模块、用什么库你定,最后告诉我为什么这么选。
- 让 AI 画一张依赖方向图(谁依赖谁,箭头别画反),然后问它:如果我以后要在同一套文件系统逻辑上再接一个 NFS 网关,要改动哪几块 —— 答案里如果需要动到文件系统逻辑本身,说明这条边界没划对
- 把非目标清单逐条对着 L4 后面四节课的标题核对一遍,看有没有它嘴上说不做、方案里又偷偷做了的(AI 倾向什么都想要)
- 拿提案里给出的 FUSE 性能预估,去你手上现成的 CephFS 或 NFS 挂载点用 fio 打一轮小 IO 实测,看它的量级估计是不是离谱 —— 这是你能立刻做的对照实验
- 让它把「3 节点、挂 1 个节点」这个场景下每一层的表现写成一段话,和你 L3 亲手测过的现象对照,对不上的地方就是它在纸上谈兵
- 文件系统逻辑和 FUSE 客户端如果不拆开,我以后想换客户端形态要重写多少东西?
- 256 个分片摊在 3 个节点上,挂掉一个节点时哪些元数据操作会直接失败、哪些只是变慢?
- 这份提案里哪一条一旦定错,后面四节课都要推倒重来?
小结
- Weka 的进程模型 = thread-per-core + 独占网卡队列 + 核内无锁;forge 用 tokio 认下这档差距,但要能量化代价
- bucket 的三层深意:哈希静态切碎拆热点、64k 粒度摊薄接管、单写者串行取代锁 —— 把并发问题转化成放置问题
- 数据通路三件套:地址翻译、满条带直写、小写日志聚合 —— 第三件就是分布式版的 Bitcask
- 众包重建的 scaling:兄弟 chunk 全集群散布,重建时间随规模线性下降,风险优先级排序
- forgefs 仿四弃三:仿 bucket/条带/聚合/重建,弃内核客户端/DPDK/快照;新增 forge-fs 与 forge-fuse 两个 crate