< Back

CubeFS技术揭秘 | 元数据子系统设计

2023-08-02Zhixiang Tang

作者简介:TangZhixiang, CubeFS maintainer之一,目前就职于OPPO,负责纠删码系统。

引言

前面CubeFS存储技术揭秘-元数据管理open in new window【1】的文章中介绍了CubeFS元数据系统的设计理念,文章中有提到传统的文件系统存在元数据节点单点的瓶颈问题,CubeFS可扩展的元数据设计有效的解决单点瓶颈问题。本文将从更详细的角度介绍元数据系统在可靠性、可扩展性以及访问性能的一些实现和思考。阅读本篇文章之前,你需要了解CubeFS的系统架构,以及各个关键组件的职责和系统的名词概念。

图片

元数据系统设计

这一章会介绍CubeFS的元数据服务的设计思想,你可以了解到以下信息:

  1. CubeFS的可扩展性元数据服务的设计原理
  2. CubeFS元数据服务的关键数据结构
  3. CubeFS元数据可靠性的实现方案

元数据分片

每个挂载的文件系统对应一个volume,一个volume代表一个虚拟文件系统,用户所有的文件元数据信息都存储在元数据分片(meta partition,简称mp)中,CubeFS的元数据采用range方式进行分片,每个mp负责一段范围的inode (inode用于标识文件和目录,每个文件或目录在文件系统中都有一个唯一的inode),最后一个mp可以支持分裂,这是CubeFS支持元数据水平扩展的关键设计。

下面展示mp的分裂过程,每个volume创建会初始化分配3个mp,假设每个mp负责1000个inode(生产环境每个mp负责1600万个inode),最后一个mp会负责[2001,+)[2001,+∞)的inode。

集群某个MetaNode节点内存使用率达到阈值(一般设定为内存总量的75%),Master组件的后台任务会定期检查每个volume的最后一个mp, 如果最后mp落在内存使用率超过阈值的MetaNode上、最后一个mp所在的MetaNode是只读状态、最后一个mp负责的inode分配到一定数量等场景,Master就会将最后一个mp分裂成两个mp;假设某个时刻集群的mp分布如下图所示:

图片

分裂以后的结果如下图所示,原本mp3负责[2001,+∞)的inode,分裂以后会新创建一个mp4,新的mp4负责[3001,+∞)范围的inode分配,而原来的mp3就负责[2001,3000]的inode。新创建的mp落在那个MetaNode上,在负载均衡部分详细说明。

图片

值得说明的是,inode的分配过程可以理解为轮询策略。对于一个volume,它包含多个mp;客户端每当创建一个新的文件时,客户端会轮询请求到不同的mp上分配inode。假设先后创建两个文件A、B,文件A会在mp1上分配编号为100的inode,而文件B会在mp2上分配编号为1100的inode。如果一个mp上所管理的inode已全部消耗完,则自动切换为只读状态。

数据结构

CubeFS的元数据采用BTree存储,每个mp中维护了两颗BTree,分别是inodeTree和DentryTree。BTree的实现采用谷歌开源的golang版本【2】,该版本BTree写入时候会采用COW的方式来提升数据写入性能,并使用回收列表来分配新的树节点,以减少内存开销。如果您想深入了解其他特性,可以查看源代码。

其中inodeTree的示意图如下,使用inode id作为BTree的索引。

为方便读者理解,这里展示的是下图所示的结构。实际上,BTree采用slice实现,不需要使用指针。此外,inode id是inodeInfo结构体中的一个字段。 图片

每个文件会对应唯一的inode,inode记录了文件的详细元数据,包括inode id、文件类型、创建时间等。

下面的inodeInfo提供了每个文件的详细元数据信息,相关的注释已经做了说明。CubeFS支持多副本和纠删码两种存储,其中Extents表示文件在多副本中的位置信息,而ObjExtents表示文件在纠删码引擎中的位置信息

