FoundationDB源码阅读--备份功能的需求和设计
前言
作为数据库系统的重要组成部分,备份和恢复是用于灾难恢复、可靠性、审计和法规遵从性目的的常用技术。当前的FoundationDB备份模块消耗了集群大约一半的写入带宽,导致存储服务器(SS)之间的写入倾斜,增加了存储空间使用率,并导致数据不均衡。新的备份模块旨在将HA集群的写入带宽提高一倍(旧的DR集群仍然需要老式的备份模块)。
背景
FoundationDB备份模块连续扫描数据库的键值空间,将键值对和版本的修改保存到对象存储的range文件和log文件中。具体来说,修改变日志是在CommitProxy生成的,与常规修改一起写入事务日志。在生产集群(如CK集群)中,备份模块始终处于打开状态,这意味着每个修改都会写入两次事务日志,消耗大约一半的写入带宽和大约40%的CommitProxy CPU时间。
术语
名称 | 含义 |
Blob storage | 用于存储非结构化数据的对象存储,备份文件被编码成二进制格式保存到对象存储中,比如S3 |
Version | FoundationDB不断生成不断增加的版本号,并使用版本来决定修改的顺序。版本号通常每秒前进一百万。要将FoundationDB集群还原到指定的日期和时间,恢复模块首先将日期和时间转换为相应的版本号,然后将集群恢复到相应的版本 |
Epoch | 事务模块的一个时代,类似于Raft协议中的term。当事务模块中有一个组件故障时,FoundationDB会自动启动恢复,并创建一个新的时代,同时epoch递增 |
Backup worker | FoundationDB集群中的一个新角色,负责从LS模块中拉取WAL日志,并保存到对象存储中 |
Tag | 是修改操作的一个简短标识,包括一个位置(int8_t表示数据中心ID,负数表示特殊的系统位置)和一个ID(int16_t),主要目的是用一个小型数据结构来表示IP地址或SS的UUID,以减少字节的消耗,因为Tag与每个修改相关,所以在内存和磁盘上都会保存 |
Tag partitioned log system | FoundationDB的WAL日志是一个tag分区的日志模块,每个修改都会被分配一系列的tags |
Log router tag | 是一个特殊的系统标记,例如,-2:0,其中Location-2表示LogRouter标记,0表示ID。如果附加到某次修改,最初该标记表示修改应发送到远端LogRouter。在新的备份模块中,我们将此标记重新用于backup workers,以接收多个分区流中的所有修改 |
Restorable version | 备份可以恢复到的版本。如果版本v的整个key space和修改日志都是完整的,则版本v就是一个可恢复的版本 |
设计思路
用一句话总结:新的备份模块引入了一个新角色,backup worker,从LS中提取并保存修改,从而消除了将修改日志保存到数据库中的负担。
旧的备份模块将修改日志写入数据库本身,从而多占用了一倍的写入带宽。备份agent稍后从数据库中获取修改日志,将其上传到对象存储,然后从数据库中删除修改日志。
新版的备份功能将修改日志直接从FoundationDB集群保存到对象存储中,启用备份时,该集群将使数据库的写入带宽几乎增加一倍。在FoundationDB中,每个修改已经有一个LogRouter tag,因此新版本备份功能的想法是分别备份每个LogRouter tag的数据(即,将修改日志保存到多个分区日志中)。在恢复期间,这些分区的修改日志组合在一起,形成一个连续的修改日志流。
设计考虑
设计考虑1:backup worker是否应该作为LS模块的一部分?还是独立出来?有两个可选的设计方案:
1、Backup worker和LS模块分开,master的故障恢复不影响backup worker工作,backup worker的状态监控和创建由cluster controller负责。
(1)这种设计方案的优点是backup workers故障不会触发master故障恢复。
(2)这种设计的缺点是backup workers需要监控master的故障恢复,尤其是配置变更。因为LogRouters的数量在master故障恢复后可能会发生变化,这就需要复杂的变更逻辑。更复杂的逻辑是backup workers需要持续监控master的状态,并且小心处理epoch变化时的版本边界,因为tag的数量可能发生变化。
2、Backup worker作为LS模块的一部分,master故障恢复时一并创建backup workers,比如根据LogRouters数量创建对应数量的backup workers。
(1)这种设计的优点是创建以及backup workers和LogRouters之间的映射简单,比如一个tag对应一个backup worker。
(2)这种设计的缺点是backup workers和master强绑定,任何一个backup workers故障都会导致master走故障恢复逻辑。
为了简化处理逻辑,我们最终采用了第二种方案。
设计考虑2:backup workers部署在主DC(data center)还是从DC?把backup workers部署在主DC可以支持任意配置,比如单个DC或者多个DC。
部署在从DC可以减少主DC中LS的负载。由于从DC的LogRouters已经从主DC拉取修改日志,backup workers只需要从LogRouters获取修改日志即可。
最终我们选择将backup workers部署在主DC,因为不是所有的集群都进行多DC配置,我们需要支持所有类型的部署。
上述设计依赖的前提
1、对象存储有足够的写入带宽和足够的存储空间存储backup workers的日志文件。
2、FoundationDB集群有足够的无状态进程运行backup workers,这些进程有足够的内存缓存10秒内提交的数据。
系统组件
Backup Worker:新版本备份功能引入的新角色。backup worker是运行在FoundationDB集群内的一个fdbserver进程,负责从LS拉取修改日志,并存储到对象存储中。
Master:负责协调FoundationDB的TS模块从一个时代转到下一个时代,在Master故障恢复期间需要创建backup workers。
Transaction Logs(TLogs):事务日志负责将修改持久化到磁盘,以减少提交延迟。LS模块按照版本号顺序从commit proxy获取事务日志,只有当事务日志被fsync到磁盘时才会回应commit proxy。SS从LS拉取事务日志。一旦SS将事务日志持久化完成,SS就会将已完成的事务日志从LS中删除。
CommitProxy:负责事务提交,跟踪哪个SS负责哪个key range。老版本的备份功能中,CommitProxy负责将事务日志攒批,并写到数据库中。
GrvProxy:负责提供读版本。
系统概述
新版本备份功能执行步骤如下:
1、通过命令行工具fdbbackup发起新的备份请求;
2、FDB集群收到请求,并将请求注册到数据库中(TaskBucket和系统key);
3、Backup workers监控系统key的修改,将请求注册到自己的内部队列中,并开始记录请求涉及key range的修改,同时由TaskBucket调度的backup agent开始对key range产生快照;
4、backup workers定期将事务日志上传到对象存储中,并将进度保存到数据库中。
5、当backup wokers保存的版本大于完整快照的最终版本时,这个备份的版本是可恢复的,如果在请求中设置了stop on restorable标志,则备份将停止。
新的备份功能主要由四部分组成:
(1)backup workers
(2)备用的backup workers
(3)分区日志系统的扩展标签,用于支持伪标签
(4)与现有的TaskBucket集成
(5)与恢复模块集成
Backup workers
backup workers内部维护了一个消息缓存,用于保存从LS拉取,但还未写到对象存储的事务日志。backup workers定期解析消息缓存,查看哪些事务日志和指定的key range是相关的,然后将这部分相关的事务日志保存到对象存储中,保存成功后,清除内部维护的消息缓存,并将进度更新到数据库中,以便出现故障后,新的backup workers可以从之前备份的版本继续往后备份。
backup workers有两种模式:非工作模式和工作模式。当没有备份任务时,backup workers处于非工作模式,只是简单的从proxy获取最近的提交版本号,删除LS中的事务日志。当有备份任务后,backup workers切换到工作模式,开始从LS拉取事务日志,并将事务日志保存到对象存储中。
在工作模式下,backup workers删除事务日志需要遵循严格递增的版本顺序。对于同一个tag,可能会有多个backup workers,每个backup workers负责不同的时代。这些backup workers必须协调他们删除事务日志的顺序,否则可能会导致数据丢失。backup workers之间的这种协调是通过推迟后一个时代的事务日志删除,而只允许最早时代的事务日志删除来实现的。最早的时代完成后,对应的backup workers会通知master,继续处理下一个时代的事务日志。
对于被替换的backup workers,有个潜在的问题是,backup workers最后一次删除LS中的事务日志可能会导致部分数据丢失。这是因为用于保存进度的事务在恢复期间可能会延迟。Master根据之前保存的进度为旧的时代创建了新的backup workers,然后事务提交成功,旧时代的backup workers在新的backup workers备份事务日志之前,将事务日志删除,导致数据丢失。这个问题的解决方案有两个:
(1)旧的backup workers知道自己被替换后,直接停止工作,不再更新进度;
(2)旧的backup workers跳过最后一次删除LS中事务日志的机会,因为下一个时代将删除比其进度更大的版本。因为第二种方法可以避免在新的时代重复工作,所以最终选择了第二种方法。
最后,支持多并发备份。每个backup worker跟踪当前备份任务,并将修改日志保存到同一批修改的相应备份容器中。
创建backup workers
backup workers在master恢复期间作为LS模块的一部分进行创建,Master根据LogRouters的数量创建对应数量的backup workers,在这个过程中master发送backup workers初始化请求,内容如下:
struct InitializeBackupRequest {
UID reqId;
LogEpoch epoch; // epoch this worker is recruited
LogEpoch backupEpoch; // epoch that this worker actually works on
Tag routerTag;
Version startVersion;
Optional<Version> endVersion; // Only present for unfinished old epoch
ReplyPromise<struct BackupInterface> reply;
… // additional methods elided
};
这里我们需要两个epoch:一个用于创建backup worker,一个用于备份。用于创建backup worker的epoch与LS的epoch一致,用于backup worker确认当前工作的epoch。backupEpoch用于保存进度,通常和创建backup worker的epoch一致,也可能比创建backup worker的epoch小一些,表示该backup worker正在为之前的epoch备份数据。这种情况下,backup worker完成任务后就退出了,并且不会触发master的故障恢复。这些机制可以通过如下协议实现:
1、backup worker将任务进度保存到数据库并将数据上传到对象存储后,会发送BackupWorkerDoneRequest给master;
2、master收到请求后,从LS中删除该worker,并更新oldestBackupEpoch;
3、master向backup worker回应消息,并向cluster controller注册新的LS;
4、backup worker收到master的响应后退出,系统中的其他backup worker从cluster controller获取新的事务日志。如果某个backup worker的backupEpoch等于oldestBackupEpoch,该backup worker需要停止从LS中删除事务日志。
引入oldestBackupEpoch是为了避免当存在旧epoch的backup worker时,新epoch的的backup worker删除事务日志,引起数据丢失。
分区日志系统的扩展标签,用于支持伪标签
分区日志系统的模型像一个FIFO队列,Proxies将修改日志放到队列,SS或LogRouters从队列取出事务日志。分区日志系统的消费者使用两个操作peek和pop来读取指定tag的事务日志,并从队列中删除事务日志。由于Proxy为某个事务日志分配了唯一的LogRouter标记,备份模块重用该标记以获得整个事务日志流。因此每个LogRouter标记有两个使用者,LogRouter和backup worker。
为了支持多个消费者同时使用LogRouter标记,peek和pop操作已扩展为支持伪标记。换句话说,每个LogRouter标记可以映射到多个伪标记。LogRouter和backup worker仍然使用LogRouter标记读取事务日志,但使用不同的伪标记从队列删除事务日志。只有在删除两个伪标记后,事务日志才真正的从队列中删除。
伪标记的引入为更多的使用场景提供了可能性。比如,可以使用伪标记实现变更流,新的消费者可以查看指定key range上的修改。
与现有的TaskBucket集成
我们努力保持操作接口与之前的备份模块一致。也就是说,新的备份模块像以前一样由客户端启动,并带有一个附加标志。FoundationDB集群接收备份请求,查看正在设置的标志,并使用新的备份模块生成修改日志。
系统默认不启用backup workers,当管理员第一次提交备份请求后,数据库执行配置变更,将backup_worker_enabled设置为1,开始启用backup workers。
管理员的备份请求可标识出使用的是旧版本备份还是新版本备份。可以通过fdbbackup工具的命令行选项-p或者--partitioned-log来指定。新版本备份功能的执行步骤如下:
1、管理员使用fdbbackup工具将需要备份的range写入到系统key中,比如\xff\x02/backupStarted。
2、所有的backup workers监控系统key \xff\x02/backupStarted。当发生改变时,开始拉取日志变更。
3、当所有的backup worker启动后,fdbbackup工具发起事务TS来启动所有或指定key range的备份。
和老版本的备份功能相比,上述步骤1和2是新增的,只有启用新的备份功能后才会触发。目的是没有任务时,backup workers可以工作在无任务模式。但是backup workers仍然需要不断的pop对应版本的事务日志,否则这些事务日志会一直保存在LS中。为了知道应该pop哪个版本,backup workers可以从GRV Proxy获取读版本号,因为这个读版本号必然是已经提交的,pop读版本号对应的事务日志是安全的。
Backup Submission Protocol
submitBackup()使用的协议,用于确保当前epoch的所有backup workers都已经开始记录日志变更:
1、调用submitBackup()后,task bucket(比如StartFullBackupTaskFunc)在系统key space中创建一个BackupConfig对象。
2、每个backup worker监控\xff\x02/backupStarted,并通知有新的backup任务。然后backup worker将新的任务添加到自己内部的队列。如果backupEpoch是当前的epoch,还会写入到BackupConfig的startedBackupWorkers中。在这些worker中,拥有-2:0标记的worker监控startedBackupWorkers,并在所有worker更新startedBackupWorkers后,设置allWorkerStarted标记。
3、Task bucket监控startedBackupWorkers的修改,并声明任务提交成功。
确定备份是否可恢复的协议
1、每个backup worker独立的将日志修改记录到负责备份的容器并在系统key space中更新进度。
2、当前epoch拥有-2:0标记的worker负责监控所有worker的进度。如果当前epoch是最小的备份epoch,表示之前没有备份过,该worker将使用worker中保存的最小版本更新BackupConfig对象中的latestBackupWorkerSavedVersion标记。
3、客户端调用describeBackup(),最终调用getLatestRestorableVersion从latestBackupWorkerSavedVersion获取值。如果该版本大于第一个快照的最后版本,表示这个备份是可恢复的。
暂停和恢复备份
暂停和恢复备份的命令和之前一致,只是实现方式有区别。这是因为在老版本的备份工具中,修改日志和range日志都由TaskBucket处理。TaskBucket是一个异步任务调度框架,将状态存储在FoundationDB中。因此,老版本的备份工具只是暂停和恢复TaskBucket。在新版本备份工具中,修改日志有backup worker生成。因此暂停或恢复备份需要告诉所有backup worker暂停或恢复从LS中提取日志。
1、管理员发起暂停或恢复备份请求,并更新TaskBucket和\xff\x02/backupPaused标记。
2、每个backup worker监控\xff\x02/backupPaused标记并通知变更。然后backup worker暂停或恢复从LS拉取日志。
备份容器更改
分区修改日志存储在plogs/XXXX/XXXX目录,名称格式是log,[startVersion],[endVersion],[UID],[N-of-M],[blockSize],其中M是分区总数,N可以是0~M-1。相反的,老版本修改日志保存在logs/XXXX/XXXX目录。
要恢复range的某个版本,需要确保该range内的所有分区日志都必须可用。恢复时,需要读取所有的分区日志,将来自不同日志的修改合并到一起,按(commit_version,subsequence)对排序,可以保证所有修改日志按照全局顺序组合在一起。注意,在老版本的备份文件中,没有subsequence,因为每个版本的修改在一个文件中按顺序序列化。
与恢复模块集成
如上所述,新的备份模块将日志拆分为多个分区。因此,恢复时必须验证具有恢复版本范围的所有分区的备份文件是否连续。这是可能的,因为每个日志文件名都有关于其分区号和分区总数的信息。
一旦恢复模块验证版本范围是连续的,恢复模块需要在不同的日志文件中过滤出重复的版本范围(日志连续性分析和重复数据消除逻辑都在备份容器抽象中实现)。给定的版本范围可能存储在多个修改日志文件中。之所以会发生这种情况,是因为backup worker已将文件上传成功,但是在更新进度之前发生故障。因此,新的epoch尝试再次备份此版本范围,生成相同的版本范围(尽管文件名不同)。
最后,恢复模块从所有分区加载同一版本的修改,然后在将这些修改应用于还原集群之前,按其subsequence的顺序合并这些修改。请注意,老版本恢复模块没有subsequence。因此,恢复老版本的备份需要为修改分配subsequence。
修改日志的顺序和完整性保证
备份模块必须生成日志文件,以便恢复模块能够以相同的顺序在备份集群上应用,并保证exactly once语义。
顺序保证
为了保证修改日志的顺序,每个修改日志都会存储Proxy提交时分配的commit_version和subsequence。恢复模块可以按顺序加载这些修改日志。
完整性保证
所有修改都应保存在日志文件中。我们不能允许备份中缺少任何修改。这是由下面讨论的容错性保证的。实际上,所有backup worker都会在数据库中检查其进度。恢复后,master将读取以前的检查点,并为任何缺少的版本范围创建新的backup worker。
备份文件格式
每个文件内容都是固定大小的block列表。每个block包含一个日志修改序列,每个日志的格式为type|kLen|vLen|Key|Value,其中type表示日志类型Set或Clear,kLen和vLen分别表示Key和Value的长度,Key和Value是实际序列化值,block末尾使用0xFF填充。
`<BlockHeader>`
`<Version_1><Subseq_1><Mutation1_len><Mutation1>`
`<Version_2><Subseq_2><Mutation2_len><Mutation2>`
`…`
`<Padding>
`