阿里妹导读:随着国内首款CloudNative自研数据库POLARDB精彩亮相ICDE的同时,作为其核心支撑和使能平台的PolarFS文件系统的相关论文"PolarFS:AnUltra-lowLatencyandFailureResilientDistributedFileSystemforSharedStorageCloudDatabase"也被数据库顶级会议VLDB录用,VLDB将于8月份在巴西里约召开。本文着重介绍PolarFS的系统设计与实现。
背景如同Oracle存在与之匹配的OCFS,POLARDB作为存储与计算分离结构的一款数据库,PolarFS承担着发挥POLARDB特性至关重要的角色。PolarFS是一款具有超低延迟和高可用能力的分布式文件系统,其采用了轻量的用户空间网络和I/O栈构建,而弃用了对应的内核栈,目的是充分发挥RDMA和NVMeSSD等新兴硬件的潜力,极大地降低分布式非易失数据访问的端到端延迟。目前,PolarFS的副本跨节点写入的访问总延迟已经非常接近单机本地PCIeSSD的延迟水平,成功地使得POLARDB在分布式多副本架构下仍然能够发挥出极致的性能。
设计初衷
针对数据库设计分布式文件系统会带来以下几点好处:
计算节点和存储节点可以使用不同的服务器硬件,并能独立地进行定制。例如,计算节点不需要考虑存储容量和内存容量的比例,其严重依赖于应用场景并且难以预测。
多个节点上的存储资源能够形成单一的存储池,这能降低存储空间碎化、节点间负载不均衡和空间浪费的风险,存储容量和系统吞吐量也能容易地进行水平扩展。
数据库应用的持久状态可下移至分布式文件系统,由分布式存储提供较高的数据可用性和可靠性。因此数据库的高可用处理可被简化,也利于数据库实例在计算节点上灵活快速地迁移。
此外,云数据库服务也会因此带来额外的收益:
云数据库可以采用虚拟计算环境如KVM等部署形态,其更安全、更易扩展和更易升级管理。
一些关键的数据库特性,如一写多读实例、数据库快照等可以通过分布式文件系统的数据共享、检查点等技术而得以增强。
系统结构
系统组件
PolarFS系统内部主要分为两层管理:
存储资源的虚拟化管理,其负责为每个数据库实例提供一个逻辑存储空间。
文件系统元数据的管理,其负责在该逻辑存储空间上实现文件管理,并负责文件并发访问的同步和互斥。
PolarFS的系统结构如图所示:
libpfs是一个用户空间文件系统库,负责数据库的I/O接入。
PolarSwitch运行在计算节点上,用于转发数据库的I/O请求。
ChunkServer部署在存储节点上,用于处理I/O请求和节点内的存储资源分布。
PolarCtrl是系统的控制平面,它包含了一组实现为微服务的管理者,相应地Agent代理被部署到所有的计算和存储节点上。
在进一步介绍各部分之前,我们先来了解下PolarFS存储资源的组织方法:
PolarFS的存储资源管理单元分为层:Volume、Chunk、Block。
★Volume
Volume是为每个数据库提供的独立逻辑存储空间,其上建立了具体文件系统供此数据库使用,其大小为10GB至TB,可充分适用于典型云数据库实例的容量要求。
在Volume上存放了具体文件系统实例的元数据。文件系统元数据包括inode、directoryentry和空闲资源块等对象。由于POLARDB采用的是共享文件存储架构,我们在文件层面实现了文件系统元数据一致性,在每个文件系统中除DB建立的数据文件之外,我们还有用于元数据更新的Journal文件和一个Paxos文件。我们将文件系统元数据的更新首先记录在Journal文件中,并基于Paxos文件以diskpaxos算法实现多个实例对Journal文件的互斥写访问。
★Chunk
每个Volume内部被划分为多个Chunk,Chunk是数据分布的最小粒度,每个Chunk只存放于存储节点的单个NVMeSSD盘上,其目的是利于数据高可靠和高可用的管理。典型的Chunk大小为10GB,这远大于其他类似的系统,例如GFS的64MB。
这样做的优势是能够有效地减少Volume的第一级映射元数据量的大小(例如,TB的Volume只包含10K个映射项)。一方面,全局元数据的存放和管理会更容易;另一方面,这使得元数据可以方便地缓存在内存中,从而有效避免关键I/O路径上的额外元数据访问开销。
但这样做的潜在问题是,当上层数据库应用出现区域级热点访问时,Chunk内热点无法进一步打散,但是由于我们的每个存储节点提供的Chunk数量往往远大于节点数量(节点:Chunk在1:0量级),PolarFS可支持Chunk的在线迁移,并且服务于大量数据库实例,因此可以将不同实例的热点以及同一实例跨Chunk的热点分布到不同节点以获得整体的负载均衡。
★Block
在ChunkServer内,Chunk会被进一步划分为多个Block,其典型大小为64KB。Blocks动态映射到Chunk中来实现按需分配。Chunk至Block的映射信息由ChunkServer自行管理和保存,除数据Block之外,每个Chunk还包含一些额外Block用来实现WriteAheadLog。我们也将本地映射元数据全部缓存在ChunkServer的内存中,使得用户数据的I/O访问能够全速推进。
下面我们详细介绍PolarFS的各个系统组件。
★libpfs
libpfs是一个轻量级的用户空间库,PolarFS采用了编译到数据库的形态,替换标准的文件系统接口,这使得全部的I/O路径都在用户空间中,数据处理在用户空间完成,尽可能减少数据的拷贝。这样做的目的是避免传统文件系统从内核空间至用户空间的消息传递开销,尤其数据拷贝的开销。这对于低延迟硬件的性能发挥尤为重要。
其提供了类Posix的文件系统接口(见下表),因而付出很小的修改代价即可完成数据库的用户空间化。
★PolarSwitch
PolarSwitch是部署在计算节点的Daemon,它负责I/O请求映射到具体的后端节点。数据库通过libpfs将I/O请求发送给PolarSwitch,每个请求包含了数据库实例所在的VolumeID、起始偏移和长度。PolarSwitch将其划分为对应的一到多个Chunk,并将请求发往Chunk所属的ChunkServer完成访问。
★ChunkServer
ChunkServer部署在后端存储节点上。一个存储节点可以有多个ChunkServer。每个ChunkServer绑定到一个CPU核,并管理一个独立的NVMeSSD盘,因此ChunkServer之间没有资源争抢。
ChunkServer负责Chunk内的资源映射和读写。每个Chunk都包括一个WAL,对Chunk的修改会先进Log再修改,保证数据的原子性和持久性。ChunkServer使用了DXPointSSD和普通NVMeSSD混合型WALbuffer,Log会优先存放到更快的DXPointSSD中。
ChunkServer会复制写请求到对应的Chunk副本(其他ChunkServer)上,我们通过自己定义的ParallelRaft一致性协议来保证Chunk副本之间在各类故障状况下数据正确同步和保障已Commit数据不丢失。
★PolarCtrl
PolarCtrl是PolarFS集群的控制核心。其主要职责包括:
监控ChunkServer的健康状况,确定哪些ChunkServer有权属于PolarFS集群;
Volume创建及Chunk的布局管理(即Chunk分配到哪些ChunkServer);
Volume至Chunk的元数据信息维护;
向PolarSwitch推送元信息缓存更新;
监控Volume和Chunk的I/O性能;
周期性地发起副本内和副本间的CRC数据校验。
PolarCtrl使用了一个关系数据库云服务用于管理上述metadata。
中心统控,局部自治的分布式管理分布式系统的设计有两种范式:中心化和去中心化。中心化的系统包括GFS和HDFS,其包含单中心点,负责维护元数据和集群成员管理。这样的系统实现相对简单,但从可用性和扩展性的角度而言,单中心可能会成为全系统的瓶颈。去中心化的系统如Dynamo完全相反,节点间是对等关系,元数据被切分并冗余放置在所有的节点上。去中心化的系统被认为更可靠,但设计和实现会更复杂。
PolarFS在这两种设计方式上做了一定权衡,采用了中心统控,局部自治的方式:PolarCtrl是一个中心化的master,其负责管理任务,如资源管理和处理控制平面的请求如创建Volume。ChunkServer负责Chunk内部映射的管理,以及Chunk间的数据复制。当ChunkServer彼此交互时,通过ParallelRaft一致性协议来处理故障并自动发起Leader选举,这个过程无需PolarCtrl参与。
PolarCtrl服务由于不直接处理高并发的I/O流,其状态更新频率相对较低,因而可采用典型的多节点高可用架构来提供PolarCtrl服务的持续性,当PolarCtrl因崩溃恢复出现的短暂故障间隙,由于PolarSwitch的缓存以及ChunkServer数据平面的局部元数据管理和自主leader选举的缘故,PolarFS能够尽量保证绝大部分数据I/O仍能正常服务。
I/O流程下面我们通过一个I/O的处理来说明各组件的互动过程。
PolarFS执行写I/O请求的过程如上图所示:
POLARDB通过libpfs发送一个写请求,经由ringbuffer发送到PolarSwitch。
PolarSwitch根据本地缓存的元数据,将该请求发送至对应Chunk的主节点。
新写请求到达后,主节点上的RDMANIC将写请求放到一个提前分好的buffer中,并将该请求项加到请求队列。一个I/O轮询线程不断轮询这个请求队列,一旦发现新请求到来,它就立即开始处理。
请求通过SPDK写到硬盘的日志block,并通过RDMA发向副本节点。这些操作都是异步调用,数据传输是并发进行的。
当副本请求到达副本节点,副本节点的RDMANIC同样会将其放到预分buffer中并加入到复制队列。
副本节点上的I/O轮询线程被触发,请求通过SPDK异步地写入Chunk的日志。
当副本节点的写请求成功回调后,会通过RDMA向主节点发送一个应答响应。
主节点收到一个复制组中大多数节点的成功返回后,主节点通过SPDK将写请求应用到数据块上。
随后,主节点通过RDMA向PolarSwitch返回。
PolarSwitch标记请求成功并通知上层的POLARDB。
数据副本一致性模型
ParallelRaft协议设计动机
一个产品级别的分布式存储系统需要确保所有提交的修改在各种边界情况下均不丢失。PolarFS在Chunk层面引入一致性协议来保证文件系统数据的可靠性和一致性。设计之初,从工程实现的成熟度考虑,我们选择了Raft算法,但对于我们构建的超低延迟的高并发存储系统而言,很快就遇到了一些坑。
Raft为了简单性和协议的可理解性,采用了高度串行化的设计。日志在leader和follower上都不允许有空洞,其意味着所有log项会按照顺序被follower确认、被leader提交并apply到所有副本上。因此当有大量并发写请求执行时,会按顺序依次提交。处于队列尾部的请求,必需等待所有之前的请求已被持久化到硬盘并返回后才会被提交和返回,这增加了平均延迟也降低了吞吐量。我们发现当并发I/O深度从8升到时,I/O吞吐量会降低一半。
Raft并不十分适用于多连接的在高并发环境。实际中leader和follower使用多条连接来传送日志很常见。当一个链接阻塞或者变慢,log项到达follower的顺序就会变乱,也即是说,一些次序靠后的log项会比次序靠前的log项先到。但是,Raft的follower必需按次序接收log项,这就意味着这些log项即使被记录到硬盘也只能等到前面所有缺失的log项到达后才能返回。并且假如大多数follower都因一些缺失的项被阻塞时,leader也会出现卡顿。我们希望有一个更好的协议可以适应这样的情形。
由于PolarFS之上运行的是Database事务处理系统,它们在数据库逻辑层面的并行控制算法使得事务可以交错或乱序执行的同时还能生成可串行化的结果。这些应用天然就需要容忍标准存储语义可能出现的I/O乱序完成情况,并由应用自身进一步保证数据一致性。因此我们可以利用这一特点,在PolarFS中依照存储语义放开Raft一致性协议的某些约束,从而获得一种更适合高I/O并发能力发挥的一致性协议。
我们在Raft的基础上,提供了一种改进型的一致性协议ParallelRaft。ParallelRaft的结构与Raft一致,只是放开了其严格有序化的约束。
乱序日志复制
Raft通过两个方面保障串行化:
当leader发送一个log项给follower,follower需要返回ack来确认该log项已经被收到且记录,同时也隐式地表明所有之前的log项均已收到且保存完毕。
当leader提交一个log项并广播至所有follower,它也同时确认了所有之前的log项都已被提交了。ParallelRaft打破了这两个限制,并让这些步骤可乱序执行。
因此,ParallelRaft与Raft最根本的不同在于,当某个entry提交成功时,并不意味着之前的所有entry都已成功提交。因此我们需要保证:
在这种情况下,单个存储的状态不会违反存储语义的正确性;
所有已提交的entry在各种边界情况下均不会丢失;
有了这两点,结合数据库或其他应用普遍存在的对存储I/O乱序完成的默认容忍能力,就可以保证它们在PolarFS上的正常运转,并获得PolarFS提供的数据可靠性。
ParallelRaft的乱序执行遵循如下原则:
当写入的Log项彼此的存储范围没有交叠,那么就认为Log项无冲突可以乱序执行;
否则,冲突的Log项将按照写入次序依次完成。
容易知道,依照此原则完成的I/O不会违反传统存储语义的正确性。
接下来我们来看log的ack-