Amazon Aurora Design Considerations for High Throughput Cloud Native Relational Databases

󰃭 2024-08-24

写在最前

当你特别在乎性能, 找到一个良好抽象的概念, 打破它, 把脚伸进泥潭里.

传统的数据库都基于posix语义的文件系统来设计的. 当我知道我要在挂着EBS的EC2上跑数据库, 后面还有S3给我做备份, 一切都可以重新设计. 这就是云原生的魅力吧.

Background

这二年都上云, 云上都玩儿存算分离. 存算分离导致了数据库服务的磁盘IO负载瓶颈变成了网络IO负载瓶颈, 但为了高可用又到处复制还是搞得写放大严重. 而且为了数据一致性一些同步操作, 二阶段提交(2PC, Two Phase Commit)等操作更是对性能影响严重.

Aurora践行日志流即数据库的思想, 通过只持久化预写日志(WAL, Write Ahead Log, 和MySQL中的redo log是一个概念)减少写放大. 还通过单写实例模式避免复杂的共识问题.

设计思想

集群容错

第二章这部分也是分布式系统的老话了, 熟悉这个领域的人一看就懂. 说的其实就是 Quroum + Segmentation (文中称一组复制为PG, Protection Group).

Quorum模型, 即在分布式系统中, 每份数据副本都有一票, 共$V$票. 读写操作都需要保证获得的票数大于最小票数限制才可以进行操作. 虽小票数限制要保证: 1.$V_{read} + V_{write} \gt V$; 2. $V_{write} > \frac{V}{2}$. 规则1保证了数据不会被并发读写, 也就保证了读的时候可以读到至少一份最新的数据. 规则2保证了两个写入操作不会同时发生, 这两条规则保证了数据的串行化.

至于Segmentation, 无非就是将上述读写操作的对象拆分(或聚合)成合理的大小, 使之成为系统中检测失效和修复的最小单元. 讨论故障恢复时有两个常见概念: 平均故障发生时间(MTTF, Mean Time to Failure) 和平均故障修复时间(MTTR, Mean Time to Repair). 我们希望MTTF > MTTR以保证系统的高可用. 但MTTF很难优化, 所以Aurora专注缩短MTTR. 将数据段大小定为10GB, 这在10Gbps的带宽中仅需要10s就能传输完, 使得在10s内一个AZ加上另外两个AZ中的两个副本同时失效的概率降得非常低.

日志流即数据库

首先要明白的是, 即使是单机的数据库也存在写放大, 相同的数据以不同的形式被多次写, 比如redo log, page, binlog等. RDS本质上就是一个跑在EC2上的托管型数据库. 托管具体指的就是你的一切管理操作都是通过HostManager来进行的, 你无法访问底层的EC2. RDS在保证高可用的同时代价就是同步和写放大. RDS为了做PITR首先会将binlog同步在S3里. 单实例的RDS下, 数据库的任何写入都会在EBS的volume层级, 在相同的AZ中复制一份. 又在数据库实例层级, 进行了跨AZ的主从同步备份. 也就是说RDS中我们是Quromn 4/4.

那为了优化这个情况, Aurora选择仅持久化redo log, 并在储存层根据redo log重建page. 且同时Aurora在日志落盘的时刻就返回ACK, 将大部分操作放到后台异步进行. 这使得 Aurora相比 RDS提高了35倍的处理效率, 优化前的IO是优化后的7.7倍.

日志流的一致性保证

第四章是本篇论文的关键部分, 讨论了Aurora如何在不适用2PC, Paxos等复杂的分布式协议的情况下保证数据的一致性.

因为采用了单写入节点的架构, Aurora可以生成一个全序的日志流, 每个日志都会有一个由数据库生成的单调递增的LSN(Log Sequence Number). 因任何原因缺失任何部分日志的节点都可以互相gossip来查缺补漏, 这里并不需要法定人数因为你要的不是最新的.

重启后事务的修复逻辑和单机一样, 但必须保证存储层可以为数据库实例提供统一的视图, 也就是说重点是维护存储层的数据一致性.

