一个数据库存储架构的独白

40次阅读

共计 9031 个字符,预计需要花费 23 分钟才能阅读完成。

本文由云 + 社区发表本文作者:许中清,腾讯云自研数据库 CynosDB 的分布式存储 CynosStore 负责人。从事数据库内核开发、数据库产品架构和规划。曾就职于华为,2015 年加入腾讯,参与过 TBase(PGXZ)、CynosDB 等数据库产品研发。专注于关系数据库、数据库集群、新型数据库架构等领域。目前担任 CynosDB 的分布式存储 CynosStore 负责人。

企业 IT 系统迁移到公有云上已然是正在发生的趋势。数据库服务,作为公有云上提供的关键组件,是企业客户是否愿意将自己运行多年的系统搬到云上的关键考量之一。另一方面,自从 System R 开始,关系数据库系统已经大约四十年的历史了。尤其是随着互联网的发展,业务对数据库实例的吞吐量要求越来越高。对于很多业务来说,单个物理机器所能提供的最大吞吐量已经不能满足业务的高速发展。因此,数据库集群是很多 IT 系统绕不过去的坎。
CynosDB for PostgreSQL 是腾讯云自研的一款云原生数据库,其主要核心思想来自于亚马逊的云数据库服务 Aurora。这种核心思想就是“基于日志的存储”和“存储计算分离”。同时,CynosDB 在架构和工程实现上确实有很多和 Aurora 不一样的地方。CynosDB 相比传统的单机数据库,主要解决如下问题:
存算分离
存算分离是云数据库区别于传统数据库的主要特点之一,主要是为了 1)提升资源利用效率,用户用多少资源就给多少资源;2)计算节点无状态更有利于数据库服务的高可用性和集群管理(故障恢复、实例迁移)的便利性。
存储自动扩缩容
传统关系型数据库会受到单个物理机器资源的限制,包括单机上存储空间的限制和计算能力的限制。CynosDB 采用分布式存储来突破单机存储限制。另外,存储支持多副本,通过 RAFT 协议来保证多副本的一致性。
更高的网络利用率
通过基于日志的存储设计思路,大幅度降低数据库运行过程中的网络流量。
更高的吞吐量
传统的数据库集群,面临的一个关键问题是:分布式事务和集群吞吐量线性扩展的矛盾。也就是说,很多数据库集群,要么支持完整的 ACID,要么追求极好的线性扩展性,大部分时候鱼和熊掌不可兼得。前者比如 Oracle RAC,是目前市场上最成熟最完善的数据库集群,提供对业务完全透明的数据访问服务。但是 Oracle RAC 的线性扩展性却被市场证明还不够,因此,更多用户主要用 RAC 来构建高可用集群,而不是高扩展的集群。后者比如 Proxy+ 开源 DB 的数据库集群方案,通常能提供很好的线性扩展性,但是因为不支持分布式事务,对数据库用户存在较大的限制。又或者可以支持分布式事务,但是当跨节点写入比例很大时,反过来降低了线性扩展能力。CynosDB 通过采用一写多读的方式,利用只读节点的线性扩展来提升整个系统的最大吞吐量,对于绝大部份公有云用户来说,这就已经足够了。
存储自动扩缩容
传统关系型数据库会受到单个物理机器资源的限制,包括单机上存储空间的限制和计算能力的限制。CynosDB 采用分布式存储来突破单机存储限制。另外,存储支持多副本,通过 RAFT 协议来保证多副本的一致性。
更高的网络利用率
通过基于日志的存储设计思路,大幅度降低数据库运行过程中的网络流量。
更高的吞吐量
传统的数据库集群,面临的一个关键问题是:分布式事务和集群吞吐量线性扩展的矛盾。也就是说,很多数据库集群,要么支持完整的 ACID,要么追求极好的线性扩展性,大部分时候鱼和熊掌不可兼得。前者比如 Oracle RAC,是目前市场上最成熟最完善的数据库集群,提供对业务完全透明的数据访问服务。但是 Oracle RAC 的线性扩展性却被市场证明还不够,因此,更多用户主要用 RAC 来构建高可用集群,而不是高扩展的集群。后者比如 Proxy+ 开源 DB 的数据库集群方案,通常能提供很好的线性扩展性,但是因为不支持分布式事务,对数据库用户存在较大的限制。又或者可以支持分布式事务,但是当跨节点写入比例很大时,反过来降低了线性扩展能力。CynosDB 通过采用一写多读的方式,利用只读节点的线性扩展来提升整个系统的最大吞吐量,对于绝大部份公有云用户来说,这就已经足够了。
下图为 CynosDB for PostgreSQL 的产品架构图,CynosDB 是一个基于共享存储、支持一写多读的数据库集群。
CynosDB for PostgreSQL 产品架构图
图一 CynosDB for PostgreSQL 产品架构图
CynosDB 基于 CynosStore 之上,CynosStore 是一个分布式存储,为 CynosDB 提供坚实的底座。CynosStore 由多个 Store Node 和 CynosStore Client 组成。CynosStore Client 以二进制包的形式与 DB(PostgreSQL)一起编译,为 DB 提供访问接口,以及负责主从 DB 之间的日志流传输。除此之外,每个 Store Node 会自动将数据和日志持续地备份到腾讯云对象存储服务 COS 上,用来实现 PITR(即时恢复)功能。
一、CynosStore 数据组织形式
CynosStore 会为每一个数据库分配一段存储空间,我们称之为 Pool,一个数据库对应一个 Pool。数据库存储空间的扩缩容是通过 Pool 的扩缩容来实现的。一个 Pool 会分成多个 Segment Group(SG),每个 SG 固定大小为 10G。我们也把每个 SG 叫做一个逻辑分片。一个 Segment Group(SG)由多个物理的 Segment 组成,一个 Segment 对应一个物理副本,多个副本通过 RAFT 协议来实现一致性。Segment 是 CynosStore 中最小的数据迁移和备份单位。每个 SG 保存属于它的数据以及对这部分数据最近一段时间的写日志。
CynosStore 数据组织形式
图二 CynosStore 数据组织形式
图二中 CynosStore 一共有 3 个 Store Node,CynosStore 中创建了一个 Pool,这个 Pool 由 3 个 SG 组成,每个 SG 有 3 个副本。CynosStore 还有空闲的副本,可以用来给当前 Pool 扩容,也可以创建另一个 Pool,将这空闲的 3 个 Segment 组成一个 SG 并分配个这个新的 Pool。
二、基于日志异步写的分布式存储
传统的数据通常采用 WAL(日志先写)来实现事务和故障恢复。这样做最直观的好处是 1)数据库 down 机后可以根据持久化的 WAL 来恢复数据页。2)先写日志,而不是直接写数据,可以在数据库写操作的关键路径上将随机 IO(写数据页)变成顺序 IO(写日志),便于提升数据库性能。
基于日志的存储
图三 基于日志的存储
图三(左)极度抽象地描述了传统数据库写数据的过程:每次修改数据的时候,必须保证日志先持久化之后才可以对数据页进行持久化。触发日志持久化的时机通常有
1)事务提交时,这个事务产生的最大日志点之前的所有日志必须持久化之后才能返回给客户端事务提交成功;
2)当日志缓存空间不够用时,必须持久化之后才能释放日志缓存空间;
3)当数据页缓存空间不够用时,必须淘汰部分数据页来释放缓存空间。比如根据淘汰算法必须要淘汰脏页 A,那么最后修改 A 的日志点之前的所有日志必须先持久化,然后才可以持久化 A 到存储,最后才能真正从数据缓存空间中将 A 淘汰。
从理论上来说,数据库只需要持久化日志就可以了。因为只要拥有从数据库初始化时刻到当前的所有日志,数据库就能恢复出当前任何一个数据页的内容。也就是说,数据库只需要写日志,而不需要写数据页,就能保证数据的完整性和正确性。但是,实际上数据库实现者不会这么做,因为 1)从头到尾遍历日志恢复出每个数据页将是非常耗时的;2)全量日志比数据本身规模要大得多,需要更多的磁盘空间去存储。
那么,如果持久化日志的存储设备不仅仅具有存储能力,还拥有计算能力,能够自行将日志重放到最新的页的话,将会怎么样?是的,如果这样的话,数据库引擎就没有必要将数据页传递给存储了,因为存储可以自行计算出新页并持久化。这就是 CynosDB“采用基于日志的存储”的核心思想。图三(右)极度抽象地描述了这种思想。图中计算节点和存储节点置于不同的物理机,存储节点除了持久化日志以外,还具备通过 apply 日志生成最新数据页面的能力。如此一来,计算节点只需要写日志到存储节点即可,而不需要再将数据页传递给存储节点。
下图描述了采用基于日志存储的 CynosStore 的结构。
基于日志的存储
图四 CynosStore:基于日志的存储
此图描述了数据库引擎如何访问 CynosStore。数据库引擎通过 CynosStore Client 来访问 CynosStore。最核心的两个操作包括 1)写日志;2)读数据页。
数据库引擎将数据库日志传递给 CynosStore,CynosStore Client 负责将数据库日志转换成 CynosStore Journal,并且负责将这些并发写入的 Journal 进行序列化,最后根据 Journal 修改的数据页路由到不同的 SG 上去,并发送给 SG 所属 Store Node。另外,CynosStore Client 采用异步的方式监听各个 Store Node 的日志持久化确认消息,并将归并之后的最新的持久化日志点告诉数据库引擎。
当数据库引擎访问的数据页在缓存中不命中时,需要向 CynosStore 读取需要的页(read block)。read block 是同步操作。并且,CynosStore 支持一定时间范围的多版本页读取。因为各个 Store Node 在重放日志时的步调不能完全做到一致,总会有先有后,因此需要读请求发起者提供一致性点来保证数据库引擎所要求的一致性,或者默认情况下由 CynosStore 用最新的一致性点(读点)去读数据页。另外,在一写多读的场景下,只读数据库实例也需要用到 CynosStore 提供的多版本特性。
CynosStore 提供两个层面的访问接口:一个是块设备层面的接口,另一个是基于块设备的文件系统层面的接口。分别叫做 CynosBS 和 CynosFS,他们都采用这种异步写日志、同步读数据的接口形式。那么,CynosDB for PostgreSQL,采用基于日志的存储,相比一主多从 PostgreSQL 集群来说,到底能带来哪些好处?
1)减少网络流量。首先,只要存算分离就避免不了计算节点向存储节点发送数据。如果我们还是使用传统数据库 + 网络硬盘的方式来做存算分离(计算和存储介质的分离),那么网络中除了需要传递日志以外,还需要传递数据,传递数据的大小由并发写入量、数据库缓存大小、以及 checkpoint 频率来决定。以 CynosStore 作为底座的 CynosDB 只需要将日志传递给 CynosStore 就可以了,降低网络流量。
2)更加有利于基于共享存储的集群的实现:一个数据库的多个实例(一写多读)访问同一个 Pool。基于日志写的 CynosStore 能够保证只要 DB 主节点(读写节点)写入日志到 CynosStore,就能让从节点(只读节点)能够读到被这部分日志修改过的数据页最新版本,而不需要等待主节点通过 checkpoint 等操作将数据页持久化到存储才能让读节点见到最新数据页。这样能够大大降低主从数据库实例之间的延时。不然,从节点需要等待主节点将数据页持久化之后(checkpoint)才能推进读点。如果这样,对于主节点来说,checkpoint 的间隔太久的话,就会导致主从延时加大,如果 checkpoint 间隔太小,又会导致主节点写数据的网络流量增大。
当然,apply 日志之后的新数据页的持久化,这部分工作总是要做的,不会凭空消失,只是从数据库引擎下移到了 CynosStore。但是正如前文所述,除了降低不必要的网络流量以外,CynosStore 的各个 SG 是并行来做 redo 和持久化的。并且一个 Pool 的 SG 数量可以按需扩展,SG 的宿主 Store Node 可以动态调度,因此可以用非常灵活和高效的方式来完成这部分工作。

