Lecture 10 - Cloud Replicated DB, Aurora
Aurora是一个高性能,高可靠的数据库。Aurora本身作为云基础设施一个组成部分而存在,同时又构建在Amazon自己的基础设施之上。
之所以要看这篇论文,有以下几个原因:
- 首先这是最近的来自于Amazon的一种非常成功的云服务,有很多Amazon的用户在使用它。Aurora以自己的方式展示了一个聪明设计所取得的巨大成果。从论文的表1显示的与一些其他数据库的性能比较可以看出,在处理事务的速度上,Aurora宣称比其他数据库快35倍。这个数字非常了不起了。
- 这篇论文同时也探索了在使用容错的,通用(General-Purpose)存储前提下,性能可以提升的极限。Amazon首先使用的是自己的通用存储,但是后来发现性能不好,然后就构建了完全是应用定制(Application-Specific)的存储,并且几乎是抛弃了通用存储。
- 论文中还有很多在云基础设施世界中重要的细节。
# 1. Aurora 的背景历史
因为这是Amazon认为它的云产品用户应该在Amazon基础设施之上构建的数据库,所以在讨论Aurora之前,我想花一点时间来回顾一下历史,究竟是什么导致了Aurora的产生?
# 1.1 EC2 以及存在的问题
最早的时候,Amazon提供的云产品是 EC2,它可以帮助用户在Amazon的机房里和Amazon的硬件上创建类似网站的应用。EC2的全称是Elastic Cloud 2。Amazon有装满了服务器的数据中心,并且会在每一个服务器上都运行VMM(Virtual Machine Monitor)。它会向它的用户出租虚拟机,而它的用户通常会租用多个虚拟机用来运行Web服务、数据库和任何其他需要运行的服务。所以,在一个服务器上,有一个VMM,还有一些EC2实例,其中每一个实例都出租给不同的云客户。每个EC2实例都会运行一个标准的操作系统,比如说Linux,在操作系统之上,运行的是应用程序,例如Web服务、数据库。这种方式相对来说成本较低,也比较容易配置,所以是一个成功的服务模式。如下图是 EC2 与实际服务器的关系:
EC2对于无状态的Web服务器来说是完美的。客户端通过自己的Web浏览器连接到一些运行了Web服务的EC2实例上。如果突然新增了大量客户,你可以立刻向Amazon租用更多的EC2实例,并在上面启动Web服务。这样你就可以很简单的对你的Web服务进行扩容。
另一类人们主要运行在EC2实例的服务是数据库。通常来说一个网站包含了一些无状态的Web服务,任何时候这些Web服务需要一些持久化存储的数据时,它们会与一个后端数据库交互。
所以,现在的场景是:一些 EC2 实例上面运行了 Web 服务,这里你可以根据网站的规模想起多少实例就起多少。之后,还有一个 EC2 实例运行了数据库。Web 服务所在的 EC2 实例会与数据库所在的 EC2 实例交互,完成数据库中记录的读写。
不幸的是,对于数据库来说,EC2 就不像对于 Web 服务那样完美了,最直接的原因就是存储。对于运行了数据库的EC2实例,获取存储的最简单方法就是使用EC2实例所在服务器的本地硬盘。如果服务器宕机了,那么它本地硬盘也会无法访问。当Web服务所在的服务器宕机了,是完全没有问题的,因为Web服务本身没有状态,你只需要在一个新的EC2实例上启动一个新的Web服务就行。但是如果数据库所在的服务器宕机了,并且数据存储在服务器的本地硬盘中,那么就会有大问题,因为数据丢失了。
Amazon本身有实现了块存储的服务,叫做S3。你可以定期的对数据库做快照,并将快照存储在S3上,并基于快照来实现故障恢复,但是这种定期的快照意味着你可能会损失两次快照之间的数据。
# 1.2 EBS 的出现
所以,为了向用户提供EC2实例所需的硬盘,并且硬盘数据不会随着服务器故障而丢失,就出现了一个与Aurora相关的服务,并且同时也是容错的且支持持久化存储的服务,这个服务就是 EBS。
EBS全称是 Elastic Block Store。从EC2实例来看,EBS就是一个硬盘,你可以像一个普通的硬盘一样去格式化它,就像一个类似于ext3格式的文件系统或者任何其他你喜欢的Linux文件系统。但是在实现上,EBS底层是一对互为副本的存储服务器。随着EBS的推出,你可以租用一个EBS volume。一个EBS volume看起来就像是一个普通的硬盘一样,但却是由一对互为副本EBS服务器实现,每个EBS服务器本地有一个硬盘。所以,现在你运行了一个数据库,相应的EC2实例将一个EBS volume挂载成自己的硬盘。当数据库执行写磁盘操作时,数据会通过网络送到EBS服务器。
这两个EBS服务器会使用 Chain Replication 进行复制。所以写请求首先会写到第一个EBS服务器,之后写到第二个EBS服务器,然后从第二个EBS服务器,EC2实例可以得到回复。当读数据的时候,因为这是一个Chain Replication,EC2实例会从第二个EBS服务器读取数据。
所以现在,运行在EC2实例上的数据库有了可用性。因为现在有了一个存储系统可以在服务器宕机之后,仍然能持有数据。如果数据库所在的服务器挂了,你可以启动另一个EC2实例,并为其挂载同一个EBS volume,再启动数据库。新的数据库可以看到所有前一个数据库留下来的数据,就像你把硬盘从一个机器拔下来,再插入到另一个机器一样。所以EBS非常适合需要长期保存数据的场景,比如说数据库。
对于我们来说,有关EBS有一件很重要的事情:这不是用来共享的服务。任何时候,只有一个 EC2 实例可以挂载一个 EBS volume。所以,尽管所有人的EBS volume都存储在一个大的服务器池子里,每个EBS volume只能被一个EC2实例所使用。
# 1.3 EBS 存在的问题
尽管EBS是一次很大的进步,但是它仍然有自己的问题。它有一些细节不是那么的完美。
- 如果你在EBS上运行一个数据库,那么最终会有大量的数据通过网络来传递。论文的图2中,就有对在一个Network Storage System之上运行数据库所需要的大量写请求的抱怨。所以,如果在EBS上运行了一个数据库,会产生大量的网络流量。在论文中有暗示,除了网络的限制之外,还有CPU和存储空间的限制。在Aurora论文中,花费了大量的精力来降低数据库产生的网络负载,同时看起来相对来说不太关心CPU和存储空间的消耗。所以也可以理解成他们认为网络负载更加重要。
- 另一个问题是,EBS的容错性不是很好。出于性能的考虑,Amazon总是将EBS volume的两个副本存放在同一个数据中心。所以,如果一个副本故障了,那没问题,因为可以切换到另一个副本,但是如果整个数据中心挂了,那就没辙了。很明显,大部分客户还是希望在数据中心故障之后,数据还是能保留的。数据中心故障有很多原因,或许网络连接断了,或许数据中心着火了,或许整个建筑断电了。用户总是希望至少有选择的权利,在一整个数据中心挂了的时候,可以选择花更多的钱,来保留住数据。 但是Amazon描述的却是,EC2实例和两个EBS副本都运行在一个AZ(Availability Zone)。
在Amazon的术语中,一个AZ就是一个数据中心。Amazon通常这样管理它们的数据中心,在一个城市范围内有多个独立的数据中心。大概2-3个相近的数据中心,通过冗余的高速网络连接在一起,我们之后会看一下为什么这是重要的。但是对于EBS来说,为了降低使用Chain Replication的代价,Amazon 将 EBS 的两个副本放在一个 AZ 中。
# 2. 故障可恢复事务(Crash Recoverable Transaction)
为了能更好的理解Aurora的设计,在进一步介绍它是如何工作之前,我们必须要知道典型的数据库是如何设计的。因为Aurora使用的是与MySQL类似的机制实现,但是又以一种有趣的方式实现了加速,所以我们需要知道一个典型的数据库是如何设计实现的,这样我们才能知道Aurora是如何实现加速的。
所以这一部分是数据库教程,但是实际上主要关注的是,如何实现一个故障可恢复事务(Crash Recoverable Transaction)。所以这一部分我们主要看的是事务(Transaction)和故障可恢复(Crash Recovery)。数据库还涉及到很多其他的方面,但是对于Aurora来说,这两部分最重要。
# 2.1 什么是事务
首先,什么是事务?事务是指将多个操作打包成原子操作,并确保多个操作顺序执行。假设我们运行一个银行系统,我们想在不同的银行账户之间转账。你可以这样看待一个事务,首先需要定义想要原子打包的多个操作的开始;之后是操作的内容,现在我们想要从账户Y转10块钱到账户X,那么账户X需要增加10块,账户Y需要减少10块;最后表明事务结束。
我们希望数据库顺序执行这两个操作,并且不允许其他任何人看到执行的中间状态。同时,考虑到故障,如果在执行的任何时候出现故障,我们需要确保故障恢复之后,要么所有操作都已经执行完成,要么一个操作也没有执行。这是我们想要从事务中获得的效果。除此之外,数据库的用户期望数据库可以通知事务的状态,也就是事务是否真的完成并提交了。如果一个事务提交了,用户期望事务的效果是可以持久保存的,即使数据库故障重启了,数据也还能保存。
# 2.2 事务是如何实现的
通常来说,事务是通过对涉及到的每一份数据加锁来实现。所以你可以认为,在整个事务的过程中,都对X,Y加了锁。并且只有当事务结束、提交并且持久化存储之后,锁才会被释放。所以,数据库实际上在事务的过程中,是通过对数据加锁来确保其他人不能访问。这一点很重要,理解了这一点,论文中有一些细节才变得有意义。
所以,这里具体是怎么实现的呢?对于一个简单的数据库模型,数据库运行在单个服务器上,并且使用本地硬盘。在硬盘上存储了数据的记录,或许是以B-Tree方式构建的索引。所以有一些data page用来存放数据库的数据,其中一个存放了X的记录,另一个存放了Y的记录。每一个data page通常会存储大量的记录,而X和Y的记录是page中的一些bit位。
在硬盘中,除了有数据之外,还有一个预写式日志(Write-Ahead Log,简称为 WAL)。预写式日志对于系统的容错性至关重要。
在服务器内部,有数据库软件,通常数据库会对最近从磁盘读取的page有缓存(buffer pool)。当你在执行一个事务内的各个操作时,例如执行 X=X+10 的操作时,数据库会从硬盘中读取持有X的记录,给数据加10。但是在事务提交之前,数据的修改还只在本地的缓存中,并没有写入到硬盘。我们现在还不想向硬盘写入数据,因为这样可能会暴露一个不完整的事务。
为了让数据库在故障恢复之后,还能够提供同样的数据,在允许数据库软件修改硬盘中真实的data page之前,数据库软件需要先在WAL中添加Log条目来描述事务。所以在提交事务之前,数据库需要先在WAL中写入完整的Log条目,来描述所有有关数据库的修改,并且这些Log是写入磁盘的。
让我们假设,X的初始值是500,Y的初始值是750。在提交并写入硬盘的data page之前,数据库通常需要写入至少3条Log记录:
- 第一条表明,作为事务的一部分,我要修改X,它的旧数据是500,我要将它改成510。
- 第二条表明,我要修改Y,它的旧数据是750,我要将它改成740。
- 第三条记录是一个Commit日志,表明事务的结束。
通常来说,前两条Log记录会打上事务的ID作为标签,这样在故障恢复的时候,可以根据第三条commit日志找到对应的Log记录,进而知道哪些操作是已提交事务的,哪些是未完成事务的。
如果数据库成功的将事务对应的操作和commit日志写入到磁盘中,数据库可以回复给客户端说,事务已经提交了。而这时,客户端也可以确认事务是永久可见的。
接下来有两种情况:
- 如果数据库没有崩溃,那么在它的cache中,X,Y对应的数值分别是510和740。最终数据库会将cache中的数值写入到磁盘对应的位置。所以数据库写磁盘是一个lazy操作,它会对更新进行累积,每一次写磁盘可能包含了很多个更新操作。这种累积更新可以提升操作的速度。
- 如果数据库在将cache中的数值写入到磁盘之前就崩溃了,这样磁盘中的page仍然是旧的数值。当数据库重启时,恢复软件会扫描WAL日志,发现对应事务的Log,并发现事务的commit记录,那么恢复软件会将新的数值写入到磁盘中。这被称为redo,它会重新执行事务中的写操作。
这就是事务型数据库的工作原理的简单描述,同时这也是一个极度精简的 MySQL 数据库工作方式的介绍,MySQL 基本以这种方式实现了故障可恢复事务。而 Aurora 就是基于这个开源软件 MySQL 构建的。
# 3. 关系型数据库 Amazon RDS
在 MySQL 基础上,结合 Amazon 自己的基础设施,Amazon 为其云用户开发了改进版的数据库,叫做 RDS(Relational Database Service)。尽管论文不怎么讨论 RDS,但是论文中的图2基本上是对 RDS 的描述。RDS 是第一次尝试将数据库在多个 AZ 之间做复制,这样就算整个数据中心挂了,你还是可以从另一个 AZ 重新获得数据而不丢失任何写操作。
对于 RDS 来说,有且仅有一个 EC2 实例作为数据库。这个数据库将它的 data page 和 WAL Log 存储在EBS,而不是对应服务器的本地硬盘。当数据库执行了写Log或者写page操作时,这些写请求实际上通过网络发送到了EBS服务器。所有这些服务器都在一个AZ中。
每一次数据库软件执行一个写操作,Amazon会自动的,对数据库无感知的,将写操作拷贝发送到另一个数据中心的AZ中。从论文的图2来看,可以发现这是另一个EC2实例,它的工作就是执行与主数据库相同的操作。所以,AZ2的副数据库会将这些写操作拷贝AZ2对应的EBS服务器。如下图:
在RDS的架构中,也就是论文图2中,每一次写操作,例如数据库追加日志或者写磁盘的page,数据除了发送给AZ1的两个EBS副本之外,还需要通过网络发送到位于AZ2的副数据库。副数据库接下来会将数据再发送给AZ2的两个独立的EBS副本。之后,AZ2的副数据库会将写入成功的回复返回给AZ1的主数据库,主数据库看到这个回复之后,才会认为写操作完成了。
RDS这种架构提供了更好的容错性。因为现在在一个其他的AZ中,有了数据库的一份完整的实时的拷贝。这个拷贝可以看到所有最新的写请求。即使AZ1发生火灾都烧掉了,你可以在AZ2的一个新的实例中继续运行数据库,而不丢失任何数据。
学生提问:为什么EBS的两个副本不放在两个数据中心呢?这样就不用 RDS 也能保证跨数据中心的高可用了。
Robert教授:我不知道怎么回答这个问题。EBS不是这样工作的,我猜是因为,对于大部分的EBS用户,如果每一个写请求都需要跨数据中心传递,这就太慢了。我不太确定具体的实现,但我认为这是他们不这么做的主要原因。RDS可以看成是EBS工作方式的一种补救,所以使用的还是未经更改的EBS工作方式。
如论文中表1所示,RDS的写操作代价极高,就如你所预期的一样高,因为需要写大量的数据。即使如之前的例子,执行类似于 x+10,y-10,这样的操作,虽然看起来就是修改两个整数,每个整数或许只有8字节或者16字节,但是对于data page的读写,极有可能会比10多个字节大得多。因为每一个page会有8k字节,或者16k字节,或者是一些由文件系统或者磁盘块决定的相对较大的数字。这意味着,哪怕是只写入这两个数字,当需要更新data page时,需要向磁盘写入多得多的数据。如果使用本地的磁盘,明显会快得多。
我猜,当他们开始通过网络来传输8k字节的page数据时,他们发现使用了太多的网络容量,所以论文中图2的架构,也就是RDS的架构很明显太慢了。
学生提问:为什么会慢呢?(教室今天好空)
Robert教授:在这个架构中,对于数据库来说是无感知的,每一次数据库调用写操作,更新自己对应的EBS服务器,每一个写操作的拷贝穿过AZ也会写入到另一个AZ中的2个EBS服务器中,另一个AZ会返回确认说写入成功,只有这时,写操作看起来才是完成的。所以这里必须要等待4个服务器更新完成,并且等待数据在链路上传输。
如论文中表1描述的性能所担心的一样,这种Mirrored MySQL比Aurora慢得多的原因是,它通过网络传输了大量的数据。这就是性能低的原因,并且Amazon想要修复这里的问题。所以这种架构增强了容错性,因为我们在一个不同的AZ有了第二个副本拷贝,但是对于性能来说又太糟糕了。
# 4. Aurora 初探
这一部分开始介绍 Aurora。
整体上来看,我们还是有一个数据库服务器,但是这里运行的是 Amazon 提供的定制软件:Aurora。所以,我可以向 Amazon 租用一个 Aurora 服务器,但是我不在上面运行自己的软件,而是运行 Amazon 的 Aurora 软件。
在 Aurora 的架构中,有两件有意思的事情:
# 4.1 更多的副本以及 JUST LOG ENTRIES
第一个是,在替代 EBS 的位置,有 6 个数据的副本,位于 3 个 AZ,每个 AZ 有 2 个副本。所以现在有了超级容错性,并且每个写请求都需要以某种方式发送给这6个副本。这有些复杂,我们之后会再介绍。
现在有了更多的副本,我的天,为什么 Aurora 不是更慢了,之前 Mirrored MySQL 中才有4个副本。答案是,这里通过网络传递的数据只有Log条目,这才是Aurora成功的关键。从之前的简单数据库模型可以看出,每一条Log条目只有几十个字节那么多,也就是存一下旧的数值,新的数值,所以Log条目非常小。然而,当一个数据库要写本地磁盘时,它更新的是data page,这里的数据是巨大的,虽然在论文里没有说,但是我认为至少是8k字节那么多。所以,对于每一次事务,需要通过网络发送多个8k字节的page数据。而Aurora只是向更多的副本发送了少量的Log条目。因为Log条目的大小比8K字节小得多,所以在网络性能上这里就胜出了。这是 Aurora 的第一个特点:只发送 Log 条目。
当然,这里的后果是,这里的存储系统不再是通用(General-Purpose)存储,这是一个可以理解 MySQL Log 条目的存储系统。EBS是一个非常通用的存储系统,它模拟了磁盘,只需要支持读写数据块。EBS不理解除了数据块以外的其他任何事物。而这里的存储系统理解使用它的数据库的Log。所以这里,Aurora将通用的存储去掉了,取而代之的是一个应用定制的(Application-Specific)存储系统。
# 4.2 Quorum 的使用
另一件重要的事情是,Aurora并不需要6个副本都确认了写入才能继续执行操作。相应的,只要Quorum形成了,也就是任意4个副本确认写入了,数据库就可以继续执行操作。所以,当我们想要执行写入操作时,如果有一个AZ下线了,或者AZ的网络连接太慢了,或者只是服务器响应太慢了,Aurora可以忽略最慢的两个服务器,或者已经挂掉的两个服务器,它只需要6个服务器中的任意4个确认写入,就可以继续执行。所以这里的Quorum是Aurora使用的另一个聪明的方法。通过这种方法,Aurora可以有更多的副本,更多的AZ,但是又不用付出大的性能代价,因为它永远也不用等待所有的副本,只需要等待6个服务器中最快的4个服务器即可。
所以,这节课剩下的时间,我们会用来解释Quorum和Log条目。论文的表1总结了一些结果。Mirrored MySQL将大的page数据发送给4个副本,而Aurora只是将小的Log条目发送给6个副本,Aurora获得了35倍的性能提升。论文并没有介绍性能的提升中,有多少是Quorum的功劳,有多少是只发送Log条目的功劳,但是不管怎么样,35倍的性能提升是令人尊敬的结果,同时也是对用户来说非常有价值的结果。我相信对于许多Amazon的客户来说,这是具有革新意义的。
# 5. Aurora 存储服务器的容错目标
从之前的描述可以看出,Aurora的Quorum系统管理了6个副本的容错系统。所以值得思考的是,Aurora的容错目标是什么?
- 首先是对于写操作,当只有一个 AZ 彻底挂了之后,写操作不受影响。
- 其次是对于读操作,当一个AZ和一个其他AZ的服务器挂了之后,读操作不受影响。这里的原因是,AZ的下线时间可能很长,比如说数据中心被水淹了。人们可能需要几天甚至几周的时间来修复洪水造成的故障,在AZ下线的这段时间,我们只能依赖其他AZ的服务器。如果其他AZ中的一个服务器挂了,我们不想让整个系统都瘫痪。所以当一个AZ彻底下线了之后,对于读操作,Aurora还能容忍一个额外服务器的故障,并且仍然可以返回正确的数据。至于为什么会定这样的目标,我们必须理所当然的认为Amazon知道他们自己的业务,并且认为这是实现容错的最佳目标。
- 此外,我之前也提过,Aurora 期望能够容忍暂时的慢副本。如果你向EBS读写数据,你并不能得到稳定的性能,有时可能会有一些卡顿,或许网络中一部分已经过载了,或许某些服务器在执行软件升级,任何类似的原因会导致暂时的慢副本。所以Aurora期望能够在出现短暂的慢副本时,仍然能够继续执行操作。
- 最后一个需求是,如果一个副本挂了,在另一个副本挂之前,是争分夺秒的。统计数据或许没有你期望的那么好,因为通常来说服务器故障不是独立的。事实上,一个服务器挂了,通常意味着有很大的可能另一个服务器也会挂,因为它们有相同的硬件,或许从同一个公司购买,来自于同一个生产线。如果其中一个有缺陷,非常有可能会在另一个服务器中也会有相同的缺陷。所以,当出现一个故障时,人们总是非常紧张,因为第二个故障可能很快就会发生。对于Aurora的Quorum系统,有点类似于Raft,你只能从局部故障中恢复。所以这里需要快速生成新的副本(Fast Re-replication)。也就是说如果一个服务器看起来永久故障了,我们期望能够尽可能快的根据剩下的副本,生成一个新的副本。
所以,以上就是论文列出的Aurora的主要容错目标。顺便说一下,这里的讨论只针对存储服务器,所以这里讨论的是存储服务器的故障特性,以及如何从故障中恢复。如果数据库服务器本身挂了, 该如何恢复是一个完全不同的话题。Aurora有一个完全不同的机制,可以发现数据库服务器挂了之后,创建一个新的实例来运行新的数据库服务器。但是这不是我们现在讨论的话题,我们会在稍后再讨论。现在只是讨论存储服务器,以及存储服务器的容错。
# 6. Quorum 机制
Aurora使用了Quorum这种思想。接下来,我将描述一下经典的Quorum思想,它最早可以追溯到1970年代。Aurora使用的是一种经典quorum思想的变种。Quorum系统背后的思想是通过复制构建容错的存储系统,并确保即使有一些副本故障了,读请求还是能看到最近的写请求的数据。通常来说,Quorum系统就是简单的读写系统,支持Put/Get操作。它们通常不直接支持更多更高级的操作。你有一个对象,你可以读这个对象,也可以通过写请求覆盖这个对象的数值。
假设有N个副本。为了能够执行写请求,必须要确保写操作被W个副本确认,W小于N。所以你需要将写请求发送到这W个副本。如果要执行读请求,那么至少需要从R个副本得到所读取的信息。这里的W对应的数字称为 Write Quorum,R对应的数字称为 Read Quorum。这是一个典型的Quorum配置。
这里的关键点在于,W、R、N之间的关联。Quorum系统要求,任意你要发送写请求的W个服务器,必须与任意接收读请求的R个服务器有重叠。这意味着,R加上W必须大于N( 至少满足R + W = N + 1 ),这样任意W个服务器至少与任意R个服务器有一个重合。
假设你有3个服务器,并且假设每个服务器只存了一个对象。我们发送了一个写请求,想将我们的对象设置成23。为了能够执行写请求,我们需要至少将写请求发送到W个服务器。我们假设在这个系统中,R和W都是2,N是3。为了执行一个写请求,我们需要将新的数值23发送到至少2个服务器上。所以,或许我们的写请求发送到了S1和S3。所以,它们现在知道了我们对象的数值是23。
如果某人发起读请求,读请求会至少检查R个服务器。在这个配置中,R也是2。这里的R个服务器可能包含了并没有看到之前写请求的服务器(S2),但同时也至少还需要一个其他服务器来凑齐2个服务器。这意味着,任何读请求都至少会包含一个看到了之前写请求的服务器。
这是Quorum系统的要求,Read Quorum必须至少与Write Quorum有一个服务器是重合的。所以任何读请求可以从至少一个看见了之前写请求的服务器得到回复。
这里还有一个关键的点,客户端读请求可能会得到R个不同的结果,现在的问题是,客户端如何知道从R个服务器得到的R个结果中,哪一个是正确的呢?通过不同结果出现的次数来投票(Vote)在这是不起作用的,因为我们只能确保Read Quorum必须至少与Write Quorum有一个服务器是重合的,这意味着客户端向R个服务器发送读请求,可能只有一个服务器返回了正确的结果。对于一个有6个副本的系统,可能Read Quorum是4,那么你可能得到了4个回复,但是只有一个与之前写请求重合的服务器能将正确的结果返回,所以这里不能使用投票。在Quorum系统中使用的是版本号(Version)。所以,每一次执行写请求,你需要将新的数值与一个增加的版本号绑定。之后,客户端发送读请求,从Read Quorum得到了一些回复,客户端可以直接使用其中的最高版本号的数值。
假设刚刚的例子中,S2有一个旧的数值20。每一个服务器都有一个版本号,S1和S3是版本3,因为它们看到了相同的写请求,所以它们的版本号是相同的。同时我们假设没有看到前一个写请求的S2的版本号是2。之后客户端从S2和S3读取数据,得到了两个不同结果,它们有着不同的版本号,客户端会挑选版本号最高的结果。
如果你不能与Quorum数量的服务器通信,不管是Read Quorum还是Write Quorum,那么你只能不停的重试了。这是Quorum系统的规则,你只能不停的重试,直到服务器重新上线,或者重新联网。
相比Chain Replication,这里的优势是可以轻易的剔除暂时故障、失联或者慢的服务器。实际上,这里是这样工作的,当你执行写请求时,你会将新的数值和对应的版本号给所有N个服务器,但是只会等待W个服务器确认。类似的,对于读请求,你可以将读请求发送给所有的服务器,但是只等待R个服务器返回结果。因为你只需要等待R个服务器,这意味着在最快的R个服务器返回了之后,你就可以不用再等待慢服务器或者故障服务器超时。这里忽略慢服务器或者挂了的服务器的机制完全是隐式的。在这里,我们不用决定哪个服务器是在线或者是离线的,只要Quorum能达到,系统就能继续工作,所以我们可以非常平滑的处理慢服务或者挂了的服务。
除此之外,Quorum系统可以调整读写的性能。通过调整Read Quorum和Write Quorum,可以使得系统更好的支持读请求或者写请求。对于前面的例子,我们可以假设Write Quorum是3,每一个写请求必须被所有的3个服务器所确认。这样的话,Read Quorum可以只是1。所以,如果你想要提升读请求的性能,在一个3个服务器的Quorum系统中,你可以设置R为1,W为3,这样读请求会快得多,因为它只需要等待一个服务器的结果,但是代价是写请求执行的比较慢。如果你想要提升写请求的性能,可以设置R为3,W为1,这意味着可能只有1个服务器有最新的数值,但是因为客户端会咨询3个服务器,3个服务器其中一个肯定包含了最新的数值。
当R为1,W为3时,写请求就不再是容错的了,同样,当R为3,W为1时,读请求不再是容错的,因为对于读请求,所有的服务器都必须在线才能执行成功。所以在实际场景中,你不会想要这么配置,你或许会与Aurora一样,使用更多的服务器,将N变大,然后再权衡Read Quorum和Write Quorum。
为了实现上一节描述的Aurora的容错目标,也就是在一个AZ完全下线时仍然能写,在一个AZ加一个其他AZ的服务器下线时仍然能读,Aurora的Quorum系统中,N=6,W=4,R=3。W等于4意味着,当一个AZ彻底下线时,剩下2个AZ中的4个服务器仍然能完成写请求。R等于3意味着,当一个AZ和一个其他AZ的服务器下线时,剩下的3个服务器仍然可以完成读请求。当3个服务器下线了,系统仍然支持读请求,仍然可以返回当前的状态,但是却不能支持写请求。所以,当3个服务器挂了,现在的Quorum系统有足够的服务器支持读请求,并据此重建更多的副本,但是在新的副本创建出来替代旧的副本之前,系统不能支持写请求。同时,如我之前解释的,Quorum系统可以剔除暂时的慢副本。
# 7. Aurora 读写存储服务器
我之前也解释过,Aurora中的写请求并不是像一个经典的Quorum系统一样直接更新数据。对于Aurora来说,它的写请求从来不会覆盖任何数据,它的写请求只会在当前Log中追加条目(Append Entries)。所以,Aurora使用Quorum只是在数据库执行事务并发出新的Log记录时,确保Log记录至少出现在4个存储服务器上,之后才能提交事务。所以,Aurora的Write Quorum的实际意义是,每个新的Log记录必须至少追加在4个存储服务器中,之后才可以认为写请求完成了。当Aurora执行到事务的结束,并且在回复给客户端说事务已经提交之前,Aurora必须等待Write Quorum的确认,也就是4个存储服务器的确认,组成事务的每一条Log都成功写入了。
实际上,在一个故障恢复过程中,事务只能在之前所有的事务恢复了之后才能被恢复。所以,实际中,在Aurora确认一个事务之前,它必须等待Write Quorum确认之前所有已提交的事务,之后再确认当前的事务,最后才能回复给客户端。
这里的存储服务器接收Log条目,这是它们看到的写请求。它们并没有从数据库服务器获得到新的data page,它们得到的只是用来描述data page更新的Log条目。但是存储服务器内存最终存储的还是数据库服务器磁盘中的page。在存储服务器的内存中,会有自身磁盘中page的cache,例如page1(P1),page2(P2),这些page其实就是数据库服务器对应磁盘的page。
当一个新的写请求到达时,这个写请求只是一个Log条目,Log条目中的内容需要应用到相关的page中。但是我们不必立即执行这个更新,可以等到数据库服务器或者恢复软件想要查看那个page时才执行。**对于每一个存储服务器存储的page,如果它最近被一个Log条目修改过,那么存储服务器会在内存中缓存一个旧版本的page和一系列来自于数据库服务器有关修改这个page的Log条目。所以,对于一个新的Log条目,它会立即被追加到影响到的page的Log列表中。这里的Log列表从上次page更新过之后开始(相当于page是snapshot,snapshot后面再有一系列记录更新的Log)。如果没有其他事情发生,那么存储服务器会缓存旧的page和对应的一系列Log条目。
如果之后数据库服务器将自身缓存的page删除了,过了一会又需要为一个新的事务读取这个page,它会发出一个读请求。请求发送到存储服务器,会要求存储服务器返回当前最新的page数据。在这个时候,存储服务器才会将Log条目中的新数据更新到page,并将page写入到自己的磁盘中,之后再将更新了的page返回给数据库服务器。同时,存储服务器在自身cache中会删除page对应的Log列表,并更新cache中的page,虽然实际上可能会复杂的多。
如刚刚提到的,数据库服务器有时需要读取page。所以,可能你已经发现了,数据库服务器写入的是Log条目,但是读取的是page。这也是与Quorum系统不一样的地方。Quorum系统通常读写的数据都是相同的。除此之外,在一个普通的操作中,数据库服务器可以避免触发Quorum Read。数据库服务器会记录每一个存储服务器接收了多少Log。所以,首先,Log条目都有类似12345这样的编号,当数据库服务器发送一条新的Log条目给所有的存储服务器,存储服务器接收到它们会返回说,我收到了第79号和之前所有的Log。数据库服务器会记录这里的数字,或者说记录每个存储服务器收到的最高连续的Log条目号。这样的话,当一个数据库服务器需要执行读操作,它只会挑选拥有最新Log的存储服务器,然后只向那个服务器发送读取page的请求。所以,数据库服务器执行了Quorum Write,但是却没有执行Quorum Read。因为它知道哪些存储服务器有最新的数据,然后可以直接从其中一个读取数据。这样的代价小得多,因为这里只读了一个副本,而不用读取Quorum数量的副本。
但是,数据库服务器有时也会使用Quorum Read。假设数据库服务器运行在某个EC2实例,如果相应的硬件故障了,数据库服务器也会随之崩溃。在Amazon的基础设施有一些监控系统可以检测到Aurora数据库服务器崩溃,之后Amazon会自动的启动一个EC2实例,在这个实例上启动数据库软件,并告诉新启动的数据库:你的数据存放在那6个存储服务器中,请清除存储在这些副本中的任何未完成的事务,之后再继续工作。这时,Aurora会使用Quorum的逻辑来执行读请求。因为之前数据库服务器故障的时候,它极有可能处于执行某些事务的中间过程。所以当它故障了,它的状态极有可能是它完成并提交了一些事务,并且相应的Log条目存放于Quorum系统。同时,它还在执行某些其他事务的过程中,这些事务也有一部分Log条目存放在Quorum系统中,但是因为数据库服务器在执行这些事务的过程中崩溃了,这些事务永远也不可能完成。对于这些未完成的事务,我们可能会有这样一种场景,第一个副本有第101个Log条目,第二个副本有第102个Log条目,第三个副本有第104个Log条目,但是没有一个副本持有第103个Log条目。
所以故障之后,新的数据库服务器需要恢复,它会执行Quorum Read,找到第一个缺失的Log序号,在上面的例子中是103,并说,好吧,我们现在缺失了一个Log条目,我们不能执行这条Log之后的所有Log,因为我们缺失了一个Log对应的更新。
所以,这种场景下,数据库服务器执行了Quorum Read,从可以连接到的存储服务器中发现103是第一个缺失的Log条目。这时,数据库服务器会给所有的存储服务器发送消息说:请丢弃103及之后的所有Log条目。103及之后的Log条目必然不会包含已提交的事务,因为我们知道只有当一个事务的所有Log条目存在于Write Quorum时,这个事务才会被commit,所以对于已经commit的事务我们肯定可以看到相应的Log。这里我们只会丢弃未commit事务对应的Log条目。
所以,某种程度上,我们将Log在102位置做了切割,102及之前的Log会保留。但是这些会保留的Log中,可能也包含了未commit事务的Log,数据库服务器需要识别这些Log。这是可行的,可以通过Log条目中的事务ID和事务的commit Log条目来判断(10.3)哪些Log属于已经commit的事务,哪些属于未commit的事务。数据库服务器可以发现这些未完成的事务对应Log,并发送undo操作来撤回所有未commit事务做出的变更。这就是为什么Aurora在Log中同时也会记录旧的数值的原因。因为只有这样,数据库服务器在故障恢复的过程中,才可以回退之前只提交了一部分,但是没commit的事务。
# 8. 数据分片(Protection Group)
这一部分讨论,Aurora如何处理大型数据库。目前为止,我们已经知道Aurora将自己的数据分布在6个副本上,每一个副本都是一个计算机,上面挂了1-2块磁盘。但是如果只是这样的话,我们不能拥有一个数据大小大于单个机器磁盘空间的数据库。因为虽然我们有6台机器,但是并没有为我们提供6倍的存储空间,每个机器存储的都是相同的数据。如果我使用的是SSD,我可以将数TB的数据存放于单台机器上,但是我不能将数百TB的数据存放于单台机器上。
为了能支持超过10TB数据的大型数据库。Amazon的做法是将数据库的数据,分割存储到多组存储服务器上,每一组都是6个副本,分割出来的每一份数据是10GB,称为一个 Protection Group(Protection Group)。所以,如果一个数据库需要20GB的数据,那么这个数据库会使用2个PG,其中一半的10GB数据在一个PG中,包含了6个存储服务器作为副本,另一半的10GB数据存储在另一个PG中,这个PG可能包含了不同的6个存储服务器作为副本。
因为Amazon运行了大量的存储服务器,这些服务器一起被所有的Aurora用户所使用。两组PG可能使用相同的6个存储服务器,但是通常来说是完全不同的两组存储服务器。随着数据库变大,我们可以有更多的Protection Group。
这里有一件有意思的事情,你可以将磁盘中的data page分割到多个独立的PG中,比如说奇数号的page存在PG1,偶数号的page存在PG2。如果可以根据data page做sharding,那是极好的。
Sharding之后,Log该如何处理就不是那么直观了。如果有多个Protection Group,该如何分割Log呢?答案是,当Aurora需要发送一个Log条目时,它会查看Log所修改的数据,并找到存储了这个数据的Protection Group,并把Log条目只发送给这个Protection Group对应的6个存储服务器。这意味着,每个Protection Group只存储了部分data page和所有与这些data page关联的Log条目。所以每个Protection Group存储了所有data page的一个子集,以及这些data page相关的Log条目。
如果其中一个存储服务器挂了,我们期望尽可能快的用一个新的副本替代它。因为如果4个副本挂了,我们将不再拥有Read Quorum,我们也因此不能创建一个新的副本。所以我们想要在一个副本挂了以后,尽可能快的生成一个新的副本。表面上看,每个存储服务器存放了某个数据库的某个某个Protection Group对应的10GB数据,但实际上每个存储服务器可能有1-2块几TB的磁盘,上面存储了属于数百个Aurora实例的10GB数据块。所以在存储服务器上,可能总共会有10TB的数据,当它故障时,它带走的不仅是一个数据库的10GB数据,同时也带走了其他数百个数据库的10GB数据。所以生成的新副本,不是仅仅要恢复一个数据库的10GB数据,而是要恢复存储在原来服务器上的整个10TB的数据。我们来做一个算术,如果网卡是10Gb/S,通过网络传输10TB的数据需要8000秒。这个时间太长了,我们不想只是坐在那里等着传输。所以我们不想要有这样一种重建副本的策略:找到另一台存储服务器,通过网络拷贝上面所有的内容到新的副本中。我们需要的是一种快的多的策略。
Aurora实际使用的策略是,对于一个特定的存储服务器,它存储了许多Protection Group对应的10GB的数据块。对于Protection Group A,它的其他副本是5个服务器。或许这个存储服务器还为Protection Group B保存了数据,但是B的其他副本存在于与A没有交集的其他5个服务器中(虽然图中只画了4个):
类似的,对于所有的Protection Group对应的数据块,都会有类似的副本。这种模式下,如果一个存储服务器挂了,假设上面有100个数据块,现在的替换策略是:找到100个不同的存储服务器,其中的每一个会被分配一个数据块,也就是说这100个存储服务器,每一个都会加入到一个新的Protection Group中。所以相当于,每一个存储服务器只需要负责恢复10GB的数据。所以在创建新副本的时候,我们有了100个存储服务器(下图中下面那5个空白的)。
对于每一个数据块,我们会从Protection Group中挑选一个副本,作为数据拷贝的源。这样,对于100个数据块,相当于有了100个数据拷贝的源。之后,就可以并行的通过网络将100个数据块从100个源拷贝到100个目的。
假设有足够多的服务器,这里的服务器大概率不会有重合,同时假设我们有足够的带宽,现在我们可以以100的并发,并行的拷贝1TB的数据,这只需要10秒左右。如果只在两个服务器之间拷贝,正常拷贝1TB数据需要1000秒左右。
这就是Aurora使用的副本恢复策略,它意味着,如果一个服务器挂了,它可以并行的,快速的在数百台服务器上恢复。如果大量的服务器挂了,可能不能正常工作,但是如果只有一个服务器挂了,Aurora可以非常快的重新生成副本。
# 9. 只读数据库(Read-only Database)
如果你查看论文的图3,你可以发现,Aurora不仅有主数据库实例,同时多个数据库的副本。对于Aurora的许多客户来说,相比读写查询,他们会有多得多的只读请求。。你可以设想一个Web服务器,如果你只是查看Web页面,那么后台的Web服务器需要读取大量的数据才能生成页面所需的内容,或许需要从数据库读取数百个条目。但是在浏览Web网页时,写请求就要少的多,或许一些统计数据要更新,或许需要更新历史记录,所以读写请求的比例可能是100:1。所以对于Aurora来说,通常会有非常大量的只读数据库查询。
对于写请求,可以只发送给一个数据库,因为对于后端的存储服务器来说,只能支持一个写入者。背后的原因是,Log需要按照数字编号,如果只在一个数据库处理写请求,非常容易对Log进行编号,但是如果有多个数据库以非协同的方式处理写请求,那么为Log编号将会非常非常难。
但是对于读请求,可以发送给多个数据库。Aurora的确有多个只读数据库,这些数据库可以从后端存储服务器读取数据。所以,图3中描述了,除了主数据库用来处理写请求,同时也有一组只读数据库。论文中宣称可以支持最多15个只读数据库。如果有大量的读请求,读请求可以分担到这些只读数据库上。
当客户端向只读数据库发送读请求,只读数据库需要弄清楚它需要哪些data page来处理这个读请求,之后直接从存储服务器读取这些data page,并不需要主数据库的介入。所以只读数据库向存储服务器直接发送读取page的请求,之后它会缓存读取到的page,这样对于将来的一些读请求,可以直接根据缓存中的数据返回。
当然,只读数据库也需要更新自身的缓存,所以,Aurora的主数据库也会将它的Log的拷贝发送给每一个只读数据库。这就是你从论文中图3看到的蓝色矩形中间的那些横线。主数据库会向这些只读数据库发送所有的Log条目,只读数据库用这些Log来更新它们缓存的page数据,进而获得数据库中最新的事务处理结果。
这的确意味着只读数据库会落后主数据库一点,但是对于大部分的只读请求来说,这没问题。因为如果你查看一个网页,如果数据落后了20毫秒,通常来说不会是一个大问题。
这里其实有一些问题,其中一个问题是,我们不想要这个只读数据库看到未commit的事务。所以,在主数据库发给只读数据库的Log流中,主数据库需要指出,哪些事务commit了,而只读数据库需要小心的不要应用未commit的事务到自己的缓存中,它们需要等到事务commit了再应用对应的Log。
另一个问题是,数据库背后的B-Tree结构非常复杂,可能会定期触发rebalance。而rebalance是一个非常复杂的操作,对应了大量修改树中的节点的操作,这些操作需要有原子性。因为当B-Tree在rebalance的过程中,中间状态的数据是不正确的,只有在rebalance结束了才可以从B-Tree读取数据。但是只读数据库直接从存储服务器读取数据库的page,它可能会看到在rebalance过程中的B-Tree。这时看到的数据是非法的,会导致只读数据库崩溃或者行为异常。
论文中讨论了微事务(Mini-Transaction)和VDL/VCL。这部分实际讨论的就是,数据库服务器可以通知存储服务器说,这部分复杂的Log序列只能以原子性向只读数据库展示,也就是要么全展示,要么不展示。这就是微事务(Mini-Transaction)和VDL。所以当一个只读数据库需要向存储服务器查看一个data page时,存储服务器会小心的,要么展示微事务之前的状态,要么展示微事务之后的状态,但是绝不会展示中间状态。
# 10. 总结
以上就是所有技术相关的内容,我们来总结一下论文中有意思的地方,以及我们可以从论文中学到的一些东西。
- 一件可以学到的事情其实比较通用,并不局限于这篇论文。大家都应该知道事务型数据库是如何工作的,并且知道事务型数据库与后端存储之间交互带来的影响。这里涉及了性能,故障修复,以及运行一个数据库的复杂度,这些问题在系统设计中会反复出现。
- 另一个件可以学到的事情是,Quorum思想。通过读写Quorum的重合,可以确保总是能看见最新的数据,但是又具备容错性。这种思想在Raft中也有体现,Raft可以认为是一种强Quorum的实现(读写操作都要过半服务器认可)。
- 这个论文中另一个有趣的想法是,数据库和存储系统基本是一起开发出来的,数据库和存储系统以一种有趣的方式集成在了一起。通常我们设计系统时,需要有好的隔离解耦来区分上层服务和底层的基础架构。所以通常来说,存储系统是非常通用的,并不会为某个特定的应用程序定制。因为一个通用的设计可以被大量服务使用。但是在Aurora面临的问题中,性能问题是非常严重的,它不得不通过模糊服务和底层基础架构的边界来获得35倍的性能提升,这是个巨大的成功。
- 最后一件有意思的事情是,论文中的一些有关云基础架构中什么更重要的隐含信息。例如:
- 需要担心整个AZ会出现故障;
- 需要担心短暂的慢副本,这是经常会出现的问题;
- 网络是主要的瓶颈,毕竟Aurora通过网络发送的是极短的数据,但是相应的,存储服务器需要做更多的工作(应用Log),因为有6个副本,所以有6个CPU在复制执行这些redo Log条目,明显,从Amazon看来,网络容量相比CPU要重要的多。