存储服务可以判断目前为止已经持久化(4/6)了的日志中最高的LSN, 称之为VCL(Volume Complete LSN). 恢复阶段, 任何大于VCL的日志都会被丢弃. 然后, 通过将日志标记为CPL(Consistency Point LSN), 我们收束一个可以接受丢弃的LSN的子集. 此时, 我们定义VDL(Volume Durable LSN), VDL是所有小于等于VCL中最高的CPL, 我们丢弃高于VDL的所有日志. 例如, 我们有900, 1000, 1100都是CPLs. 在某一时刻我们出错重启了, 此时能确定的VCL是1007, 我们丢弃1000以后的所有日志. 虽然到1007都是完整的, 但只有到1000是持久的.

完整和持久是不同的. CPL被视作是划分存储系统事务的有限形式,必须按序接受. 如果不要则可视每一条日志为CPL. 实践中, 数据库引擎层和存储层有以下交互:

  1. 每个数据库级的事务被分解为多个有序且必须原子执行的迷你事务.
  2. 每个迷你事务由多个连续的日志组成.
  3. 每个迷你事物的最后一条日志是CPL.

基本操作

1.Writes

在Aurora中, 数据库实例向存储节点传递redo日志, 达成多数派后将事务标记为提交状态, 然后推进VDL, 使数据库进入一个新的一致状态. 在任何时刻, 数据库中都会并发运行着成千上万个事务, 每个事务的每条redo日志都会分配一个唯一的LSN, 这个LSN一定大于当前最新的VDL, 为了避免前台事务并发执行太快, 而存储服务的VDL推进不及时, 我们定义了LSN Allocation Limit(LAL), 目前定义的是10,000,000, 这个值表示新分配LSN与VDL的差值的最大阀值, 设置这个值的目的是避免存储服务成为瓶颈, 进而影响后续的写操作. 由于底层存储按segment分片, 每个分片管理一部分页面, 当一个事务涉及的修改跨多个分片时, 事务对应的日志被打散, 每个分片只能看到这个事务的部分日志. 为了确保各个分片日志的完整性, 每条日志都记录前一条日志的链接, 通过前向链接确保分片拥有了完整的日志. Segment Complete LSN(SCL)表示分片拥有完整日志的位点, 存储节点相互间通过gossip协议来弥补本地日志空洞, 推进SCL .

2.Commits

在Aurora中, 事务提交是完全异步的. 每个事务由若干个日志组成, 并包含有一个唯一的“commit LSN”, 工作线程处理事务提交请求时, 将事务相关的日志提交到持久化队列并将事务挂起, 并继续处理其它数据库请求. 当VDL的位点大于事务的commit LSN时, 表示这个事务redo日志都已经持久化, 可以向客户端回包, 通知事务已经成功执行. 在Aurora中, 有一个独立的线程处理事务成功执行的回包工作, 因此, 从整个提交流程来看, 所有工作线程不会因为事务提交等待日志推进而堵塞 , 他们会继续处理新的请求, 通过这种异步提交方式, 大大提高了系统的吞吐. 这种异步化提交思想目前比较普遍, AliSQL也采用类似的方式.

3.Reads

在Aurora中, 与大多数数据库一样, 数据页的请求一般都从缓冲池中获得, 当缓冲池中对应的数据页不存在时, 才会从磁盘中获取. 如果缓冲池满了, 根据特定的淘汰算法(比如LRU), 系统会选择将一个数据页淘汰置换出去, 如果被置换的数据页被修改过, 则首先需要将这个数据页刷盘, 确保下次访问这个页时, 能读到最新的数据. 但是Aurora不一样, 淘汰出去的数据页并不会刷盘写出, 而是直接丢弃. 这就要求Aurora缓冲池中的数据页一定有最新数据, 被淘汰的数据页的page-LSN需要小于或等于VDL. (注意, 这里论文中描述有问题, page-LSN<=VDL才能被淘汰, 而不是大于等于) 这个约束保证了两点:1.这个数据页所有的修改都已经在日志中持久化, 2.当缓存不命中时, 通过数据页和VDL总能得到最新的数据页版本.

