几个月前,AWS在SIGMOD 17上发表了《Amazon Aurora: Design Considerations for High Throughput Cloud-Native Relational Databases》,详细解读了Aurora的背景、设计思想以及效果。即使本人不搞关系数据库,也没有读过MySQL内核源码,读完后也觉得可以从中学习到很多东西,记录于此。一些理解不正确的地方,也欢迎讨论。
传统关系数据库三宗罪,扩展性、可用性和schema 变更(前两条普适各类存储系统)。经常见到的方案中 ,人们用分库分表/读写分离等解决扩展性,用主备策略(sync/semi-sync/async/backup|restore)解决可用性,用数据重写/提前定义多余列/json支持等解决schema变更。这些解决方案一般是业务感知的,有时也需要业务做痛苦的取舍(保数据不丢还是保服务可用?)。一些新系统的出现一般都是解决了其中一些问题,比如著名的BigTable[2](包括开源的HBase[3]、阿里自研的表格存储[4])相当程度的解决了扩展性,可用性,直接摒弃了schema,但是不提供关系数据库事务。Spanner[5]、OceanBase[6]、TiDB[7]、CockroachDB[8]等除了解决上述问题,还提供了事务,但是在MySQL协议兼容方面很难做到100%。Aurora[1]和云栖大会上刚刚推出的PolarDB[9]则选择了另外的路线,借助于已有的MySQL引擎,做到了MySQL功能无损,同时通过计算存储分离以及对存储提供扩展性解决了读扩展以及可用性问题,通过versioned schema解决schema变更问题(Aurora论文描述)。废话说完,开始学习Aurora。
文中描述,数据库中有些操作很难并行,比如disk read/transaction commit,一旦这些操作出现了fail或者毛刺,对系统影响很大;还有多阶段同步协议(比如2PC)对failure容忍度不够好,而云环境下failure是常态,所以实现这些协议有挑战。基于这些基本的考虑,Aurora的计算存储分离变成了,计算包括query processor, transactions, locking, buffer cache, access methods and undo management,而存储包括redo logging, durable storage, crash recovery, and backup/restore。为什么undo log不下沉呢,是因为这个问题不重要还是复杂度高?个人觉得undo log的处理跟一致性密切相关,也许不是那么容易下沉的。图1描述了这种架构,左上的可以看成计算层,左下是专为Aurora打造的智能分布式存储层(有数据库逻辑在里面),由多个AZ(可理解为机房,每个AZ具有独立的物理设施,通过高速网络互联),每个AZ内多个存储节点组成。注意VPC,其实有3个VPC,第三个是control panel跟实例之间的,作为公有云,安全是任何时候都要重视的问题。
文章正文主要介绍了其三个贡献,本文也主要学习这三个点。
第一个是如何设计quorum系统以抵抗关联失败(correlated failure)。Aurora一般部署在3AZ,关联失败的例子是一个AZ挂了,然后另外一个AZ的某台机器/磁盘也挂了。Quorum协议是[10],是NWR的理论基础。基本约束是N台机器,写成功要大于N/2,读+写要大于N。Aurora对容错的目标是:a) 如果一个AZ挂了,不影响写(除了挂掉的AZ外,另外2AZ的读当然也不影响);b) 如果一个AZ挂了,同时剩余2个AZ中又有一个机器/磁盘等挂了,不丢数据。如何保证这个目标呢?其做法是3AZ,每个AZ写2个replica,任何时候,只要4个replica写成功,就可以认为这份写是安全的。读的时候则要求读3,这样读跟写至少有一个交集,保证读到最新数据。此时很容易推导出来前面两个目标是能够达到的。任何一个AZ挂了,仍然会有4个replica可用,写不会中断,但是此时网络/磁盘抖动的影响可能会很可观。如果再有一个replica挂了,数据不会丢失,因为还有跨两个AZ的3份replica存在,满足读3个要求。
这里讨论一个问题,3个AZ,其中2个AZ写2份,一个AZ写1份,是不是也能满足上述目标?我想也是可以的。此时写要求>=3,读要求3。举例,比如AZ-1,AZ-2要求写2份replica,AZ-3写一份replica。a) 当AZ-1或者AZ-2挂,不影响写;AZ-3挂了当然也不影响写;b) 当AZ-1挂了,此时AZ-2一个node也挂了,则无法提供写,AZ-2可以从本AZ的存活节点快速恢复;如果此时AZ-3一个node挂了,无法提供写,但是AZ-3只有一个节点,需要向AZ-2请求replica。所以该方案的缺点是
某些情况下,replication流量会跨AZ
而该方案优点是,
关联失败场景下,部分实例能提供写:比如AZ-3挂了,此时AZ-1或者AZ-2一个node挂了,不影响写,在这种概率下,比文中方案具备更高的可用性。而如果对不同实例来说,2-2-1结构被打散在各个AZ,那么是否可以认为任何时候,一个AZ挂了,另外一个AZ有一个node挂了,此时总有1/3的实例是能提供写的
5份replica而不是6份节约了存储空间以及IOPS,利于性能提升
为什么不采用类似方案呢?我猜有两个理由,
文章一开头就说网络是新的约束,所以对AZ间网络流量应该尽可能节省
作者将重点放在了快速恢复上,尽最大努力降低MTTR(Mean Time to Repair)。实现方式是,将实例存储分为以10G为单位的segment。那么对每个segment内的replica而言,如果node挂了要恢复,在万兆网卡下,只要10s就可以(我怀疑这个值没有包括健康检测时间)。而且,还可以通过将10G变得更小进一步提高MTTR,或者用更快的网络。既然这么快,上面优势中的第一条就不是很重要了。
所以,考虑到工程复杂度(分布式系统里面太容易出错了,复杂度是一个重要决策依据),选择每个AZ 2个replica是合适的。不过从源头再想一想,关联失败场景下10s不可写是否需要进一步提高?如果要求如此苛刻,可能已经算不上Aurora的客户了。
接下来讨论第二个问题,为什么以及如何将存储下沉?图2展示了经典场景下MySQL主备在AWS上的工作模式。
从图中可以看到,写需要经过MySQL engine,EBS的primary/secondary(文中描述是chain replication,所以这个延时应该很可观)。主实例完成后,block 被replicate到只读实例,其也要经历类似步骤,忍受EBS同步的延时。为了数据安全,作者举了一个延时最差的例子。文章认为这个流程中最大的问题有两个,
不少操作是串行的,比如1,3,5,这会增加延时且碰到毛刺的概率也高
一个事务操作写了太多份数据出去,比如redo log、double write、replicated block
解决思路见图3。对问题1,通过将chain变成star缓解串行操作的延时和毛刺效应;对问题2,通过只写redo减少写出数据量。
既然只有redo下沉到了存储节点上,那么存储节点就得知道如何将redo变成page data,所以log applicator下沉也是必然的。不过这里有一个有意思的设计,redo log是可以直接当做page data用的(redo本来就是page change)。如果只是从正确性而言,每次启动后将所有的log apply一遍就可以,这显然太重了。现实中,很容易走另一个极端,就是redo来了,直接变成data,只要data 持久化完毕,redo就丢掉。对于那些很少修改的page来说,从redo 变成data是一种浪费,只有那些变动频繁的page会被变成data(materialization ),其余依旧保留redo的形态,参与读流程。这个设计也许可以称为lazy page materialization,将合并推迟到read参与的时候或者page change 太多的时候,有机会减少随机IO。lazy是系统优化中的一个重要手段,一般是系统设计之初就体现在顶层设计中。
试验证明,redo log下沉的设计比起经典模式,每个事务平均IO次数降低了85%+,而且数据安全性也是类似甚至更高的。
同时,crash recovery也更快了,因为每个存储节点需要replay的redo少了,还有就是laze recovery,存储节点启动后并不需要recovery做完才提供服务,而是可以立刻提供服务,如果请求的page需要做log apply,那么顺便recovery也做掉了(第三点贡献详细描述了这点)。
这里有个疑问,之前看到不少三方工具依赖bin log,文中没有提如何处理bin log。
文章继续描述了其在降低延时方面的努力,那就是主路径尽可能的减少操作,将更多的操作放到后台执行。图4显示,只要1 2 完成就可以返回,其余全部是后台执行,这对降低延时意义重大。这个模式在很多KV数据库里面也是大量使用的。同时,注意下左下角的peer 之间的gossip,这仍然是NWR里面的思路,不同节点之间会定期通信,从而将replica补足。这个gossip并不是说peer的数据不用client发送了,只是偶尔某个peer无法应答(client 4/6就算成功了)的时候client会将其忽略,所以他们只能通过互相询问补足数据。不过补足数据也是有个边界的,否则gossip协议流量会太大,这个边界的计算在文章后面做了说明,本文也会提到。
文章说的第三点贡献来自crash recovery、checkpoint机制,这些当然是以前面的设计为基础的。每个log都有一个log sequence number,LSN。任何一个用户层面的事务都可以拆解成多个mini-transaction,MTR,而每个MTR都可以由多个log records组成。我不了解MTR的说法,从文中描述来看,如果一个事务的某个操作修改了多个page,那么应该是产生多个log records的,因为两个page 很可能属于不同的segment,那么问题就来了,多个segment之间,靠什么约束一致性呢?因为前面说了,存储层只接受log,而log只有LSN,那么LSN应该是充当这个角色的很好的选择。
存储层分为两层,一层是segment,一层是每个segment内部的N个replica。对每个segment而言,如果按照quorum协议,其完成了某个LSN的持久化,那么这个最大的LSN称作Segment Complete LSN,SCL,这个可以用于上面说的gossip协议计算hot log边界,补充log gap。对segment整体而言,也就是存储服务而言,如果某个LSN是被W/N写成功的,那么就会产生一个Volumn Complete LSN,VCL,表示这个之前的log都安全了。然后事务(准确说是MTR)commit前的最后一条log对应的LSN会被标记Consistency Point LSNs(有多个), CPL。比VCL小的最大的CPL称为Volumn Durable LSN,VDL。commit LSN小于等于VDL的事务都被认为是完成的。这个有点绕,其实还是好理解的,比如有两个独立事务,T1 T2。他们交叉进行,产生的log如下,
T1 LSN=100, T2 LSN = 101, T1 LSN = 102, T1 LSN = 103(T1 commit), T2 LSN = 104, T2 LSN = 105(commit)
此时CPL是103, 105(只有这两条commit log完成了持久化对事务来说才有意义)。现在存储层持久化VDL只到了104,那么显然T1已经完成了,可以响应client成功,而T2还未完成,如果此时发生failover,那么103之后的都可以直接移除,103前面的需要更多的数据结构来记录以便GC。
上面说的是写和commit,读也是类似。主实例将redo log和一些meta信息异步发给只读实例,如果只读实例发现了log对应的page 在buffer cache里面就apply准备以后读,否则直接丢弃,如果以后需要读,可以直接向存储层发请求。读的时候并不需要做quorum,因为数据库知道读对应的VDL,只要选择VCL大于VDL的存储节点就可以了。当然,这里主备异步同步log会存在读过期数据的问题,这跟当前经典模式只读实例是类似的,Aurora并没有解决这个问题。个人认为这种不一致对业务是很难用的,但是,100% MySQL协议兼容和存储、计算可扩展同时做到显然不是件容易的事情。
文章最后的细节阐述了crash recovery,利用的手段前面已经描述了,主要将传统的先恢复再提供服务,改为了先提供服务,后台恢复,按需恢复。能这么做的原因应该归功于log applicator下沉到了存储层,可以独立工作。
整体看来,Aurora并没有一开始就要做一个完美的系统,而是先将计算存储分离开来,独立解决存储问题,这样做已经解决了很多用户的痛点,作为产品和架构权衡的一个例子,值得思考学习。
如无特殊说明,截图均来自论文[1]。
[1]. Amazon Aurora: Design Considerations for High Throughput Cloud-Native Relational Databases
[2]. BigTable:A Distributed Storage System forStructured Data
[3]. HBase: http://hbase.apache.org/
[4]. 表格存储:https://www.aliyun.com/product/ots
[5]. Spanner: Google’s Globally-Distributed Database
[6]. OceanBase: https://www.aliyun.com/product/oceanbase
[7]. TiDB: https://github.com/pingcap/tidb
[8]. CockroachDB: https://www.cockroachlabs.com/
[9]. PolarDB: https://www.aliyun.com/product/polardb
[10]. Weighted Voting for Replicated Data