推荐语
探索现代存储技术如何助力深度学习,了解DeepSeek 3FS系统的强大性能和设计优势。
核心内容:
1. 3FS系统在DeepSeek数据访问中的关键角色
2. 3FS集群的配置和读压测吞吐性能
3. 传统文件系统与3FS在数据访问模式上的差异及优化
杨芳贤
53A创始人/腾讯云(TVP)最具价值专家
开源周第五天:3FS,为所有DeepSeek数据访问提供动力的推进器,一个并行文件系统,充分利用现代SSD和RDMA网络的全带宽。支持V3/R1中的训练数据预处理、数据集加载、检查点保存/重载、嵌入向量搜索及KVCache查找推理。DeepSeek展示了一个大型3FS集群的读压测吞吐情况。该集群由180个存储节点组成,每个存储节点配备2×200Gbps InfiniBand网卡和16个14TiB NVMe SSD。大约500+个客户端节点用于读压测,每个客户端节点配置1x200Gbps InfiniBand网卡。在训练作业的背景流量下,最终聚合读吞吐达到约6.6TiB/s(单盘2.3GiB/吞吐)。传统文件系统通常针对顺序、线性访问模式进行优化。 这对关系数据库引擎等都大有裨益。然而,预训练大多以随机、短暂的方式访问数据 - 完全相反。但是为什么要这样访问数据呢? 假设我们有大量文本,无法全部放入 GPU 的 RAM 中。我们也不想一次处理一个标记 - 因为这会导致计算/内存能力的极度利用不足。更理想的方法是一次训练“一批”数据。在顺序数据上训练模型可能会“记住”数据的顺序性,而不是从底层特征中学习 - 随机抽样基本上可以消除这种风险。
因此,这是使用错误工具(传统文件系统)完成工作(大规模预训练)的罕见案例。同样值得注意的是,从理论上讲,与硬盘相比,SSD 在随机访问和顺序访问方面提供相同的性能。
读取文件时,程序通常会经过一个中间人——内核,它提供文件缓存等功能。文件缓存的目的是检测经常读取的块,将它们存储在内存中,并在再次访问时节省时间。
(文件缓存也会导致不可预测的内存使用量/延迟峰值) DeepSeek 的文件系统完全消除了它,客户端直接访问驱动器(直接 IO)——删除了中间人(Postgres 或 OracleDB 等 DBMS 中常见的技术)。
DeepSeek的 FS 也针对 KV 缓存进行了优化。Transformer 的核心部分是用于随意注意的查询、键、值张量。KV 缓存是一种简单的技术,可以大大提高推理性能 - 通过重新获取 Q 和 K 张量,而不是在每一层重新计算。
到目前为止,KV 缓存通常驻留在 GPU 的易失性存储器中,而 DeepSeek 的文件系统现在既可以支持更大的缓存,又可以支持更大的 K 和 V 张量容量(略微牺牲带宽),从而实现整体性能的提升。
文件系统还具有一流的高度优化的数据格式 - “FFRecord” - 它可以与现有 ML 框架(torch,Jax 等)的数据加载器很好地集成。
3FS 系统由四个组件组成:集群管理器、元数据服务、存储服务和客户端。所有组件都通过 RDMA 网络(InfiniBand 或 RoCE)互连。元数据服务和存储服务会向集群管理器发送心跳信号。集群管理器负责处理成员变更,并将集群配置信息分发给其他服务和客户端。系统部署了多个集群管理器,其中一个被选举为主管理器(Primary)。如果主管理器发生故障,系统会自动提升另一个管理器为新的主管理器。集群配置信息通常存储在可靠的分布式协调服务中,如 ZooKeeper 或 etcd。在我们的生产环境中,我们直接使用存储文件元数据的同一KV存储,以减少外部依赖。
文件元数据操作(如打开或创建文件/目录)会发送到元数据服务进行处理。元数据服务实现了文件系统的语义,但本身是无状态的,因为文件元数据存储在支持事务的KV存储系统(如 FoundationDB)中。客户端可以连接到任意一个元数据服务。每个存储服务管理若干本地 SSD,并提供分块存储(Chunk Store)接口。存储服务采用带分配查询的链式复制(CRAQ)算法,确保数据的强一致性。CRAQ 采用“全部写入、任意读取”的策略,充分发挥 SSD 和 RDMA 网络的吞吐能力。3FS 文件会被拆分成大小相等的数据块,并在多个 SSD 上进行复制存储。我们为应用程序开发了两种客户端:FUSE 客户端和原生客户端。大多数应用程序使用 FUSE 客户端,因为它的使用成本较低,集成简单。而对于对性能要求极高的应用,我们提供了原生客户端以实现更好的性能优化。对象存储在数据分析和机器学习领域正变得越来越流行。然而,文件系统语义以及基于目录组织文件的统一命名空间,为应用程序提供了更大的灵活性。1. 原子化的目录操作:对象存储可以通过在对象键中使用斜杠(/)来模拟层次化的目录结构,但它不支持诸如原子移动文件/目录或递归删除整个目录等操作。实际上,在我们的内部应用中,一个常见的模式是先创建一个临时目录,写入文件后再将其移动到最终位置。当需要处理大量小文件时,递归删除目录至关重要。如果缺少该功能,应用程序只能遍历每个目录并逐个删除文件,效率低下。2. 符号链接和硬链接:我们的应用程序广泛使用符号链接和硬链接,以便对动态更新的数据集创建轻量级快照。其中,新数据会以单独的文件形式追加存储。3. 熟悉的接口:文件系统接口广为人知,并被广泛使用,开发者无需学习新的存储 API。许多数据集本身就是以 CSV 或 Parquet 文件的形式存储的。因此,将基于文件的数据加载器适配到 3FS FUSE 客户端或原生客户端非常简单。FUSE(Filesystem in Userspace)通过 FUSE 内核模块,将 I/O 操作重定向到用户空间进程,简化了文件系统客户端的开发。它让应用程序看起来像是在访问本地文件系统,但 FUSE 存在以下性能瓶颈:1. 内存拷贝开销:FUSE 的用户空间文件系统守护进程无法直接访问应用程序的内存,因此数据在内核态和用户态之间传输时会消耗大量内存带宽,增加端到端的延迟。2. 线程模型受限:当应用程序发起 I/O 请求时,FUSE 会将这些请求放入一个多线程共享队列,并使用自旋锁(spin lock)进行保护。用户空间文件系统守护进程再从队列中取出请求并处理。然而,由于锁争用(lock contention)问题,FUSE 的 I/O 处理能力无法随着线程数的增加而线性扩展。我们的基准测试表明,FUSE 仅能处理大约 40 万次 4KiB 读取/秒。进一步增加并发度并不会提升性能,反而会因锁争用导致性能下降。perf 性能分析表明,内核态的自旋锁占用了大量 CPU 时间。3. 并发写入受限:大多数应用(如数据分析)在 3FS 上执行大块写入,或者在内存中缓存数据,待写缓冲区满后再一次性刷入 3FS。然而,在 Linux 5.x 内核中,FUSE 不支持对同一文件的并发写入。应用程序通常通过同时向多个文件写入数据来规避此问题,以最大化总吞吐量。4. 复杂的读操作模式:读操作的模式更为复杂。部分训练任务需要随机访问数据集样本,单次读取的大小可能从几 KB 到几 MB 不等,并且样本在文件中的数据偏移往往不是 4K 对齐的。数据加载器通常会预取一批样本,但在 FUSE 挂载的 3FS 上进行小规模的随机读取时,性能较差,SSD 和 RDMA 网络的带宽无法被充分利用。将文件系统客户端实现为 VFS(虚拟文件系统)内核模块 可以避免前述的性能问题,但 内核模块的开发远比用户态编程复杂。
1. Bug 调试困难:一旦出现问题,可能导致 生产环境发生严重故障,甚至直接导致机器崩溃,而且可能不会留下任何日志,增加排查难度。2. 升级复杂:当需要升级内核模块时,所有正在使用该文件系统的进程必须被优雅地终止,否则可能需要重启整台机器。基于这些考虑,我们选择在 FUSE 守护进程(daemon)内部实现原生客户端。该客户端提供 支持异步零拷贝 I/O 的接口:
1. 文件元数据操作:(如 open()
、close()
、stat()
)仍然由 FUSE 守护进程 处理,以保持 POSIX 语义的一致性,方便现有应用迁移。2. 数据 I/O 操作:通过 原生客户端 直接执行,绕过 FUSE 的性能瓶颈。应用程序通过 open()
打开文件后,可以使用 原生 API 进行注册(register),然后使用 原生客户端 进行 I/O 操作。这种设计既保证了 元数据管理的兼容性,又大幅提升了数据读写的性能。
该 异步零拷贝 API 的设计灵感来源于 Linux io_uring,其核心数据结构包括:
1. Iov(输入/输出缓冲区)
一个大块内存区域,用于零拷贝读写操作,由用户进程和原生客户端共享;InfiniBand 内存注册由原生客户端管理;在原生 API 中,所有读取的数据都会写入 Iov
,所有待写入的数据在调用 API 之前必须先存入 Iov;
2. Ior(I/O 请求环形缓冲区)
一个小型共享环形缓冲区,用于用户进程与原生客户端之间的通信。其使用方式类似于 Linux io_uring
:用户进程将用户进程将读/写请求放入队列(enqueue),原生客户端取出请求并处理(dequeue);读写请求按批次执行,其批量大小由 io_depth
参数 控制。
多个批次可以并行处理,无论是来自不同的 Ior
,还是同一个 Ior
。但对于多线程应用,仍建议使用多个 Ior
,因为共享一个 Ior
需要同步,会影响性能。
3. 原生客户端的并行 I/O 处理
在原生客户端内部,会创建多个线程,用于从 Ior
中获取 I/O 请求。这些请求会进行批量处理,然后统一发送到存储服务,以减少小型随机读请求引发的 RPC 开销,提高整体吞吐量。
3. 文件元数据存储
3.1 文件块的位置
3FS 将文件数据划分为等大小的数据块(chunk),并将其条带化分布存储在多个复制链(replication chain)上。
用户可以按目录指定chain table、块大小(chunk size)和条带大小(stripe size)。
每个数据块独立存储在多个存储服务上,其 chunk ID 通过文件的 inode ID 和 chunk index拼接而成。
在创建新文件时,元数据服务会采用轮询策略(round-robin)从指定的chain table中选择连续的复制链,依据条带大小进行分配。然后,生成一个随机种子来打乱所选复制链的顺序,从而确保数据在复制链和 SSD 之间的负载均衡。
当应用程序打开文件时,客户端会查询元数据服务以获取文件的数据布局信息。之后,客户端可以独立计算 chunk ID 和对应的复制链,这样在执行数据操作时,无需频繁依赖元数据服务,从而减少了元数据服务在关键路径中的参与,提高访问效率。
3.2 文件元数据
3FS 采用 FoundationDB 作为其分布式元数据存储系统。FoundationDB 提供键值存储接口,并支持 可序列化快照隔离(SSI)事务。
3FS 将所有元数据存储为 键值对,元数据服务采用无状态架构,这使得系统维护更加便捷,管理者可以无缝升级或重启服务,而不会影响正常运行。当客户端请求失败或超时时,它们可以自动切换到其他可用的元数据服务。
文件系统的元数据主要由两个核心结构组成:inode(索引节点)和目录项。inode 用于存储文件、目录和符号链接的属性信息,每个 inode 由一个全局唯一的 64 位标识符标识,该标识符单调递增。inode 的键由前缀 "INOD" 和 inode ID 拼接而成,其中 inode ID 以小端字节序编码,以便将 inode 分散到多个 FoundationDB 节点上。inode 的值根据其类型有所不同:1. 所有类型的 inode 都包含以下基本属性:所有权、权限、访问/修改/变更时间。2. 文件 inode 的额外属性包括:文件长度、块大小、chain table中选定的范围、随机种子。3. 目录 inode 的额外属性包括:父目录的 inode ID、子目录/文件的默认布局配置(chain table、块大小、条带大小)。父目录的 inode ID 用于在移动目录时检测循环。例如,将 dir_a/dir_b 移动到 dir_c/ 时,需要确保 dir_c 不是 dir_b 的子目录,这可以通过向上检查 dir_c 的所有祖先来实现。4. 符号链接 inode 的额外属性包括:目标路径字符串。目录项的键由前缀 "DENT"、父目录的 inode ID 和 条目名称组成。目录项的值存储目标 inode ID 和 inode 类型。一个目录中的所有条目自然形成一个连续的键范围,从而可以通过范围查询高效地列出目录内容。元数据操作利用了 FoundationDB 的事务机制:1. 只读事务用于元数据查询,例如 fstat、lookup、listdir 等。2. 读写事务用于元数据更新,例如 create、link、unlink、rename 等。对于写事务,FoundationDB 会跟踪读写键集以形成冲突检测集。当检测到并发事务冲突时,元数据服务会自动重试事务。这种设计允许多个元数据服务并行处理请求,同时保持文件系统元数据的一致性。3.3 动态文件属性
文件会话管理:在大多数本地文件系统中,删除正在打开的文件会被延迟,直到所有相关的文件描述符(fd)被关闭。因此,系统需要跟踪所有文件描述符。然而,在训练任务中,启动时会打开大量文件,如果存储所有 fd,会给元数据服务和 FoundationDB 带来巨大负担。由于训练任务不依赖该特性,3FS 不追踪以只读模式打开的文件描述符。
但对于以写入模式打开的文件,3FS 需要维护文件会话(file session),以防止在并发写入时删除文件导致垃圾块无法回收。当一个仍有活跃写入会话的文件被删除时,元数据服务会延迟删除,直到所有相关 fd 被关闭。为了避免离线客户端的会话长期存在,3FS 元数据服务会定期检查客户端的存活状态,并清理离线客户端的会话。
文件长度管理:文件长度存储在 inode 中。但对于正在被写入的文件,inode 存储的长度可能与实际长度不一致。因此,客户端会每 5 秒(默认)向元数据服务报告每个写入文件的最大写入位置。如果该位置超过 inode 记录的长度,且没有并发 truncate 操作,则该位置会被更新为新的文件长度。
由于多个客户端可能同时写入同一文件,上述方法只能保证最终一致性。在 close/fsync
操作时,元数据服务会向存储服务查询最后一个数据块的 ID 和长度,以获取精确的文件长度。但由于文件数据分布在多个复制链上,这一过程可能产生较大的开销。
并发文件长度更新优化:如果多个元数据服务同时更新同一文件的长度,可能会引发事务冲突,导致重复计算文件长度。为优化该过程,3FS 使用 inode ID 结合 Rendezvous 哈希算法,在多个元数据服务间分配文件长度更新任务,以减少冲突。
在生产环境中,3FS 采用较大的条带大小(stripe size = 200)。对于小文件,其数据块的存储链数通常远小于 200。为优化长度更新时的链数查询,3FS 在文件 inode 中存储一个链数提示值,初始值设为 16,每当额外的数据块写入新链,该值翻倍。这样可以避免在更新小文件长度时查询所有 200 条链,从而减少不必要的查询开销。这一优化同样可以用于小文件删除,提升系统整体性能。
块存储系统的设计目标是在存储介质发生故障时仍能实现最高的带宽。3FS 的读写吞吐量应随 SSD 数量以及客户端与存储服务之间的网络带宽呈线性扩展。应用程序以无感知数据位置的方式访问存储服务。每个文件块通过链式复制与分配查询(CRAQ)的方式在一组存储目标之间进行复制。在 CRAQ 中,写请求会发送到链的头部目标,并沿链传播。读请求可以发送到链中的任意一个存储目标。通常,为了更好的负载均衡,读流量会均匀分布在链中的所有目标上。每个 SSD 上会创建多个存储目标,这些目标会加入不同的链。假设有 6 个节点:A、B、C、D、E、F。每个节点有 1 个 SSD。在每个 SSD 上创建 5 个存储目标:1、2、...、5。那么总共有 30 个目标:A1、A2、A3、...、F5。如果每个块有 3 个副本,则chain table的构建方式如下:每条链都有一个版本号。如果链发生变化(例如某个存储目标下线),版本号会递增。只有主集群管理器可以对链表进行修改。可以构建多个chain table以满足不同的数据分布需求。例如,可以创建两个chain table,一个用于批处理/离线任务,另一个用于在线服务。这两个chain table由互不重叠的节点和 SSD 上的存储目标组成。从逻辑上讲,每条链的状态是独立变化的。每条链可以包含在多个chain table中。引入链表的概念是为了让元数据服务能够为每个文件选择一个表,并将文件块分布到表中的多个链上。假设在上述chain table中,读流量均匀分布在所有存储目标上。当节点 A 发生故障时,其读请求会被重定向到节点 B 和 C。在高负载情况下,B 和 C 的读带宽会立即饱和,成为整个系统的瓶颈。更换故障 SSD 并将数据同步到新 SSD 可能需要数小时,在此期间读吞吐量会受到严重影响。为了减少性能影响,可以让更多的 SSD 分担重定向的流量。在下面的chain table中,节点 A 与所有其他 SSD 配对。当 A 发生故障时,其他每个 SSD 会接收到 A 的 1/5 读流量。为了在恢复期间实现最大读吞吐量,负载均衡问题可以建模为一个平衡不完全区组设计(Balanced Incomplete Block Design)。通过整数规划求解器可以获得最优解。CRAQ 是一种写全读任意(write-all-read-any)的复制协议,专为读密集型工作负载优化。在全闪存存储系统中,充分利用所有副本的读带宽对于实现最高读吞吐量至关重要。1. 服务检查写请求中的链版本是否与最新已知版本匹配;如果不匹配,则拒绝请求。写请求可能由客户端或链中的前驱节点发送。2. 服务发起 RDMA 读操作以拉取写数据。如果客户端或前驱节点失败,RDMA 读操作可能会超时,写操作会被中止。3. 一旦写数据被拉取到本地内存缓冲区,服务会从锁管理器中获取待更新块的锁。对同一块的并发写操作会被阻塞。所有写操作在链的头部目标处串行化。4. 服务将块的已提交版本读入内存,应用更新,并将更新后的块存储为待定版本。一个存储目标可能存储一个块的两个版本:已提交版本和待定版本。每个版本都有一个单调递增的版本号。已提交版本和待定版本的版本号分别为 v 和 u,且满足 u = v + 1。5. 如果服务是链的尾部,则用待定版本原子替换已提交版本,并向其前驱节点发送确认消息。否则,将写请求转发给后继节点。当已提交版本更新时,当前链版本会作为块元数据的一个字段存储。6. 当确认消息到达存储服务时,服务会用待定版本替换已提交版本,并将消息继续传播给其前驱节点。然后释放本地块锁。假设链中有 3 个目标:A、B、C。一个写请求刚刚在 A 处进入第 5 步,A 将请求转发给后继节点 B。此时 B 立即故障,转发的写请求丢失。当集群管理器检测到 B 的故障时,会将 B 标记为离线并将其移到链的末尾,并广播更新后的链表。一旦 A 接收到最新的链表,它会将写请求转发给新的后继节点 C。C 可能尚未接收到最新的链表并拒绝请求,但 A 可以持续将请求转发给 C。最终,C 获取到最新的链表并接受请求。1. 如果服务只有块的已提交版本,则将该版本返回给客户端。2. 与 CRAQ 不同,我们的实现不会向尾部目标发起版本查询。如果同时存在已提交版本和待定版本,服务会返回一个特殊状态码通知客户端。客户端可以等待短暂间隔后重试,或者发起一个宽松读请求以获取待定版本。集群管理器依赖心跳检测故障停止(fail-stop)故障。如果集群管理器在可配置的时间间隔(例如 T 秒)内未收到某个服务的心跳,则会宣布该服务故障。如果服务在 T/2 秒内无法与集群管理器通信,则会停止处理请求并退出。心跳可以看作是对集群管理器授予的租约的续租请求。元数据服务是无状态的。集群管理器提供的在线元数据服务列表是一种简单的服务发现机制,帮助客户端建立与元数据服务的连接。如果一个元数据服务下线,客户端可以切换到其他任何元数据服务。集群管理器在存储服务的成员变更中扮演更关键的角色。它维护链表和存储目标状态的全局视图。每个存储目标都有一个公共状态和一个本地状态。公共状态指示它是否准备好处理读请求,以及写请求是否会传播给它。公共状态与链表一起存储,并分发给服务和客户端。本地状态仅由存储服务和集群管理器知晓,并存储在集群管理器的内存中。如果某个存储目标发生介质故障,相关服务会在心跳中将该目标的本地状态设置为离线。如果某个存储服务下线,则由该服务管理的存储目标会被标记为离线。存储目标的公共状态会根据最新的本地状态发生变化。本地状态起到触发事件的作用。集群管理器会定期扫描每条链,并根据状态转换表更新链上目标的公共状态。2. 如果某个存储目标被标记为离线,它会被移到链的末尾。3. 如果存储服务发现任何本地存储目标的公共状态为 `lastsrv` 或 `offline`,它会立即退出。该服务可能因网络分区错误而与集群管理器隔离。4. 一旦处于同步状态的存储目标完成数据恢复,存储服务会在后续发送给集群管理器的心跳消息中,将该目标的本地状态设置为 `up-to-date`。4.5 数据恢复
当存储服务退出(例如进程崩溃或升级期间重启)或存储介质发生故障时,集群管理器会将所有相关的存储目标标记为离线并移到链的末尾。
一旦服务重启,服务上的每个目标会独立进入恢复过程。整个恢复过程与正常活动重叠,最大限度地减少中断。当之前离线的存储服务启动时:
1. 服务会定期从集群管理器拉取最新的链表,但在其所有存储目标在最新链表中被标记为离线之前,不会发送心跳。这确保所有目标都会经历数据恢复过程。
2. 在恢复期间收到写请求时,该请求始终是一个全块替换写操作。本地已提交版本会被更新,任何现有的待定版本会被丢弃。由于当前服务是链的尾部,它会向前驱节点发送确认消息。通过连续的全块替换写操作,前驱节点的完整状态会被复制到恢复的服务中。
3. 在存储目标的数据恢复开始之前,前驱节点会向恢复的服务发送一个 `dump-chunkmeta` 请求。然后,服务会遍历本地块元数据存储,收集目标上所有块的 ID、链版本以及已提交/待定版本号,并将收集到的元数据回复给前驱节点。
4. 当收到 `sync-done` 消息时,服务知道该存储目标已更新完毕。它会在发送给集群管理器的心跳消息中将目标的本地状态设置为 `up-to-date`。
当存储服务发现之前离线的后继节点上线时:
1. 服务开始将正常的写请求转发给后继节点。客户端可能只更新块的一部分,但转发的写请求应包含整个块,即全块替换写操作。
2. 服务向后继节点发送 `dump-chunkmeta` 请求。一旦收到后继节点上所有块的元数据,它会收集本地目标上的块元数据,并比较两份元数据以决定需要传输哪些块。
3. 通过发起全块替换写请求,将选中的块传输给后继节点。首先为每个块获取块锁。读取链版本、已提交版本号和块内容,并通过发送全块替换请求将其传输给后继节点。释放块锁。
4. 当所有需要传输的块都完成后,向后继节点发送 `sync-done` 消息。用于决定哪些块需要传输的规则如下:
- 如果块仅存在于本地目标,则需要传输。
- 如果块仅存在于远程目标,则需要删除。
- 如果本地块副本的链版本大于远程块副本的链版本,则需要传输。
- 如果本地和远程块副本的链版本相同,但本地已提交版本号不等于远程待定版本号,则需要传输。
- 否则,两个块副本要么相同,要么正在被进行中的写请求更新。
4.6 块与元数据
文件块存储在块引擎中。在每个 SSD 上,块引擎的持久化存储由固定数量的数据文件(用于存储块数据)和一个 RocksDB 实例(用于维护块元数据和其他系统信息)组成。
此外,块引擎还维护一个块元数据的内存缓存,以提高查询性能。块引擎实现了一个块分配器,用于快速分配新块。块引擎接口通过以下操作提供线程安全的访问:
-open/close:通过从 RocksDB 加载元数据并重建块分配器状态来初始化引擎。
-get:通过哈希表缓存检索块元数据和引用计数句柄,支持平均复杂度为 O(1) 的并发访问。
-update:通过分配新块实现写时复制(COW)语义。旧块在所有句柄释放之前仍可读。
-commit:通过写批次将更新的块元数据提交到 RocksDB,确保原子更新;同步刷新块元数据缓存。
块数据最终会存储在物理块上。物理块的大小从 64KiB 到 64MiB,按 2 的幂次递增,共有 11 种不同大小。
分配器会分配与实际块大小最接近的物理块。为每种物理块大小构建一个资源池,每个池包含 256 个物理文件。物理块的使用状态通过位图在内存中维护。当物理块被回收时,其位图标志被设置为 0。块的实际存储空间会保留,并优先用于后续分配。当没有可用物理块时,会使用 `fallocate()` 在物理文件中分配一个连续的大空间,创建 256 个新物理块——这种方法有助于减少磁盘碎片。
在对块执行写操作时,分配器首先分配一个新的物理块。系统将现有块数据读入缓冲区,应用更新,并将更新后的缓冲区写入新分配的块。
对于追加操作,实现了优化过程,直接在现有块的末尾就地添加数据。从新块的位置和现有块元数据构建新的元数据副本。随后,新的块元数据以及新旧物理块的状态会在 RocksDB 中原子更新。