在正常情况下, 进行读操作时并不需要达成Quorum. 当数据库实例需要读磁盘IO时, 将当前最新的VDL作为一致性位点read-point, 并选择一个拥有所有VDL位点的日志的节点作为请求节点, 这样只需要访问这一个节点即可得到数据页的最新版本. 从实现上来看, 因为所有数据页通过分片管理, 数据库实例记录了存储节点管理的分片以及SCL信息, 因此进行IO操作时, 通过元信息可以知道具体哪个存储节点有需要访问的数据页, 并且SCL>read-point. 数据库实例接收客户端的请求, 以PG为单位计算Minimum Read Point LSN, 在有读副本实例的情况下, 每个实例都都可以作类似的计算得到位点, 实例之间通过gossip协议得到全局的per-Group MRPL, 称之为PGMRPL. PGMRPL是全局read-point的低水位, 每个存储节点根据PGMRPL, 不断推进数据页版本, 并回收不再使用的日志.

4.Replicas

在Aurora中, 写副本实例和至多15个读副本实例共享一套分布式存储服务, 因此增加读副本实例并不会消耗更多的磁盘IO写资源和磁盘空间. 这也是共享存储的优势, 零存储成本增加新的读副本. 读副本和写副本实例间通过日志同步. 写副本实例往存储节点发送日志的同时向读副本发送日志, 读副本按日志顺序回放, 如果回放日志时, 对应数据页不在缓冲池中, 则直接丢弃. 可以丢弃的原因在于, 存储节点拥有所有日志, 当下次需要访问这个数据页时, 存储节点根据read-point, 可以构造出特定的数据页版本 需要说明的是, 写副本实例向读副本发送日志是异步的, 写副本执行提交操作并不受读副本的影响. 副本回放日志时需要遵守两个基本原则, 1.回放日志的LSN需要小于或等于VDL, 2.回放日志时需要以MTR为单位, 确保副本能看到一致性视图. 在实际场景下, 读副本与写副本的延时不超过20ms.

故障恢复

大多数数据库基于经典的ARIES协议处理故障恢复, 通过WAL机制确保故障时已经提交的事务持久化, 并回滚未提交的事务. 这类系统通常会周期性地做检查点, 并将检查点信息计入日志. 故障时, 数据页中同时可能包含了提交和未提交的数据, 因此, 在故障恢复时, 系统首先需要从上一个检查点开始回放日志, 将数据页恢复到故障时的状态, 然后根据undo日志回滚未交事务. 从故障恢复的过程来看, 故障恢复是一个比较耗时的操作, 并且与检查点操作频率强相关. 通过提高检查点频率, 可以减少故障恢复时间, 但是这直接会影响系统处理前台请求吞吐, 所以需要在检查点频率和故障恢复时间做一个权衡, 而在Aurora中不需要做这种权衡.

传统数据库中, 故障恢复过程通过回放日志推进数据库状态, 重做日志时整个数据库处于离线状态. Aurora也采用类似的方法, 区别在于将回放日志逻辑下推到存储节点, 并且在数据库在线提供服务时在后台常态运行. 因此, 当出现故障重启时, 存储服务能快速恢复, 即使在10wTPS的压力下, 也能在10s以内恢复. 数据库实例宕机重启后, 需要故障恢复来获得运行时的一致状态, 实例与Read Quorum个存储节点通信, 这样确保能读到最新的数据, 并重新计算新的VDL, 超过VDL部分的日志都可以被截断丢弃. 在Aurora中, 对于新分配的LSN范围做了限制, LSN与VDL差值的范围不能超过10,000,000, 这个主要是为了避免数据库实例上堆积过多的未提交事务, 因为数据库回放完redo日志后还需要做undo recovery, 将未提交的事务进行回滚. 在Aurora中, 收集完所有活跃事务后即可提供服务, 整个undo recovery过程可以在数据库online后再进行.