编辑推荐: |
本文根据论文提供的信息对Taurus的设计进行简单介绍,希望对您的学习有所帮助。
本文来自于51CTO博客,由火龙果软件Alice编辑、推荐。 |
|
前言
多年以来,各云数据库厂商基于传统的单体数据库为客户提供了云上的数据库服务,满足了客户一定层次的需求:服务高可用、数据高可靠、完善的运营。然而,传统单体数据库架构存在严重的短板,无法满足客户更高层次的需求,所以近年来头部云厂商开始提供“云原生”数据库服务,克服了传统单体数据库的一些短板。
云原生数据库的主要特点是:
更好的弹性:占用空间随数据大小自动扩缩容,甚至可以做到计算资源随工作负载自动扩缩容,突破单机瓶颈。
更强劲的性能:云原生架构相对传统单机数据库,性能天花板更高。
更高的可用性:数据库崩溃恢复的时间比传统单机数据库更短,可用性得到提升。
更低的成本:添加额外的计算节点不需要增加额外的全量数据,节省成本。
所付即所得:客户所花的费用与实际消耗的计算资源、存储空间相关。
目前,国外的云原生数据库有:AWS Aurora、微软Socrates,都已经商业化。
国内的云原生数据库有:阿里云POLARDB、腾讯云CynosDB、华为Taurus。其中POLARDB、CynosDB都已经商业化很长时间,Taurus目前应该是还未商业化。
但是最近Taurus发表了一篇Paper《Taurus Database: How to be Fast,
Available, and Frugal in the Cloud 》,详细介绍了它的架构特点、设计理念。本文对Paper中的内容进行一下概括。
背景
上述几个云原生数据库都是采用计算与存储分离的架构,存储层可以水平扩展支持上百TB的数据,计算层也可以垂直扩展以及水平扩展(添加只读实例)。但是如何做到计存分离,各自分法都有不同之处。
POLARDB通过将Innodb的log和page存放到类POSIX接口的分布式文件系统(PolarFs)来实现计存分离。这种做法看似很美好、对Innodb的侵入非常小,但是却有一些严重的问题,Taurus论文中有提及。具体来说,大量刷脏的时候,持久化page的网络流量对于计算层、存储层都是一个很大的挑战,因为page流量是单纯log流量的几倍到几十倍不等,具体取决于用户的工作负载。另外,page刷脏会抢占log的持久化需要的资源(网络带宽、IO带宽),增大log持久化的延时,继而增大事务提交的延时。另外,由于PolarFs的基于raft(准确说是ParallelRaft)的数据复制方式,导致事务提交的路径上至少需要两跳网络传输,这个架构导致其需要在计算节点、存储节点都需要引入RDMA来减少网络带来的rt。
另外,我觉得POLARDB在通用分布式文件系统PolarFs上提供云原生数据库服务,多多少少在性能上、功能实现上被PolarFs限制,除非PolarFs针对db场景做定制,比如需要支持保存多个版本的page。
其实,计存分离的最优做法是采用“log is database”的理念,只需要把log写到存储层,由存储层负责重放log、回写page并尽量减少写放大。将刷脏这个操作从计算层剔除之后,可以降低计算节点的网络开销。Aurora首先采用这种做法,后续的Socrates、CynosDB、Taurus也均采用这个做法。
Aurora将db的数据(也即是所有page)分成若干个10GB大小的shard,相应的log也随data一起保存在shard中。每个shard有6个副本,采用N=6,W=4,R=3的策略,事务提交时需要等到log在至少4个副本持久化之后才能完成提交。Aurora的log持久化、page读取都只需要一跳网络传输。
Socrates也是采用“log is database”的理念,但是它单独了一个log层用于快速持久化log(具体实现不详),避免受到重放log、回写page的影响。另外,page
svr层从log层拉取log进行重放、回写page,并向计算节点提供读取page的服务。但是page
svr层只将部分page缓存在本地,全量的page在额外的冷备层。所以Socrates的读请求有可能在page
svr层本地无法命中,进而从冷备层获取page。
CynosDB也是采用“log is database”的理念,从公开资料来看,存储层为计算节点提供了Log
IO接口与Page IO接口,前者负责持久化log,后者负责page的读取。
Taurus也是采用“log is database”的理念,存储层分为Log Store、Page
Store两个模块,前者负责持久化log,后者负责page的读取。log持久化、page读取都只需要一跳网络传输。
本文根据论文提供的信息对Taurus的设计进行简单介绍。
总体架构
Taurus包含四个主要的模块:SQL前端(修改过的MySQL)、嵌入到SQL前端的SAL library(
Storage Abstraction Layer)、Log Stores和Page Stores。其中SQL前端和SAL组成了计算层,Log
Stores和Page Stores组成了存储层。如下图所示:
SQL前端
SQL前端是基于MySQL 8.0的修改版,包含一个提供读写服务的Master实例,以及若干个提供只读服务的RO实例。Master实例提交事务时会产生redo
log,这些log内容描述了事务对page所做的改动。Master会将log发送给Log Store。另外,Master也会被把log分发给Page
Stores。其中,写log、读取page的逻辑都封装在了SAL中,为MySQL屏蔽了存储层的复杂性。
Log Store
Log Store负责持久化log,一旦事务的所有log持久化之后,那么SQL前端即可通知client事务已经提交完成。另外,Log
Store负责为RO实例提供log内容,后者需要重放log来更新自己buffer pool中的page。Master实例周期性地告知RO实例最新log的位置,以便RO实例可以读取到最新的log。
Log Store提供了一个关键的抽象对象,PLog,每个PLog都是大小有限(最大64MB)、append-only的对象,并强同步复制到多个Log
Store(进程/机器)上。某个db实例的log保存在多个的PLog中,这些PLog组成的有序列表作为元信息保存在一个单独的metadata
PLog中,这个特殊PLog在db初始化的时候就会创建,它的读写模式与普通PLog一致。
Log Store机器以集群的方式组织,典型的部署包含几百个Log Store 机器。当需要创建PLog时,集群管控平台会选择3个Log
Store机器(或称为节点)来负责PLog的3个副本。集群管控平台会给PLog分配24字节的ID唯一标识该PLog。当PLog的大小达到64MB之后,会创建一个新的PLog,选择新PLog的3个Log
Store节点时会考虑集群中Log Store节点的空闲空间以及工作负载。
规定PLog的大小上限为64MB是为了能将log写入负载均衡打散到不同的Log Store上,同时又避免过度碎片化。因为如果PLog太大会导致Log
Store之间负载不均衡,可能存在某个db写入流量很大,短时间内集中在某3个Log Store中。反过来,如果PLog太小,虽然可以将写入负载在集群中快速滚动起来,但是过度碎片化会导致PLog的频繁创建、删除,导致元信息的膨胀,另外,会对log写入、log读取造成一定的性能抖动。
PLog的replication采用N=3,W=3, R=1的leaderless replication策略。当SAL需要将一批log写入PLog时,只有全部3个PLog副本都成功写入之后,对它的写入操作才算成功。如果其中一个Log
Store无法响应,那么对该PLog的写入操作就认为是失败的,并且不会再有写入请求发送给该PLog,取而代之的是由另外3个Log
Store组成的新PLog。这个做法可以保证log写入必定是可以成功完成的(论文中认为写log的可用性为100%),只要集群中至少还有3个健康的Log
Store。从PLog读取log的操作可用性也非常高,只要有1个PLog的副本存在,读取log的操作即可成功。
Page Store
Page Store负责为SQL前端提供page读取服务,每个Page Store处理属于不同db的若干个slice(Taurus将db的所有page划分为若干个10GB大小的分片,称为slice,每个slice具有唯一的id),同时接收这些slice相关的log并持久化。Page
Store具有构造SQL前端所需要的、任意版本的page的能力。
Page Store向SAL提供了主要的4个API:
(1) WriteLogs:用于传输一批log到Page Store
(2) ReadPage:用于读取指定版本的page,版本由page ID以及LSN共同标识。
(3) SetRecycleLSN:用于设定一个LSN,小于该LSN的旧版本页面都可安全回收 。该LSN是所有SQL前端可能会请求的、最旧的page的LSN。
(4) GetPersistentLSN:返回该Page Store持久化的、最大的、没有空洞的LSN。
当SQL前端需要读取某个page时,SAL会指定slice ID、page ID以及page的版本来调用ReadPage接口。Page
Store必须有能力提供page的旧版本数据,因为RO与Master之间的视图会有一定的lag,后文会讲到。
由于保留旧版本的页面需要占用资源(硬盘空间、内存),所以SAL会周期性的调用SetRecycleLSN来告知Page
Store可以回收的LSN位点。
前文有讲到SAL会将一批log写入到PLog成功之后,事务即可提交。同时,当写入Plog成功之后,SAL还会将该批log按照所属slice进行切割划分,copy到相应的slice
write buffer中。当某个slice的 write buffer满了,或者一定超时之后,SAL会调用WriteLogs接口将该buffer写入到该slice所属的Page
Stores中。
Slice的replication采用N=3,W=1, R=3的leaderless replication策略,只要SAL等到了其中1个slice副本的响应,那么对应的write
buffer即可释放并重用。某个slice副本收到的最大的LSN叫做flush LSN。由于slice副本可能会缺少某些buffer形成空洞,所以每个buffer都有slice
ID和序列号标识,Page Store可以根据这序列号来发现遗漏的buffer,也即是判断是否有空洞。某个slice副本持久化的、最大的、没有空洞的LSN叫做persistent
LSN。Slice的副本之间会通过gossip协议补齐自身的空洞。
对于page读请求,Taurus也类似Aurora做了优化,无需读取3个slice副本,正常情况下只需读取1个slice副本即可获取需要的page内容,因为SAL会维护每个slice副本的persistent
LSN信息,进而知道哪个slice副本可以满足page读请求。SAL是通过显示调用GetPersistentLSN接口查询该信息,或者通过WriteLogs和ReadPage接口捎带该信息。
但是,毕竟SAL会维护每个slice副本的persistent LSN不是实时的,所以有些情况SAL知道所有3个副本都不满足page读请求,也会发送ReadPage请求给其中1个副本。那Page
Store如何判断自身能否满足该请求呢?Page Store本地最新版本的page也可能会比SAL期望的要旧,怎么识别这种情况?Taurus维护了发送到每个slice最后一个log的sent
LSN,ReadPage请求会带上sent LSN,如果Page Store没有接收到小于等于sent
LSN的所有log,那么它便无法满足该请求,返回特定错误码,SAL接着尝试下一个Page Store,直到找到一个满足条件的Page
Store。
正常来说,SAL会最多访问3次Page Store就能获取到需要的page,但是如果结合故障恢复的场景,可能会需要请求更多次。
另外,删除Log Store中无用的PLog也依赖于slice的persistent LSN。SAL会综合每个slice的每个副本的persistent
LSN,以那些还有log未达成3副本的slices中的最小persistent LSN作为db persistent
LSN。同时,SAL也知道每个PLog的LSN范围,所以进而可以知道哪些PLog的LSN小于db persistent
LSN,可以安全删除这类PLog。
另外,SAL会周期性的将db persistent LSN记录起来(应该是记录到metadata
PLog中)用于db的故障恢复用途。后文会讲到。
SAL
SAL( Storage Abstraction Layer)是一个链接到SQL前端的library,将远程存储、数据分片、故障恢复、主从复制的复杂性从SQL前端隔离开来。
前面提到过,写log、读取page的逻辑都封装在了SAL中。除此之外,SAL还负责创建、管理、销毁Page
Stores中的slices,负责按照一定规则映射pages到slices。当某个db刚创建并初始化或者进行扩展时,SAL会选择3个Page
Stores并在上面创建slice。
SAL另一个重要的功能是,处理SQL前端的故障恢复、协助Page Store的故障恢复,后文会讲到。
这些模块之间的交互流程(以写log为例)如下所示:
故障恢复
Log Stores和Page Stores的节点可用性由一个Recovery服务监控,如果检测到某个Store节点故障,那么这个故障首先会被认为是短暂故障,并且还会持续监控该Store节点。如果该节点长时间都不可用(15min),那么会被认为是长期故障。Taurus认为15min这个阈值已经足够小,因为15min内虽然数据只有2个副本,但是也不会破坏数据的持久性保证。
Log Store恢复
Log Store的故障很容易处理并恢复。如前所述,只要某个Log Store不可用,该Log Store上的PLog都会停止接收写入请求变成read-only。所以,如果只是短暂故障,不需要任何Recovery流程,因为所有数据仍然是3副本。如果是升级为长期故障,那么故障的Log
Store节点会被从集群剔除,并且其上的PLog副本会在集群中的其它节点中重建,从其它2个可用副本之一复制全量数据来建立新的副本。
疑问:PLog被认为变成read-only之后,如何fencing意外的写入请求?Recovery服务如何与SAL一起联动?
Page Store恢复
Page Store的故障恢复比较复杂,论文中讲了Page Store故障恢复的四种场景。
场景1
某个Page Store节点从短暂故障恢复上线后,它会开始运行gossip协议,从该从它负责的slice的其它副本中获取缺少的日志,如下图所示:
场景2
当Page Store节点发生长期故障,集群管控平台会将故障的节点剔除,并重新将其上的slice副本分布到集群剩余的Page
Store节点中进行重建。某个处于重建状态的副本刚开始的时候是没有任何数据的,它可以立马接收WriteLogs请求,但是由于它还没有任何page内容,也没有过去一段时间的log内容,所有它还无法提供读请求服务。接着,它会请求其它slice副本中的其中一个,获取所有page的最新版本。一旦接收到所有的page之后,重建后的slice副本即可提供读服务。
疑问:那新的slice副本重建完毕之后,如果有RO实例请求旧版本的page,该副本理论上会无法满足这种请求,因为新重建的slice副本不包含旧版本的page内容。所以,Taurus重建slice副本时,应该是用正常slice副本的整个快照来重建新副本?
场景3
该场景是短暂故障和长期故障结合的场景。
该场景下,两个副本遭遇短暂故障没有收到某个log,唯一收到log的副本又因为长期故障被重建,导致没有任何副本包含前述的log。这种情况如何恢复呢?答案是SAL发现某个slice副本的persistent
LSN发生回退之后,会从Log Store读取log并重新发送给相应的Page Stores。其中,从Log
Store读取log的起始LSN位点是slice三副本中最小的persistent LSN。如下图所示:
但是,由SLA去探测某个副本persistent LSN回退这种做法并不能充分的保证数据不丢,毕竟SLA本身也会重启丢失之前维护的各slice副本的persistent
LSN信息 。另外,也存在下文的场景4,通过这种做法是无法保证数据不丢的。
场景4
该场景是短暂故障和长期故障结合的场景,但是更复杂一些。
当一系列故障发生之后,由于三个副本内都存在空洞,导致某slice的persistent LSN一直无法推进,而且也没有发生slice副本的persistent
LSN回退的情况。这种情况如何恢复呢?答案是SAL会周期性的获取slice的persistent LSN和flush
LSN,如果发现persistent LSN小于flush LSN而且不会推进,那么SAL会查询每个副本的空洞的LSN范围。如果SAL发现某些log在所有3个副本中都不存在,那么它会从Log
Store读取缺失的log并重新发送给相应的Page Stores。如下图所示:
从Page Store相关的恢复逻辑可以看到SAL的逻辑有点过于复杂,根因是因为SAL向Page
Store写入日志时,采用了分发复制N=3,W=1的leaderless replication手段,导致某些slice副本内产生空洞,或者persistent
LSN在重建之后发生回退。或许采用leader-follower replication手段会更合适一些,这样可以大大降低SAL层的复杂度,虽然多一跳网络rt,但是因为写入log到Page
Store的操作不在事务提交的路径上,rt高一点问题不大,只要吞吐量能够匹配redo log产生的速度即可。
计算层恢复
计算层恢复涉及2个主要的步骤:1)SAL恢复 和 2)SQL前端恢复。
首先是SAL恢复,这个恢复过程的目标是确保db的slices都含有计算层崩溃之前已经写入到Log
Store的所有log。SAL恢复的逻辑也很简单,从前文提到的db persistent LSN开始从Log
Store读取log,并把slice缺失的log重新写入到相应的Page Stores。
SAL恢复之后,SQL前端即可接收新的请求。与此同时,SQL前端会回滚掉未提交活跃事务的改动。
我认为SAL恢复流程中还缺少一步,也是最开始要执行的流程:找到上次最后使用的PLog的有效的结束LSN,但是论文中未提及这一点。因为该PLog的末尾可能包含大量未达成3副本的log,SAL恢复的时候,要找到PLog的3副本中最小的结束LSN,作为该PLOG的结束LSN,并且清空多余的log,或者将该Plog设为read-only新建一个Plog用于后续的log写入。
只读实例
Taurus的只读实例架构如下图所示:
当Master将log写入到Log Store之后,RO实例会从Master收到消息,消息包含log的位置(推测是PLog
ID)、slices列表的变动、slice的persistent LSN。接着,RO实例从Log Stores中读取log用于更新buffer
pool中的page。最后,如果RO实例需要读取page,那么它也会从Page Store中获取指定版本的page。
由于PLog中末尾的log可能是还未被Master确认持久化成功的log,所以,需要避免RO读取到并重放这些log,所以我推测上述消息中的LSN是用于该目的,告知RO最多只能够重放到该LSN位点的log,后续的log暂时不能重放。
主从物理复制的常规做法是在Master与RO之间传输log内容,然而Taurus没有这样做,因为这样做会使得Master角色成为瓶颈,Master不但需要消耗CPU和内存来传输log内容,而且网卡带宽会成为瓶颈。对于每秒产生100MB的log的写密集工作负载来说,如果有15个RO实例,那么光是传输log内容,Master就需要发送12Gpbs的数据。所以Taurus的主从复制类似于Socrates,让RO实例从Log
Store获取log内容。
虽然这种做法提高了Log Store的网卡带宽消耗,但是由于Log Store服务是以集群的方式管理,可以很轻松水平扩展,而且负载基本是均衡的,所以这种做法不会对Log
Store造成挑战。
由于Master与RO共享存储,所以page会被Master修改并且与RO之间没有任何同步互斥,所以如何让RO看到一致的数据是一个重要挑战。
首先,要保证物理页面一致,也即是B+树的一致。比如,当某个线程正在分裂某个B+树的page,这时候的改动会涉及到多个page,同时,有也在遍历这一颗树其他线程,那么确保该线程看到一致的结果,也即是分裂操作看起来像是一个原子操作。在Master上,这种一致性可以通过对pages加锁来实现。但是,如果在Master和RO之间也协调类似的锁,那么造成的开销是非常大的。为了避免这样的开销,Master以group为单位产生log,并且保证group的边界是一致的,同时RO读取和重放log的时候,也是以group为原子粒度,通过这种方式保证RO看到的B+树是一致的。
其次,要保证事务隔离级别相关的一致性。Taurus通过在事务提交之后,写入一条commit log。在RO解析该log之后,就可以更新它的活跃事务列表,产生新的read
view,来保证事务隔离的一致性。
当然,RO与Master建立主从复制关系的时候,Master需要将当时的存量的活跃事务告知RO。
RO在重放log的过程中,持续推进它的visible LSN,但是RO在推进visible LSN时,需要保证不会超过slice的persistent
LSN,避免出现某个Page Store无法满足RO的读请求的情况。当只读事务需要读取page时,需要记录当前的visible
LSN,记为TV-LSN。不同的事务有不同的TV-LSN。因为RO在不断推进visible LSN,所以事务的TV-LSN可能会落后。RO实例会持久维护最小的TV-LSN并告知Master。Master收集不同RO的LSN并取最小值,设置到所有slice中作为新的recycle
LSN。小于该LSN的旧版本页面才可以安全删除。
Page Store设计
论文中专门讲了Page Store的一些设计要点。首先,slice 副本之间的log重放、page回写都是独立进行的,不需要在副本间replicate构造page产生的数据,减少了网络带宽开销。其次,磁盘IO都是append-only,提升性能并减少设备损耗。第三,构造新page需要的数据(log+基准page)在内存中有进行cache,提高构造page的速度。另外,论文中还讲了如何在构造、回写新page的过程中尽量降低写放大,这一点也是非常重要。
其中关键一个数据结构是Log Directory,是一个无锁hash表,key是page ID,value是相应page的一系列log的存储位置、各版本page的存储位置。该数据结构会被周期性的持久化。Page
Store的工作流程如下图所示,细节此处不再赘述:
我猜测存储位置指的是在append-only文件中的offset,这个文件貌似叫做slice_log,论文中并没有明确的说明。我猜测slice收到的log内容、构造的page内容都是顺序写入在slice_log中。通过append-only的方式将原本回写page的随机IO转换成了顺序IO。猜测也把多个slice的log内容、page内容放到了同一个slice_log,避免多个slice直接各写各的数据形成随机IO。同时,在Log
Directory维护了page的存储位置,可以快速在slice_log中读取到需要的page。但是,这样的做法如何实现冗余数据的回收呢,比如需要删除掉旧版本的page,应该如何操作?采用compaction的方式将slice_log内的冗余数据删掉?但是在Log
Directory中维护的位置是不是都要相应调整,带来的写放大是否可以接受?
|