第
极限挑战:使用Go打造百亿级文件系统的实践之旅
可见这些结构体都非常小,但是数量会非常庞大。Go的GC不支持分代,也就是说如果将它们都交由GC来管理,就需要在每次进行内存回收时都将它们扫描一遍,并且标记所有被引用的对象。这个过程会非常慢,不仅使得内存无法及时回收,还可能消耗过多的CPU资源。
为了能够高效地管理这些海量小对象,我们使用unsafe指针(包括uintptr)来绕过Go的GC进行手动内存分配和管理。实现时,元数据引擎每次向系统申请大块的内存,然后根据对象的大小拆分成相同尺寸的小块来使用。在保存指向这些手动分配的内存块的指针时,尽量使用unsafe.Pointer甚至uintptr类型,这样GC就不需要扫描这些指针,也就大幅减轻了其在执行内存回收时的工作量。
具体而言,我们设计了一个名为Arena的元数据内存池,其中包含有多个不同的桶,用来隔离大小差异较大的结构体。每个桶存放的是较大的内存块,例如32KiB或128KiB。需要使用元数据结构体时,通过Arena接口找到相应的桶,并从其中的内存块划分一小段来使用;使用完毕后,同样通知Arena将其放回内存池。它的设计示意图如下:
具体的管理细节较为复杂,感兴趣的读者可以了解更多关于tcmalloc和jemalloc等内存分配器的实现原理,基本思路与它们类似。以下介绍Arena中的关键代码:
//内存块常驻
varslabs=make(map[uintptr][]byte)
p:=pagePool.Get().(*[]byte)//128KiB
ptr:=unsafe.Pointer((*p)[0])
slabs[uintptr(ptr)]=*p
其中slabs是一个全局的map,它记录了Arena里所有被申请的内存块,这样GC就能知道这些大内存块正在被使用。下面一段是结构体创建的代码:
func(a*arena)Alloc(sizeint)unsafe.Pointer{...}
size:=nodeSizes[type]
n:=(*node)(nodeArena.Alloc(size))
//varnodeMapmap[uint32,uintptr]
nodeMap[n.id]=uintptr(unsafe.Pointer(n)))
其中Arena的Alloc函数用于申请指定大小的内存,并返回一个unsafe.Pointer指针。创建一个node时,我们先确定其类型所需的大小,然后将申请到的指针转换为所需结构体类型,即可正常使用。必要时,我们会将这个unsafe.Pointer转成uintptr保存在nodeMap中。这是一个非常大的映射(map),用来根据nodeID快速找到对应的结构体。
在这种设计下,从GC角度看,会发现程序申请了许多128KiB的内存块,且一直在使用,但里面具体的内容显然不需要它来操心。另外,虽然nodeMap中含有数亿甚至数十亿元素,但其键值均为数值类型,因此GC并不需要扫描每一个键值对。这种设计对GC非常友好,即使上百GiB的内存也能轻松完成扫描。
3.3压缩空闲目录
在2.3节中提到过,文件系统的访问具有很强的局部性,应用程序在一段时间内通常只会频繁访问几个特定的目录,而其他部分则相对闲置,全局随机访问的情况较少。基于此,我们可以将不活跃的目录元数据进行压缩,从而达到减少内存占用的效果。如下图所示:
当目录dir处于空闲状态时,可以将它和它下面所有一级子项的元数据按预定格式紧凑地序列化,得到一段连续的内存缓冲区;然后再将这段缓冲区进行压缩,变成一段更小的内存。
通常情况下,将多个结构体一起序列化后能节省近一半的内存,而压缩处理则能进一步节省大约一半到三分之二的内存。因此,这种方法大幅降低了单个文件元数据的平均占用。然而,序列化和压缩过程会占用一定的CPU资源,并可能增加请求的延迟。为了平衡效率,我们在程序内部监控CPU状态,仅在CPU有闲余时触发此流程,并将每次处理的文件数限制在1000以内,以保证其快速完成。
3.4为小文件设计更紧凑的格式
为了支持高效的随机读写,JuiceFS中普通文件的元数据会分为三个层级来进行索引:fnode、chunks和slice,其中chunks是一个数组,slice则放在一个哈希表中。初始设计时,每个文件都需要分配这3块内存,但后来我