系统文件系统只给你一个开关,所以我们自己写了一个
上一篇讲到:为了让 HDD 上的 Raft 达到可用,我们把”写即达稳定存储”的语义下沉到了载体层——而这件事的前提是,布局层是我们自己写的。
这篇就讲这个虚拟文件系统本身的设计选型:为什么要写它、它长什么样、每个关键决策为什么这么定,以及它明确的边界。
为什么要自己写:系统文件系统是一层”只能用、不能改”的东西
动机不是”系统文件系统不好”,而是四个具体的约束:
一、它只保证单次系统调用原子,不保证跨调用之间的协调。 两个写谁先谁后、并发追加会不会撕裂、读到哪个版本——它不管。有一句总结很到位:把协调甩给系统 = 并发语义不可控 = 数据撕裂。
二、四类平台顽疾。 这些不是性能问题,是语义问题:
| 问题 | 在系统文件系统上 | 在自研布局上 |
|---|---|---|
| 句柄 × 删除耦合 | Windows 删被拒 → 释放句柄 → 重试环 | 内部记账——无句柄可阻塞 |
| 命名平台差异 | 大小写敏感性/分隔符/路径上限 | 自有命名空间——字节级全平台同语义 |
| 移动/改名原子性分歧 | 不同平台的系统调用语义不一 | 命名空间表更新,随元数据提交原子生效 |
| 元数据通道分叉 | xattr / ADS / 边车文件三套路由 | 元数据内联记录,随元数据提交原子 |
三、元数据的归属。 最本质的一条:原子性、稀疏、几何这些性质,在文件系统上是”探测所得”,在自研布局上是”构造所得”。不用猜系统支不支持稀疏、有没有特权做预分配——这些性质由我们自己的结构定义。
四、没有细粒度控制。 这条最容易被忽略,但实际影响最大——开缓冲的时候,写线程不是你的,缓冲页也不是你的:
- 写线程不可控:内核的回写线程是它自己的线程——什么时候回写、一次回多少、什么顺序,只能通过少数旋钮间接影响。而我们的自管缓存里,回写线程是自己写的:固定周期轮询 + 阈值唤醒(阈值设计上与内核的脏页阈值同构——把模型照搬过来,但参数由自己定),单次排干有明确预算。反过来说,走直接 IO 的一个直接收益就是”免内核回写线程竞争”。
- 缓冲页不是你的:内核页缓存多进程共享——里面混着别人的数据,谁留谁走只有内核能仲裁;你自己的热页也可能被别人的流式读写冲掉。而自管缓存的唯一持有者就是实例自己:逐出策略、预读发起(按区间表精确发起,而不是启发式猜)、哪些写不污染缓存,全部自己定。
- 分配与落盘语义不可控:分配是不是即时簇分配(实测造成过 11.5 秒巨刺)、稀疏/打洞支不支持、元数据放哪——全看系统支不支持、有没有特权、平台一不一致。
一句话:系统文件系统只给你一个粗粒度的开关(开不开缓冲),不给你细粒度的控制(谁回写、何时回写、哪些页留下、怎么分配)。而存储引擎恰恰在这三件事上最需要控制权。
它是什么:一个文件系统,不是镜像格式
这里要先澄清一个很容易搞错的身份问题:它是文件系统层,不是块设备层的镜像格式。它既不是 raw,也不是 qcow2。
这两类东西的差别是结构性的:
VM 镜像格式(raw/qcow2/VMDK/VHD/VHDX/VDI)是块设备层——客体是扇区空间,guest 自己装文件系统,形成两层文件系统栈(镜像映射 + guest 文件系统),元数据开销付两次。我们做的是文件系统层——区间表直接就是文件系统,单层。
也就是说:qcow2 那类格式解决的是”怎么把一块虚拟磁盘存成文件”,它上面还得再挂一个文件系统(ext4/NTFS)才有目录和文件;而我们做的直接就是那个文件系统本身——命名空间、文件、目录、区间、位图、日志都是我们自己的结构,不存在”镜像层”这一层。
身份差异带来几项结构性优势:
| 维度 | 我们 | VM 镜像格式 |
|---|---|---|
| 元数据面 | Open 35× / Stat 176× / 枚举 18.7× | 镜像映射 + guest 文件系统两层开销 |
| 追加快道 | O(1) 尾延伸 + 一条日志记录 | qcow2 首触一簇 = 数据 + L2 + refcount 三连写 |
| 恢复 | 位图可达集对账(精确) | qemu-img check 启发式扫描 |
| 多载体拼接 + 迁移 | 原生支持 | 全族没有 |
| 挂载 | 直接打开 | 需要 NBD 之类挂载工具 |
顺带一个命名史,它正好说明这个身份问题有多容易混:实现一度真的叫 RawFileSystem、载体叫 .raw。这个名字是错的——因为”raw”在存储语境里指的正是”零元数据扇区转储”(VM raw 格式),与我们这个到处是自描述元数据的布局恰好语义相反。加上协议层早就叫 virtual,造成实现层与协议层命名分裂,后来统一改名(类名直接改,无兼容壳;协议层的 spec 字符串零变化)。
形态:一份逻辑布局,两种物理形态
虚拟卷是一份逻辑布局(自描述的单工件:magic、版本、几何、校验和全内建),它可以落在两种物理载体上:
| 载体 | 例子 | 机制 |
|---|---|---|
| 文件载体 | 一个 .tier 文件 | 系统文件系统上的单文件 + 伴生锁文件做跨进程互斥 |
| 设备载体 | Linux 块设备/裸分区、Windows 卷或物理盘 | 原生打开 + 文件锁;容量/扇区经系统接口探测;直接 IO 强制 |
“设备即根空间,且是单文件根空间。应用层想在这个单文件根空间上怎么组织数据是它自己的事,本层不知道也不需要知道。”
同一份布局换载体 = 字节级 dd;而且它既是活卷又是存档——cp / dd / 网络推流都是合法迁移,不需要中间容器。
而它只是四个平权介质之一。整个 IO 栈对外只有一个门面:
local:// → 系统文件系统直读直写 (互操作与过渡)
memory: → 内存介质 (高性能运行时)
virtual:// → 自研布局 (本地持久化主形态)
network:// → 对象存储 (异地/归档)
平权的意思是四者是可替代实现,不是上下叠加。有一句定位很关键:
选文件系统 = 选运行形态,不是选完成度——数据面速度四类带内持平,差异在元数据面与一致性语义。
卷格式选型:几个”为什么要这样”的决策
卷的布局:
[超级块 4096B] [位图区] [日志区] [镜像区(检查点)] [数据区]
几个关键决策,每个都有明确的理由:
一、超级块双份轮写。 两份(主/备),任一份校验通过且版本合法即可采纳,两份都坏才拒绝打开。单份超级块 = 单点。
二、位图是几何的一部分,且每载体自带。 位图按容量定长,并且每载体自带自己的一份——加载体就是加容量,不需要全局位图重划;成员容量按 64 块对齐,保证位图的字不跨成员。
三、多载体的字段必须第一天就位。 卷 UUID、载体索引、成员表全部在格式第一版就定义好(哪怕当时只支持单载体):
实现可以仅支持单载体,但字段必须在格式第一版就位——多载体必须是加法演进,不是格式换代。
四、未知的东西一律拒开,绝不错读。 版本高于支持上限 → 拒绝打开;读到未定义的标志位、未定义的状态值、保留字段非零 → 拒开卷。演进规则是”只加不改”:版本内字段语义冻结,行为变更 = 认领新标志位或升版本,物理布局不重排。
五、链式结构而非 B 树。 全部元数据结构 = 定长记录 + 块链:无树再平衡、无排序迁移、无结构部分更新,刷盘顺序确定——这让崩溃一致性协议保持简单(下一节因此才有可能做得干净)。
六、统一块空间,元数据不设独立台阶。 条目/区间/附加信息的唯一上限是块数(容量),元数据与数据同源分配——满了是”空间满”,不是”条目满”。理由是介质平权:否则会出现”同样的数据,还原到自研布局成功、还原到文件系统却因为文件数超限失败”。
七、日志区第一天预留,但物理上可选。 超级块里的日志字段在第一版就命名并置零(非零 → 拒开);物理上由格式参数决定是否预留一段。这样老卷升级到带日志形态是”启用”而不是”换代”。
三维正交 + 写穿:把存储形态拆成旋钮
这是整套设计里我最喜欢的一部分——把”用什么存储形态”拆成三个互不蕴含的维度和一个正交补充:
| 维度 | 取值 | 含义 |
|---|---|---|
| 预分配 | 预分配 / 精简 | 创建时就占好空间(写时零分配、延迟稳定、初始慢)vs 用多少占多少(省空间、创建快、首次写有抖动) |
| 稀疏 | 稀疏 / 非稀疏 | 未写区域不占物理块(读零、写时分配)vs 全逻辑块对物理块(延迟可预测、空间大创建慢) |
| 缓存路径 | 直接 IO / 缓冲 | 绕页缓存(免双缓存、大块顺序甜点、要求对齐)vs 页缓存吸收(预读/写合并/回写) |
| 写穿 | 开 / 关 | 每写即达稳定存储——缓存路径维度的正交补充 |
它们可自由叠加,效果互相影响。于是极端不同的形态,只是同一套代码的不同旋钮:
| 目标形态 | 组合 |
|---|---|
| 生产数据库(写时零分配抖动、零 fsync 提交) | 预分配 + 非稀疏 + 直接 IO + 载体写穿 |
| 开发测试(省空间、创建快) | 精简 + 稀疏 + 缓冲 |
| 大顺序扫描 / 备份 | 直达档(绕自管缓存) |
有个细节能看出这套抽象的成熟度:这四个维度在四个介质上各有”已实现、物理无意义、语义天然成立”三种状态,而且文档把三者分得很清楚——比如对象存储的”写穿”是语义天然成立(对象存储的持久化本身就是写透式:上传完成即服务端强一致持久,没有缓冲软状态),而不是”没实现”;内存介质的直接 IO/写穿才是物理无意义(无盘、无持久化)。
自管页缓存:不是”系统缓存不好”,是”仲裁前提被消灭了”
虚拟卷有自己的页缓存(默认预算几十 MiB,设 0 则禁用、退化为纯直达形态)。为什么不用系统的?三条理由,第一条最本质:
通用文件系统依赖内核缓存的根因是多进程共享、互不可信,只有内核能仲裁;我们一卷一实例、无侧门,缓存的唯一持有者就是实例自己,内核仲裁的存在理由被设计掉了。
第二条是设备载体上的硬需求:设备载体走直接 IO(文件锁是咨询性的,拦不住外部进程写,用内核缓存有一致性风险)——而直接 IO + 无自管缓存 = 每读必盘,此时自管缓存是读性能的存在条件,不是优化。
第三条是信息优势:我们手里有区间表——预读可以按连续段精确发起(内核只能启发式猜顺序),逐出策略可以按自己的访问语义定制。数据库系统全走直接 IO + 自管缓冲池,道理一样:存储引擎自己知道什么热。
第四条是控制权:回写线程是我们自己的、缓存里每一页都是我们的数据、哪些写绕过缓存、什么时候排干、按什么顺序,全部显式。
这块的实现里还埋着一次很凶险的教训(上一篇提过):写绕(整块直落载体不污染缓存)与”空转快道”叠加,曾让刷盘在数据只到内核页缓存时就返回——“达标”是假象,断电即丢。一条经验:当性能数字突然变好,先怀疑数据是不是没落盘。
崩溃一致性:断电后直接打开,无需修复工具
这是自研布局最重要的承诺:
打开路径自动完成:唯一性检查 → 超级块采纳 → 日志重放(脏卷)→ 可达性对账 → 恢复可写。断电后直接打开即可,无需修复工具。
机制分两条路线,按卷形态递进:
基础路线:写时复制元数据 + 超级块原子翻转。 元数据序列化为镜像 → 新块落盘(旧块不动)→ 位图落盘 → 备份侧翻转(代数 +1)→ 主侧翻转。单一原子提交点 = 超级块代数。 提交序不变量:数据先于元数据,元数据先于翻转。恢复时取校验有效且代数最高的一份超级块,载入其镜像,脏则做”可达性对账”重写位图(未提交的分配当作孤儿回收——位图 = 可达集恢复)。
这里还有个顺手的优化:脏页 / 镜像 / 位图 / 备份侧连续写入后合并为一次 fsync(“数据先于元数据”在同一屏障内成立),主侧翻转后再一次——从 5 次设备屏障降到 2 次,崩溃语义不变。
进阶路线:物理循环日志 + 有效前缀提交。 开日志的卷把”原子提交点”从每次操作的超级块翻转改成每批次的日志屏障(超级块翻转留给后台检查点)。日志是 ordered 模式:记录粒度 = 逻辑操作,原子性由”有效前缀”规则承载——恢复时顺序扫描,重放所有校验有效且序号大于检查点的记录,遇首个损毁记录即止;屏障完成时刻之后的记录永不可见,之前的全部可见。
两条纪律值得抄:“重放零载体写”(位图/结构操作全在内存,数据已按序持久);以及重放与写路径共用同一批操作函数——“防『两套逻辑漂移』(日志系统第一死因)”。
自动扩容也是同一个提交协议的应用:容量触及边界时,位图重定位 + 超级块原子翻转(初始小界、倍增;有容量护栏)。翻转前崩溃 = 旧界旧位图完好;翻转后崩溃 = 新位图已完整。日志语义不切割——追加式扩容下既有记录的全局块号稳定。
文档对边界同样诚实:日志区整体损毁(载体中段坏块)退化为检查点态——日志不提供坏块容忍;双超级块皆坏 → 拒开(既有不可修复场景,没变)。
两档 IO:缓冲档与直达档
对外只有一个切换面:打开时带不带”不缓冲”这个提示。
| 档 | 路径 | 适用 |
|---|---|---|
| 缓冲 | 自管页缓存(命中 memcpy / 缺页读载体 + 自动预读) | 随机读、重读、小写(写回 + 后台回写线程) |
| 直达 | 绕过自管缓存直读直写(文件载体走直接读通道) | 大顺序扫描、备份、透写 |
两档语义同构,并且”直达档的对齐由实现吸收”——未对齐的缓冲也能用(弹跳窗 + 三重对齐,单段全对齐时走零拷贝直读)。一致性也内建:直达写失效对应缓存页、直达读前排干重叠脏页。
但这里有一次很贵的实测否决:我们一度想做”文件载体统一走直接 IO”,被实测打回了——
直接 IO 的同步小写每写一次设备往返(实测小写是甜点、大块是灾难段)……单线程排干顶替不了内核电梯调度;缓冲档能平权裸盘的存在条件 = 内核回写吸收。
硬切之后缓冲档从 1330 MB/s 掉到 80–300 MB/s。最终形态是读写分开定策:写侧保持缓冲、读侧补专用直接读通道(补齐后顺序读 -78% → +2~+7%)。“读写两侧分开定策”从此成为这套两档模型的核心规范。
边界:软件已到极限,物理地板一步不让
最后是这套设计最诚实的部分。我们专门写过一份《写路径性能极限论证》,逐形态给出”极限状态 + 残余软件空间”:
| 负载形态 | 极限状态 | 残余软件空间 |
|---|---|---|
| 单写者顺序追加(盘) | 硬件极限(盘写速地板 0.53–0.56 GB/s) | 无 |
| 追加 + 逐次刷盘 | fsync 硬件地板(564µs ≈ 裸 fsync 548µs) | 无 |
| 单写者大块写(内存) | 带宽极限(memcpy 物理) | 无 |
| 4 写者小写(内存) | 内存子系统极限 | 无 |
而且有一次自我证伪很有说服力:我们账面估算”小写还有 15–25% 的软件优化空间”,随后做了两组实验——实测零提升。结论写得很狠:“不是’残余’,是下界本身”。
所以这份文档的定位是”设计无问题”的证明:每个”没有优化空间”的结论都附测量证据或下界论证,每个”有空间但不可达”的结论都附收益/风险量化。方法论上也有一条裁定:测量优先于实现——每项功能先探针后实现,“初始最优 ≠ 终局最优,落地实测可能更慢”。
这条裁定还真被验证过:写并发模式做过两档(全串行 vs 数据段并行),实测在快速载体上全串行反而更好(1 写者 6.36 GB/s、4 写者扩展率 0.92×;并行档 0.66×,争用损失大于并行收益)——于是两档都保留成显式旋钮,而不是”并行一定更快”。
明确的非目标(写下来是为了防止事后返工):数据页日志(有序模式等价且省一半日志流量)、格式级压缩(压缩在管线层做,格式层评估后判定不做)、格式级加密(外包给载体级加密)、旧卷在线启用日志。异步 IO 框架有明确的否决记录——“无收益锚点不启动”。
小结
这套设计的几个可复用点:
- 把”能用不能改”的一层收回来——不是为了性能(元数据面 35×/176× 是附带),而是为了语义自持:命名、删除、原子性、元数据通道不再按平台分裂;
- 格式设计要”只加不改”——多载体字段第一天就位、日志字段第一天预留、未知值拒开,让升级是”启用”而不是”换代”;
- 把复杂度摊成正交旋钮——预分配 / 稀疏 / 缓存路径 / 写穿 / 写并发,用组合覆盖形态,而不是为每个场景造一套实现;
- 一致性靠协议而不是靠工具——写时复制 + 原子翻转 + 有效前缀日志,换来”断电后直接打开,无需修复工具”;
- 承认物理地板——把”软件已到极限”写成有证据的文档,比任何性能承诺都值钱。
本文事实来自 TC.Tier 的虚拟文件系统文档、盘上格式规范、写路径性能极限论证、IO 语义台账,以及实现目录与提交历史。
评论