JavaWebHao 2019-06-30
本文由云+社区发表本文作者:许中清,腾讯云自研数据库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会为每一个数据库分配一段存储空间,我们称之为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)完成类似数据库日志的功能,比如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进行进一步的增强。
此文已由作者授权腾讯云+社区发布