文章摘要:聊聊我们用自研的、持久化的 Radix Tree (Fractal ART) 替换 RocksDB 的原因,以及我们为此付出的成本。
在构建 FractalBits[1] 对象存储的初期,我们和许多开发者一样,最初计划使用 RocksDB 作为元数据引擎。但在原型开发阶段,我们发现有一个难以解决的问题是:核心LSM 存储引擎其底层设计无法原生支持key的层级结构。
在 LSM 树的设计里面,所有 key 都只是一长串扁平的、无差别的字节。在进行key的比较时,只是机械地按字典序逐字节处理。因此,路径中的 / 分隔符,对它而言没有特殊含义,路径所蕴含的“层级语义”,因此也无法利用相同的路径前缀,导致了很多重复的比较。
虽然 RocksDB 提供了“前缀提取器”或自定义比较器等功能,但这些本质上是辅助性的优化,无法改变根本问题。这种底层数据结构与上层业务需求的结构性错配,导致了一系列现实的性能问题:大量的冗余前缀比较以及开销很大的 目录重命名等。
本文将完整阐述我们是如何从 LSM 方案,最终转向自研 Fractal ART 的。Fractal ART 是我们自己实现的一套持久化的 Radix Tree,它是一种全路径方案,但从设计上就充分利用了路径的层级结构,从而将查找复杂度从 O(路径长度 * log N) 降到了 O(路径长度),以及同时支持了真正的 O(1) 操作的目录重命名;在高并发写入时也不会在单个目录上导致锁竞争。当然这些也不都是免费的,我们也会同时探讨下构建这套自定义方案所付出的代价。
主流元数据方案概览
在决定自研之前,我们对市面上的主流方案进行了一轮深入研究,它们大致可以分为三类:
1. 基于 LSM 的键值存储(主流方案)
这是最常见的做法。像 Cassandra, CockroachDB, TiDB, Ozone, Ceph (BlueStore) 等系统,底层都在使用 RocksDB 或类似的 LSM 引擎。这套方案已经成熟并且经过了长时间的线上环境打磨,且 RocksDB 也提供了 prefix_seek 这
样的功能来跳过不相关的 SSTable。但这些终究是建议性的优化,无法改变其内核将 key 视为扁平字节的事实,忽略了路径的层级结构,在比较、重命名和数据局部性这些关键问题上还是无能为力。
2. 将元数据交由外部数据库 有些系统选择完全做“甩手掌柜”,将元数据层卸载给 FoundationDB 这样的KV系统。这样做的好处是简化了自身的架构,但问题也很明显:系统的可用性、性能和运维复杂度,都从此与一个外部系统紧密耦合。更关键的是,FoundationDB 本身也是一个有序键值存储,它同样不理解路径的结构。这也意味着只是将元数据管理的难题外包了,并没有真正解决问题:冗余前缀的比较只是从应用层转移到了存储节点上而已。
3. 直接使用本地文件系统
这条路看起来最简单。例如 MinIO 把元数据直接存成 xl.meta 文件,NVIDIA AIStore 则使用文件系统的扩展属性 (xattrs)。这种方式足够简洁,但代价是将元数据性能与本地文件系统的行为深度绑定:list 操作会退化为 readdir,效率低下;元数据无法被分离到更快的存储介质上;并且在单个目录下文件数过多时,许多文件系统的性能都会急剧下降。
我们也考虑过 B+ 树[2],但发现它存在同样的核心问题:目录重命名仍需重写所有相关的 key,而前缀压缩只能节省空间,无法降低 CPU 的比较开销。
索引对象路径的两种思路(及其问题)
方案一:将完整路径作为 Key
这是最直接的模型:将 /bucket/images/2024/photo.jpg 这样的完整路径,作为一个扁平的 key 直接存入 KV 存储。
键 -> 值(元数据 + 数据位置)
/bucket/images/2024/photo.jpg -> {size: 2MB, etag: "abc123", ...}
/bucket/images/2024/video.mp4 -> {size: 50MB, etag: "def456", ...}
如果将这套模型构建在 LSM 树之上,会暴露两个严重的性能问题:
-
冗余的前缀比较:查找
/bucket/images/2024/photo.jpg时,二分查找的每一步,都需要将其与/bucket/images/2024/video.mp4这样的邻近键进行完整的逐字节比较。这导致它们共同的前缀/bucket/images/2024/被 反复处理,浪费了大量 CPU 周期。 -
目录重命名开销大:将
/bucket/images/重命名为/bucket/photos/,意味着需要重写目录下的所有key——这可能是一场涉及数百万次删除和插入的“数据风暴”。更麻烦的是,要保证这个过程的原子性和崩溃一致性(crash consistency),协调逻辑会变得异常复杂。一个本该是 O(1) 的元数据操作,却退化成了一个 O(n) 的全量数据迁移。
方案二:类似 Inode 的模型
这个思路源自传统文件系统,为每个目录和文件分配唯一的 ID (inode),通过父子关系来维护层级。
Inode 表:
inode=1 (目录): name="bucket", parent=0, children=[2]
inode=2 (目录): name="images", parent=1, children=[3,4]
这个模型在处理目录操作和重命名时很自然,但在分布式的 KV 存储中,也带来了新的问题:
-
“多跳”的路径解析:解析一个4层深的路径,需要4次独立的 KV 查询。在分布式环境里,这可能就意味着4次网络往返。
-
普遍的分布式事务:创建一个文件,需要同时创建新文件的 inode,并将其加入父目录的 children 列表。这两处数据只要不在同一个分片上,就需要分布式事务来保证原子性,开销很大。目录重命名也有类似的问题。
-
棘手的目录争用:当大量任务并发写入同一个目录(如 AI 训练中写入 checkpoints)时,所有写操作都会竞争同一个父目录的 inode,从而成为整个系统的性能瓶颈。
我们的方案:Fractal ART
Fractal ART 是我们自己实现的一套持久化 Radix Tree。它有着上文两种方案的优点,使用全路径作为key避免了inode方案的性能问题,但同时支持关键目录语义比如目录重命名等。
它的设计思想源于 Adaptive Radix Tree (ART)。ART 最初是为高效率的内存索引而设计的,它能根据节点的子节点数量,自适应地选择不同的节点类型(Node4, 16, 48, 256),从而在内存效率和查找速度之间取得绝佳平衡。
核心设计:按路径边界切分 Blob
我们没有将树的每个节点都单独存成一个 KV 对,而是将整棵子树序列化后,打包成一个 blob 存储在磁盘上。当这个 blob 过大时,它会自动沿着路径的边界进行“分裂”,将一部分子树拆分成一个新的、独立的 blob,并在父节点 中保留一个指向新 blob 的引用(BLOB_REF)。
+----------------------------------------------------------------+
| BLOB 1: Root Blob |
| |
| (root) -- /bucket/ -+- images/ --> [BLOB_REF -> blob_2] |
| | |
| +- docs/ ---> report.pdf -> [metadata] |
| +- logs/ ---> app.log -> [metadata] |
| |
| (docs/ and logs/ are small, inlined in root blob) |
| (images/ is large, split into a separate blob) |
+----------------------------------------------------------------+
可以说,这套基于 blob 的组织方式,正是 Fractal ART 所有优势的源头。
-
优势一:前缀只处理一次,无冗余比较 在 LSM 树里,一个长 key 在每一层都要被完整地比较多次。但在 Fractal ART 中,查找是沿着路径逐字符遍历的,每个前缀只会被处理一遍。整个查找过程,通常只需几次 blob 读取和一次完整的路径遍历。
-
优势二:O(1) 的原子化目录重命名 在 LSM 树中重命名目录,在实现上需要复杂协调的 O(n) 数据迁移。而在我们这里,整棵子树都挂在一个引用(BLOB_REF)之后。重命名操作仅仅是修改父 blob 中这条边的名称,子树的 blob 本身无需任何改动。这种类似指针的修 改,在实现上容易做到原子性。
-
优势三:告别目录热点争用 在高并发写入同一目录时,Inode 模型会因竞争同一个锁而产生瓶颈。而在 Fractal ART 中,当一个 blob 变热时,它会自动分裂。例如,一个
uploads/目录可以按字母范围分裂成a-m/和n-z/两个独立的 blob。分裂后,写入不同 key 范围的请求会落到不同的 blob 上,每个 blob 都有自己独立的锁,竞争问题因此被自然地解决了。 -
优势四:更少的网络 I/O 在 Inode 模型里,解析一个 N 层深的路径,最坏可能需要 N 次网络往返。而在 Fractal ART 中,由于数据是按前缀组织的,多层路径往往都聚集在同一个 blob 里。一个 N 层深的路径,可能只需若干次单机 blob 读取就能解析完 毕,数据局部性得到了极大改进。
我们付出的代价
当然,自研引擎替换掉 RocksDB 这样成熟的方案,是有切实代价的。
-
工程量:RocksDB 是几十万行经过千锤百炼的 C++ 代码。但我们的 Radix Tree 是一个全新的实现,新代码就意味着新 bug。为了保证它的正确性,我们投入了大量时间进行各种严格的测试,如崩溃注入和模糊测试。如果只是使 用成熟引擎,这些工作没有必要。
-
写入开销:目前,修改一个 blob 需要重写整个 blob。我们通过自适应切分来控制单个 blob 的大小,以缓解这个问题。同时,我们也设计了增量更新的机制:小的改动可以先记录为 delta,累积到一定程度再一次性合并写入磁 盘。
-
更复杂的崩溃恢复:LSM 树的恢复机制非常简洁,一个 WAL 加上一堆不可变的文件即可。我们这套基于 blob 的树,则必须自己实现一套持久化和恢复逻辑。我们采用了 WAL + physiological logging的优化方案,但同时增加了 系统的复杂度和潜在的风险。
-
需要时间打磨:RocksDB 在各大公司的生产环境里已经运行多年,其稳定性经过了时间的检验。对任何一个技术选型团队来说这一点至关重要。我们对自己的测试结果有信心,但也必须承认还需要更多生产环境来证明。
这些都是真实的代价。但对于我们主要面向的 AI 和数据分析场景,目录操作频繁、热点前缀多、路径又深,这样的工程投入是值得的,它换来的结构性优势已经非常明显:在一台 m7gd.4xlarge 的机器上,我们的元数据服务可以支持 100万 QPS 的 4K S3 GET,p99 延迟在 5ms 左右。
总结
| 操作 | 全路径 + LSM | Inode + LSM | Fractal ART (我们) |
|---|---|---|---|
| 单点查找 | 每次二分查找都比较完整键 | 每个路径部分顺序查找 | 单次遍历,字节级索引 |
| 查找复杂度 | O(L * log N) | 多跳 O(L) | 单遍 O(L) (SIMD加速) |
| 目录重命名 | O(n)键重写, 复杂的崩溃语义 | 分布式事务 | 单指针更新 (原子) |
| 前缀查询 | 带迭代器合并的范围扫描 | 多次查找, 可能争用 | 直接枚举子节点 |
| 热点目录 | N/A (扁平命名空间) | 所有写入序列化于单个inode | 分裂成独立 blob |
| 写入开销 | SSTable 分层Compaction | 事务协调 | Blob 重写 (可增量优化) |
| 实现成本 | 现成方案 (RocksDB) | 现成方案 + 事务层 | 自定义引擎 |
结语
总的来说,LSM 树是一个非常优秀的通用数据结构,但它和层级化的路径数据在结构上确实“不匹配”。即使打上各种补丁,它看到的仍然还是一长串扁平的字节,而无法利用到路径中天然的树状结构。
Fractal ART 的核心思想,就是让数据的物理结构去匹配它本来的逻辑形态。我们通过沿路径边界切分 blob 的方式,换来了高效的查找、原子的重命名和可扩展的并发能力,但代价是得从头构建和维护一个存储引擎。
如果你也探索过类似的“路径感知”索引方案,或者有什么好办法能让 LSM 存储更好地处理层级key,欢迎一起交流。
参考文献
[1] FractalBits: fractalbits.com 一款与 S3 兼容的高性能对象存储解决方案。GitHub: github.com/fractalbits…
[2] 关于 B+ 树的补充: B+ 树是大多数数据库的基石,能很好地支持范围扫描,但在路径数据上存在同样的结构性限制。它们在叶节点中存储完整键——前缀压缩(如 WiredTiger 和 LeanStore)只减少了存储空间,并未降低比较成本。其固定的扇出扇入比与路 径层次结构不匹配,使得前缀范围操作不够自然。最关键的是重命名目录前缀仍需重写所有受影响的叶节点键,与全路径 LSM 存储相比并没有显著改进。当然,B+ 树提供了更简单的崩溃恢复机制(ARIES-style WAL)和数十年的生产环 境检验,不过这是我们选择 Radix Tree 时所能接受的权衡。