type inodeInfo struct {
	sync.RWMutex
	inode      uint64 // inode id
	Type       uint32 // 文件的类型和权限
	Uid        uint32 // 用户id
	Gid        uint32 // 组id
	Size       uint64 // 文件大小
	Generation uint64 // 版本号,每次修改会增加
	CreateTime int64  // 创建时间
	AccessTime int64  // 访问时间
	ModifyTime int64  // 修改时间
	LinkTarget []byte // 保存软链接对应源文件的路径名
	NLink      uint32 // 硬链接的数量
	Flag       int32  // 标记是否可以删除
	Extents    *SortedExtents // 文件在三副本的位置信息
	ObjExtents *SortedObjExtents // 文件纠删码的位置信息
}

Extents 是在多副本系统中的位置信息,是一个[]ExtentKey的数组,记录文件在多个不同Extent(存储文件数据的结构,在DataNode节点上,由DP管理,详细可以阅读副本子系统文章)的位置信息,一个文件会分片写入不同的Extent里面。

// ExtentKey defines the extent key struct.
type ExtentKey struct {
    FileOffset   uint64   // 该分片文件中的偏移量
    PartitionId  uint64   // 数据分片的id
    ExtentId     uint64   // extent的id
    ExtentOffset uint64   // 该分片在extent中位置
    Size         uint32   // 文件大小
    CRC          uint32   // crc校验值
 }

ObjExtents 是在纠删码系统中的位置信息,是[]ObjExtentKey 的数组,同样一个文件也会切成多个blob(纠删码系统定义的编码对象大小)写入纠删码系统,所以会有多个ObjExtentKey 信息。

// ExtentKey defines the extent key struct.
type ObjExtentKey struct {
	Cid        uint64 // 纠删码集群的clusterId,纠删码支持多个clusterid部署
	CodeMode   uint8  // 纠删码编码模式
	BlobSize   uint32 // 纠删码系统中每个blob的大小
	BlobsLen   uint32 // bid的数量,就是blobs的长度
	Size       uint64 // 文件大小
	Blobs      []Blob // 每个bid在纠删码中的位置信息,这里不展开介绍了
	FileOffset uint64 // 文件中的偏移量
	Crc        uint32 // crc校验值
}

每个dentry代表一个目录项,其中包含了父目录的inode和该文件(或目录)的名称等信息。dentry的详细结构如下

// Dentry defines the dentry struct.
type Dentry struct {
    ParentId  uint64 // 父目录的的inode
	Name      string // 当前目录项的名字
	inode     uint64 // 当前文件的inode
	Type      uint32 // 文件类型和权限
}

DentryTree的主要作用是加速文件查询。它的索引由parentId和name一起构成。查询时,先比较parentId,如果相同,则继续比较name。每个文件的dentry保存在父目录inode所在的分区,这样一个目录下的所有子文件的dentry信息都会保存在同一个mp中。这种设计考虑了一定的亲和性,查询目录下的文件会集中在一个mp中,而不需要访问整个集群的所有mp。 图片

思考:

这里大家也可以思考能不能使用B+tree来保存InodeTree和DentryTree

可靠性

多个mp会分配到三个MetaNode节点上,通过Raft算法来保证元数据的可靠性。采用了Mutil-Raft算法来降低节点之间的心跳开销。每个MetaNode节点上会存在多个mp,选举一个主节点来处理读写请求。

图片

每五分钟,MP会进行一次快照以持久化元数据。每个快照都会生成一个新文件,并在无异常情况下替换旧文件 (但为了防止数据损坏,旧文件会先做备份)。两次快照操作之间的所有元数据变更都会记录在Raft的WAL日志中,以保证在节点故障后可以快速恢复到故障时刻的状态。快照恢复过程如下:在故障发生后,首先加载前一个快照时刻的数据,然后根据apply index值重放WAL日志中的日志,以恢复内存中的数据。

