[博客翻译]JuiceFS:如何管理数十亿文件的元数据


原文地址:https://juicefs.com/en/blog/engineering/reduce-metadata-memory-usage


JuiceFS是一个用Go语言编写的云原生分布式文件系统,它能够在单一命名空间中管理数十亿个文件。其元数据引擎采用全内存处理方式,并实现了显著的内存优化,能够使用30 GiB内存处理3亿个文件,响应时间为100微秒。通过内存池、手动内存管理、目录压缩和紧凑型文件格式等技术,元数据内存使用量减少了90%。

JuiceFS企业版在生产环境中,通过10个元数据节点,每个节点配备512 GB内存,共同管理超过200亿个文件。

3.png

为了实现极致性能,我们的元数据引擎采用全内存模式,并不断进行优化。与管理相同数量文件的HDFS NameNode相比,它只需要约27%的内存,与CephFS元数据服务器(MDS)相比,仅需3.7%的内存。这种极高的内存效率意味着在相同的硬件资源下,JuiceFS可以处理更多的文件和更复杂的操作,从而实现更高的系统性能。

在本文中,我们将深入探讨JuiceFS的架构、元数据引擎设计以及减少元数据平均内存使用至100字节的优化方法。我们的目标是为JuiceFS用户提供深入的见解,并增强其在处理极端场景时的信心。我们也希望本文能为设计大规模系统提供宝贵的参考。

JuiceFS架构

JuiceFS由三大组件构成:

  • 客户端:这是与应用程序交互的访问层。JuiceFS支持多种协议,包括POSIX、Java SDK、Kubernetes CSI驱动程序和S3网关。
  • 元数据引擎:它维护文件系统的目录树结构和单个文件的属性。
  • 数据存储:它存储常规文件的实际内容,通常由对象存储服务(如Amazon S3)处理。

JuiceFS社区版架构

目前,JuiceFS提供社区版和企业版两种版本。虽然它们的架构有相似之处,但关键区别在于元数据引擎的实现:

  • 社区版的元数据引擎使用现有的数据库服务,如Redis、PostgreSQL和TiKV。
  • 企业版则采用了自主开发的元数据引擎。这个专有引擎不仅提供了更高的性能和更低的资源消耗,还支持企业级需求。

接下来的章节将探讨我们在开发JuiceFS企业版独家元数据引擎时的考虑和方法。

选择Go作为开发语言

底层系统软件的开发通常基于C或C++,而JuiceFS选择了Go作为开发语言。这是因为Go具有以下优势:

  • 高开发效率:Go语法相较于C更简洁,表达能力更强。此外,Go内置了内存管理功能和强大的工具链,如pprof。
  • 出色的程序执行性能:Go本身是一种编译语言,在绝大多数情况下,用Go编写的程序通常不会落后于C程序。
  • 更好的程序可移植性:Go对静态编译的支持更好,使得程序能够更容易地直接在不同操作系统上运行。
  • 支持多语言SDK:借助原生的cgo工具,Go代码也可以被编译成共享库文件(.so文件),便于其他语言加载。

尽管Go带来了便利,但它隐藏了一些底层细节。这在一定程度上可能会影响程序使用硬件资源的效率,特别是垃圾收集器(GC)的内存管理。因此,在关键性能点需要针对性的优化。

性能提升策略:全内存、无锁服务

为了提高性能,我们需要了解分布式文件系统元数据引擎的核心职责。通常,它主要负责两项重要任务:

  • 管理大量文件的元数据
  • 快速处理元数据请求

为了完成这项任务,有两种常见的设计方法:

  1. 将所有文件元数据加载到内存中,如HDFS NameNode。这可以提供出色的性能,但不可避免地需要大量的内存资源。
  2. 只将部分元数据缓存在内存中,如CephFS MDS。当所请求的元数据不在缓存中时,MDS会暂时保留请求,通过网络从磁盘(元数据池)检索相应内容,解析后再重试操作。这容易导致延迟峰值,影响用户体验。因此,在实践中,为了满足应用程序的低延迟访问需求,尽可能增加MDS内存限制以缓存更多文件,甚至全部文件。

JuiceFS企业版追求极致性能,因此采用了第一种全内存方法,并不断优化以减少文件元数据的内存使用量。全内存模式通常使用实时事务日志来持久化数据以确保可靠性。JuiceFS还使用Raft共识算法实现元数据多服务器复制和自动故障转移。

元数据引擎的关键性能指标是每秒可以处理的请求数量。通常,元数据请求需要确保事务性,并涉及多个数据结构。在并发多线程中,为确保数据一致性和安全,需要复杂的锁定机制。当事务冲突频繁时,多线程并不能有效提高吞吐量;相反,由于过多的锁操作,可能会增加延迟。这在高并发场景中尤为明显。

JuiceFS采用了与Redis的无锁模式类似的不同方法。在这种模式下,所有核心数据结构操作都在单个线程中执行。这种方法具有以下优势:

  • 单线程方法确保了每个操作的原子性(避免被其他线程中断),减少了线程上下文切换和资源争用。从而提高了系统的整体效率。
  • 同时,它大大降低了系统的复杂性,增强了稳定性和可维护性。
  • 由于采用了全内存元数据存储模式,请求可以高效处理,CPU不容易成为瓶颈。

多分区水平扩展

单个元数据服务进程可用的内存是有限的,随着每个进程内存使用量的增加,效率逐渐下降。JuiceFS通过在多个节点上分布元数据的虚拟分区实现水平扩展,支持更大的数据规模和更高的性能需求。

具体来说,每个分区负责文件系统子树的一部分,客户端协调和管理跨分区的文件,将文件组装成单一命名空间。这些分区中的文件可以根据需要动态迁移。例如,一个管理超过200亿个文件的集群可能使用10个元数据节点,每个节点配备512 GB内存,部署在80个分区上。通常,建议将单个元数据服务进程的内存限制在40 GiB,并通过多分区水平扩展管理更多文件。

文件系统访问通常具有很强的局部性,文件在同一目录或相邻目录内移动。因此,JuiceFS实现了动态子树分割机制,维护较大的子树,使得大多数元数据操作都在单个分区内完成。这种方法显著减少了分布式事务的使用,确保即使在大规模扩展后,集群仍然保持与单个分区相似的元数据响应延迟。

如何减少内存使用量

随着数据量的增加,元数据服务的内存需求也在上升。这影响了系统性能,并且增加了硬件成本。因此,在涉及大量文件的场景中,减少元数据内存使用量对于维持系统稳定性和成本控制至关重要。

为了实现这一目标,我们探索并实施了广泛的内存分配和使用优化。下面,我们将讨论通过多年迭代和优化证明有效的一些措施。

使用内存池减少分配

使用内存池减少分配是Go程序中常见的优化技术,主要使用标准库中的sync.Pool结构。原理是使用后不丢弃数据结构,而是将其返回到池中。当再次需要同类型的数据结构时,可以直接从池中获取,无需分配。这种方法有效地减少了内存分配和释放的频率,从而提高了性能。

例如:

pool := sync.Pool{

	New: func() interface{} {

		buf := make([]byte, 1<<17)

		return &buf

	},
}

buf := pool.Get().(*[]byte)

// do some work

pool.Put(buf)

在初始化时,通常需要定义一个New函数来创建一个新结构。当我们使用该结构时,我们使用Get方法获取对象并转换为相应的类型。使用完毕后,我们使用Put方法将结构返回到池中。值得注意的是,返回到池中的结构只有一个弱引用,随时可能被垃圾回收。

上述结构是预分配的内存片段,本质上创建了一个简单的内存池。