三、CynosStore Journal(CSJ)

CynosStore Journal(CSJ)完成类似数据库日志的功能,比如 PostgreSQL 的 WAL。CSJ 与 PostgreSQL WAL 不同的地方在于:CSJ 拥有自己的日志格式,与数据库语义解耦合。PostgreSQL WAL 只有 PostgreSQL 引擎可以生成和解析,也就是说,当其他存储引擎拿到 PostgreSQL WAL 片段和这部分片段所修改的基础页内容,也没有办法恢复出最新的页内容。CSJ 致力于定义一种与各种存储引擎逻辑无关的日志格式,便于建立一个通用的基于日志的分布式存储系统。CSJ 定了 5 种 Journal 类型:
1.SetByte:用 Journal 中的内容覆盖指定数据页中、指定偏移位置、指定长度的连续存储空间。
2. SetBit:与 SetByte 类似,不同的是 SetBit 的最小粒度是 Bit,例如 PostgreSQL 中 hitbit 信息,可以转换成 SetBit 日志。
3. ClearPage:当新分配 Page 时,需要将其初始化,此时新分配页的原始内容并不重要,因此不需要将其从物理设备中读出来,而仅仅需要用一个全零页写入即可,ClearPage 就是描述这种修改的日志类型。
4. DataMove:有一些写入操作将页面中一部分的内容移动到另一个地方,DataMove 类型的日志用来描述这种操作。比如 PostgreSQL 在 Vacuum 过程中对 Page 进行 compact 操作,此时用 DataMove 比用 SetByte 日志量更小。
5. UserDefined:数据库引擎总会有一些操作并不会修改某个具体的页面内容,但是需要存放在日志中。比如 PostgreSQL 的最新的事务 id(xid)就是存储在 WAL 中,便于数据库故障恢复时知道从那个 xid 开始分配。这种类型日志跟数据库引擎语义相关,不需要 CynosStore 去理解,但是又需要日志将其持久化。UserDefined 就是来描述这种日志的。CynosStore 针对这种日志只负责持久化和提供查询接口,apply CSJ 时会忽略它。
以上 5 种类型的 Journal 是存储最底层的日志,只要对数据的写是基于块 / 页的,都可以转换成这 5 种日志来描述。当然,也有一些引擎不太适合转换成这种最底层的日志格式,比如基于 LSM 的存储引擎。
CSJ 的另一个特点是乱序持久化,因为一个 Pool 的 CSJ 会路由到多个 SG 上,并且采用异步写入的方式。而每个 SG 返回的 journal ack 并不同步,并且相互穿插,因此 CynosStore Client 还需要将这些 ack 进行归并并推进连续 CSJ 点(VDL)。
CynosStore 日志路由和乱序 ACK
图五 CynosStore 日志路由和乱序 ACK
只要是连续日志根据数据分片路由,就会有日志乱序 ack 的问题,从而必须对日志 ack 进行归并。Aurora 有这个机制,CynosDB 同样有。为了便于理解,我们对 Journal 中的各个关键点的命名采用跟 Aurora 同样的方式。
这里需要重点描述的是 MTR,MTR 是 CynosStore 提供的原子写单位,CSJ 就是由一个 MTR 紧挨着一个 MTR 组成的,任意一个日志必须属于一个 MTR,一个 MTR 中的多条日志很有可能属于不同的 SG。针对 PostgreSQL 引擎,可以近似理解为:一个 XLogRecord 对应一个 MTR,一个数据库事务的日志由一个或者多个 MTR 组成,多个数据库并发事务的 MTR 可以相互穿插。但是 CynosStore 并不理解和感知数据库引擎的事务逻辑,而只理解 MTR。发送给 CynosStore 的读请求所提供的读点必须不能在一个 MTR 的内部某个日志点。简而言之,MTR 就是 CynosStore 的事务。
四、故障恢复
当主实例发生故障后,有可能这个主实例上 Pool 中各个 SG 持久化的日志点在全局范围内并不连续,或者说有空洞。而这些空洞所对应的日志内容已经无从得知。比如有 3 条连续的日志 j1, j2, j3 分别路由到三个 SG 上,分别为 sg1, sg2, sg3。在发生故障的那一刻,j1 和 j3 已经成功发送到 sg1 和 sg3。但是 j2 还在 CynosStore Client 所在机器的网络缓冲区中,并且随着主实例故障而丢失。那么当新的主实例启动后,这个 Pool 上就会有不连续的日志 j1, j3,而 j2 已经丢失。
当这种故障场景发生后,新启动的主实例将会根据上次持久化的连续日志 VDL,在每个 SG 上查询自从这个 VDL 之后的所有日志,并将这些日志进行归并,计算出新的连续持久化的日志号 VDL。这就是新的一致性点。新实例通过 CynosStore 提供的 Truncate 接口将每个 SG 上大于 VDL 的日志 truncate 掉,那么新实例产生的第一条 journal 将从这个新的 VDL 的下一条开始。
故障恢复时日志恢复过程
图六:故障恢复时日志恢复过程
如果图五刚好是某个数据库实例故障发生的时间点,当重新启动一个数据库读写实例之后,图六就是计算新的一致性点的过程。CynosStore Client 会计算得出新的一致性点就是 8,并且把大于 8 的日志都 Truncate 掉。也就是把 SG2 上的 9 和 10truncate 掉。下一个产生的日志将会从 9 开始。
五、多副本一致性
CynosStore 采用 Multi-RAFT 来实现 SG 的多副本一致性,CynosStore 采用批量和异步流水线的方式来提升 RAFT 的吞吐量。我们采用 CynosStore 自定义的 benchmark 测得单个 SG 上日志持久化的吞吐量为 375 万条 / 每秒。CynosStore benchmark 采用异步写入日志的方式测试 CynosStore 的吞吐量,日志类型包含 SetByte 和 SetBit 两种,写日志线程持续不断地写入日志,监听线程负责处理 ack 回包并推进 VDL,然后 benchmark 测量单位时间内 VDL 的推进速度。375 万条 / 秒意味着每秒钟一个 SG 持久化 375 万条 SetByte 和 SetBit 日志。在一个 SG 的场景下,CynosStore Client 到 Store Node 的平均网络流量 171MB/ 每秒,这也是一个 Leader 到一个 Follower 的网络流量。
六、一写多读
CynosDB 基于共享存储 CynosStore,提供对同一个 Pool 上的一写多读数据库实例的支持,以提升数据库的吞吐量。基于共享存储的一写多读需要解决两个问题:
1. 主节点(读写节点)如何将对页的修改通知给从节点(只读节点)。因为从节点也是有 Buffer 的,当从节点缓存的页面在主节点中被修改时,从节点需要一种机制来得知这个被修改的消息,从而在从节点 Buffer 中更新这个修改或者从 CynosStore 中重读这个页的新版本。
2. 从节点上的读请求如何读到数据库的一致性的快照。开源 PostgreSQL 的主备模式中,备机通过利用主机同步过来的快照信息和事务信息构造一个快照(活动事务列表)。CynosDB 的从节点除了需要数据库快照(活动事务列表)以外,还需要一个 CynosStore 的快照(一致性读点)。因为分片的日志时并行 apply 的。
如果一个一写多读的共享存储数据库集群的存储本身不具备日志重做的能力,主从内存页的同步有两种备选方案:
第一种备选方案,主从之间只同步日志。从实例将至少需要保留主实例自从上次 checkpoint 以来所有产生的日志,一旦从实例产生 cache miss,只能从存储上读取上次 checkpoint 的 base 页,并在此基础上重放日志缓存中自上次 checkpoint 以来的所有关于这个页的修改。这种方法的关键问题在于如果主实例 checkpoint 之间的时间间隔太长,或者日志量太大,会导致从实例在命中率不高的情况下在 apply 日志上耗费非常多的时间。甚至,极端场景下,导致从实例对同一个页会反复多次 apply 同一段日志,除了大幅增大查询时延,还产生了很多没必要的 CPU 开销,同时也会导致主从之间的延时有可能大幅增加。
第二种备选方案,主实例向从实例提供读取内存缓冲区数据页的服务,主实例定期将被修改的页号和日志同步给从实例。当读页时,从实例首先根据主实例同步的被修改的页号信息来判断是 1)直接使用从实例自己的内存页,还是 2)根据内存页和日志重放新的内存页,还是 3)从主实例拉取最新的内存页,还是 4)从存储读取页。这种方法有点类似 Oracle RAC 的简化版。这种方案要解决两个关键问题:1)不同的从实例从主实例获取的页可能是不同版本,主实例内存页服务有可能需要提供多版本的能力。2)读内存页服务可能对主实例产生较大负担,因为除了多个从实例的影响以外,还有一点就是每次主实例中的某个页哪怕修改很小的一部分内容,从实例如果读到此页则必须拉取整页内容。大致来说,主实例修改越频繁,从实例拉取也会更频繁。
相比较来说,CynosStore 也需要同步脏页,但是 CynosStore 的从实例获取新页的方式要灵活的多有两种选择 1)从日志重放内存页;2)从 StoreNode 读取。从实例对同步脏页需要的最小信息仅仅是到底哪些页被主实例给修改过,主从同步日志内容是为了让从实例加速,以及降低 Store Node 的负担。
CynosDB 一写多读
图七 CynosDB 一写多读
图七描述了一写一读(一主一从)的基本框架,一写多读(一主多从)就是一写一读的叠加。CynosStore Client(CSClient)运行态区分主从,主 CSClient 源源不断地将 CynosStore Journal(CSJ)从主实例发送到从实例,与开源 PostgreSQL 主备模式不同的是,只要这些连续的日志到达从实例,不用等到这些日志全部 apply,DB engine 就可以读到这些日志所修改的最新版本。从而降低主从之间的时延。这里体现“基于日志的存储”的优势:只要主实例将日志持久化到 Store Node,从实例即可读到这些日志所修改的最新版本数据页。
七、结语
CynosStore 是一个完全从零打造、适应云数据库的分布式存储。CynosStore 在架构上具备一些天然优势:1)存储计算分离,并且把存储计算的网络流量降到最低;2)提升资源利用率,降低云成本,3)更加有利于数据库实例实现一写多读,4)相比一主两从的传统 RDS 集群具备更高的性能。除此之外,后续我们会在性能、高可用、资源隔离等方面对 CynosStore 进行进一步的增强。
此文已由作者授权腾讯云 + 社区发布

正文完
 0