注意:这里的快照不是Raft的快照,而是单个mp自身的快照 快照文件记录的详细信息如下(文章基于V3.3.0以前版本,V3.3.0以后快照增加了事务相关内容):

  • apply: 表示 Raft的最大的apply index 值
  • inode:文件元数据信息,采用BTree结构
  • dentry: 文件目录项dentry信息,采用BTree结构
  • cursor:当前mp已经分配出去最大的inode ID
  • extent:扩展属性,目前用于对象存储,采用BTree结构
  • multipart:存储对象存储分片上传信息,采用BTree结构
  • .sign:是dentry、extend、inode、multipart的crc32值,用做校验。 图片

负载均衡

mp均衡创建

为保证每个MetaNode上的mp数量均衡,新创建的mp会选择一个合适的MetaNode节点。选择MetaNode的策略基于节点的权重分配,权重值与MetaNode的剩余未使用内存大小呈正相关,剩余内存越大,权重越大;此外,选取过程还增加了一定的随机策略,以避免新扩容的MetaNode在短时间内被大量分配mp,该策略可有效提高系统的稳定性和可靠性。

分配策略

  • 先选择zone,如果volume不跨zone,则zoneNum =1,否则如果集群当前的zone数目小于副本数3,则zoneNum =2,否则zoneNum = 3,这里zone可以理解为可用区;
  • 然后从zone中选择nodeset,nodeset是一组节点的集合,可以作故障域区分;
  • 获取nodeset里面所有可写的MetaNodes,要求MetaNode处于active状态(MetaNode会定期向master做心跳,超过心跳间隔时间未上报会被设置为false,默认是18s)、内存使用率未超过0.75、mp的数量不超过阈值并且不是只读;
  • 筛选MetaNodes所有节点中,可用内存的最大值作为maxTotal,这个作为基准值;
  • 每个节点初始化会带一个InitCarry,这个[0.0 - 1.0]之间;
  • 每个节点有个weight值,内存未使用节点的weight是1,已经使用weight=(maxTotal-used)/maxTotal;
  • 最后每个节点carry=InitCarry+weight,按照carry值从大到小排列;
  • 选择carry值最大的几个Metanode返回,被选中的MetaNode的carry值会--,降低下次被选中概率。 随着集群规模扩大,会不断有新MetaNode节点扩容进来,而旧MetaNode节点上的mp数量一定比新MetaNode上的mp数量多。当mp倾斜严重时,可通过mp下线策略,将旧节点上面的mp下线,系统会重新创建新的mp副本,新的mp副本大概率会分配到新扩容的MetaNode节点。

主mp均衡分布

前面说过每个mp会有三个副本,三副本会通过 Raft 协议选择一个leader mp,leader mp会承担文件的读写请求(对一致性要求不高场景可开启follower read),CubeFS 的Raft 采用lease read的方式优化读请求,来减少每次readIndex读产生的开销。

所有读写请求都会集中到leader mp上,这意味着在一个Metanode节点上,若leader mp的数量越多,该节点承担的读写压力越大。假设某时刻集群中的mp分片分布如下:MetaNode1有3个主mp,而MetaNode3和MetaNode4上没有主mp,这将导致多个MetaNode间负载不均。

图片

通过主动均衡策略,可将leader mp均衡分布到不同节点。正常情况下Raft组的leader mp在集群MetaNode多个节点间是均衡的,但随着MetaNode节点由于各种原因(进程启停、负载过高、硬件故障等,可能导致leader mp在集群中的分布逐渐倾斜;通过主动均衡机制,可有效应对应Leader mp分布失衡导致的集群稳定性问题。

图片

小结

本章介绍了CubeFS的元数据设计的思想,通过分裂提供元数据的扩展特性,此外还介绍元数据的详细数据结构以及如何通过快照和 Raft 来保证元数据的可靠性。相信通过本章的介绍你会对CubeFS的元数据系统有更深的理解。

文件访问的关键流程

假设挂载的路径为 CubeFS/mnt ,初始化volume会分配3个mp,这里假设每个mp负责1000个inode。初始化会创建一个根目录“/”, 开始会随机选择一个mp写入,这里假设选择的是mp1,对应会分配inode :1。每次创建新文件,客户端会维护一个自增的epoch值,每次写入epoch会自增,通过%count(mp)来选择mp分配inode。如果进一步创建目录A就好在mp2上分配inode:1001,创建目录B就会在mp3上分配inode:2001。

如果按照【目录A、目录B、file1、目录C、file2、file3、file4】的顺序创建文件,最终的inode的分布情况如下。

图片

创建文件

现在需要在目录/A/下面创建一个新文件file5,核心流程如下:

  1. fuse客户端启动后会定期拉取volume下面mp的信息,包括每个mp负责的inode范围、mp对应MetaNode的成员和ip地址。
  2. 根目录的inode默认为1,客户端根据每个mp负责的inode范围,可以快速定位inode:1落在mp1上。在mp1中DentryTree记录{ParentId:1,Name:"A",inode:1001}的索引可以快速找到目录A的inode值为1001。客户端会缓存已经查找过的Dentry值以便下次快速定位。
  3. 在mp2中查找/A/file5是否存在,mp2的DentryTree没有记录{ParentId:1001,Name:"file5"}的索引,此时会返回一个文件不存在的错误。
  4. 客户端请求创建一个新的inode给file5,根据轮询策略这个请求会发送给mp3,mp3会创建一个inode为2003的文件元数据,并且在保存在内存的Inode B-Tree中。
  5. inode创建成功后,会在file5的父目录A对应mp中插入一条dentry记录,如果dentry插入失败,会同步删除上一次创建的inode,保证操作的原子性。 图片

最终新的创建的inode和dentry会插入到对应mp的BTree中。

图片

读取文件

假设需要读文件/A/file5,读文件流程和创建文件流程一致,关键流程如下:

  1. 从根目录开始查找file5的父目录A,目录A对应dentry已经在创建file5的流程里已经缓存到客户端,可以直接找到目录A在mp2上
  2. 在mp2中找到file5对应dentry信息{ParentId:1001,Name:"file5",Inode:2003}
  3. 根据客户端缓存的mp信息,可以快速定位inode:2003在mp3上
  4. 会创建一个Streamer结构,从mp3获取文件最新的元数据信息,并更新到Streamer的cache缓存中,根据文件元数据获取文件数据存储的位置信息ExtentKey或者ObjExtentKey,下图假设file5的数据存储在DataNode1的dp1。 图片

文件列表

如果需要获取目录A下面的文件列表,元数据访问的关键流程如下:

  1. 从根目录/开始找到目录A,由于根目录的inode为1,客户端又保存了每个mp管理的inode信息,可以快速在mp1中找到目录A对应的dentry信息{ParentId:1,Name:"A",inode:1001}
  2. 在mp2中列举目录A下面的所有文件信息,因为mp2的dentryTree记录了所有子文件的dentry信息,目录C的dentry{ParentId:1001,Name:"C",inode:1002},file2的dentry{ParentId:1001,Name:"file4",inode:1003},file5的dentry{ParentId:1001,Name:"C",inode:2003},
  3. 分别访问各个子文件所在的mp,获取对应的元数据信息;例如根据目录C的inode:1002和file4的inode:1003在mp2中获取目录C和file4的详细元数据,根据file5的inode:2003在mp3上获取file5的详细元数据 下图展示的mp2中的dentryTree的结构,根据inode找到对应mp流程和读取文件流程类似,这是不再画图展示。

图片

删除文件

图片

假设需要删除刚才创建的/A/file5,关键流程如下:

  1. 同样从根目录开始查找,找到文件对应父目录,这里因为客户端会缓存目录A的dentry信息,所以能够直接找到目录的A所在的mp
  2. 在mp2中删除file5对应的dentry值{ParentId:1001,Name:"file5",Inode:2003}
  3. 向file5所在的mp3发起inode的删除,mp3通过raft达成一致性协议后,对应inode的NLink--
  4. 如果inode的NLink值为0,标准inode为Delete状态,并且加入每个mp的freeList列表,后台用于异步释放文件的内容数据
  5. mp会启动一个后台协程,定期清理freeList里面inode的数据
    1. 每次从freeList中取出一批inode,执行删除流程,文件数据的删除流程请阅读CubeFS源码解读-副本子系统open in new window【3】
    2. 执行删除前,将删除的inode号写入mp根目录下的INODE_DEL文件进行持久化(做记录)
    3. 将各个inode的ExtentKey按照DP做聚合,以此来减少发送给每个DP的删除请求。

元数据节点存储实例展示

MetaNode节点存储的目录结构,包括两个大目录meta和Raft,meta中保存每个MetaNode上面所有mp对应的元数据信息。而Raft则记录每个mp对应的Raft信息。其中expired_patition表示被迁移到其他MetaNode的mp,会有后台任务定期清理expired_partition_*对应的mp目录。expired_raft_*对应是V3.3.0版本以后增加的,V3.3.0版本之前可能无法看到。

├── meta
│   ├── constcfg
│   ├── expired_partition_357  // 被迁移到其他MetaNode的partition
│   ├── expired_partition_358
│   ├── partition_308
│   ├── partition_309
│   ├── partition_310
│   ├── partition_314
└──  Raft 
    ├── expired_raft_357
    ├── expired_raft_358
    ├── 308
    ├── 309
    ├── 310
    ├── 314
   

meta目录

constcfg

保存 Raft 的监听、复制和心跳端口信息,采用mutil- Raft ,MetaNode上面所有mp通过一个统一的端口发送心跳

{"listen":"17210"," Raft ReplicaPort":"17240"," Raft HeartbetPort":"17230"}

partition

每个mp对应元数据目录的记录如下:

├── EXTENT_DEL_V2_0
├── inode_DEL
├── .snapshot
├── .snapshot_backup
├── meta
├── .meta
└── snapshot
    ├── apply
    ├── dentry
    ├── extend
    ├── inode
    └── multipart
    └── .sign 

inode_DEL 记录删除的inode号

EXTENT_DEL_V2_0

记录删除的Extent信息,可能是ExtentKey 或者ObjExtentKey 。

meta

meta记录当前mp的元数据,主要记录mp管理的inode、所归属的volume信息、以及mp对应副本组的节点信息

{
    "end": 9223372036854775807,//最大inode值,最后一个partition取无限大
    "partition_id": 418,
    "partition_type": 0,
    "peers": [
        {
            "addr": "127.0.0.1:17210",
            "id": 4
        },
        {
            "addr": "127.0.0.2:17210",
            "id": 8
        },
        {
            "addr": "127.0.0.3:17210",
            "id": 116
        }
    ],
    "start": 33554433, // 最小的inode值
    "vol_name": "smux-test"
}

start-end是一段inode区间,同一个volume里面的inode对应mp是连续的,下图展示是某个卷上面三个mp负责的元数据信息。 图片

snapshot

快照保存的一些信息

  • apply : 70466|33554433,| 号前面表示 Raft 的最大的apply index 值,**|**后面是cursor值,表示已经分配出去的inode id。
    • 如果cursor大于meta的最后end的值,表示当前mp的inode用尽,会切只读
    • cursor会定期(默认一分钟)做一次持久化, 后续版本会优化成和 Raft 快照一起保存
  • dentry :dentry信息,采用BTree结构
  • inode:inode信息,采用BTree结构
  • extend:扩展属性,目前用于对象存储,采用BTree结构
  • multipart:对象存储分片上传,采用BTree结构
  • .sign:是dentry、extend、inode、multipart的crc32值,用做校验。
├── apply       //一个实例70466|33554433
├── dentry
├── extend
├── inode
└── multipart
└── .sign       // 一个实例 206325109 0 3523407757 3523407757

Raft目录

保存 Raft 元数据和 WAL 日志

  • META是保存term、vote、commit、snapTerm、snapIndex等信息
.
├── 0000000000000001-0000000000000001.log
└── META

参考文献

【1】CubeFS存储技术揭秘-元数据管理open in new window

【2】谷歌开源版本BTreeopen in new window

【3】CubeFS源码解读-副本子系统open in new window