【51CTO.com原创稿件】行业背景
银行业从最初的手工记账到会计电算化,到金融电子化,再到现在的金融科技,可以看到金融与科技的结合越来越紧密,人工智能、大数据、物联网、区块链等新兴技术改变了金融的交易方式,为金融行业的创新前行提供了源源不断的动力。同时互联网金融的兴起是一把双刃剑,带来了机遇的同时也带来了挑战。
普惠金融使得金融的门槛降低,更多的普通大众参与到金融活动中,这让金融信息系统承受了越来越大的压力。于是我们可以看到大型商业银行、保险公司、证券公司、交易所等核心交易系统都在纷纷进行分布式改造,其中数据库作为有状态的应用,成为了信息系统中唯一的单点,承担了所有来自上层应用的压力。随着数据库瓶颈的凸显,进行分布式改造迫在眉睫。
数据库分布式改造的途径
数据库进行分布式改造主要有三种途径:分布式访问客户端、分布式访问中间件、分布式数据库。由于其分布式能力实现在不同的层次(应用层、中间层、数据库层),对应用程序有不同的侵入程度,其中分布式访问客户端对应用侵入性最大,改造难度最大,而分布式数据库方案对应用侵入性最小,但是架构设计及研发难度最大。
分布式数据库总体架构
其实当前市面上的分布式数据库总体架构都是类似的,由必不可少的三个组件组成:接入节点、数据节点、全局事务管理器。总体架构如下,接入节点负责sql解析,生成分布式执行计划,sql转发,数据汇总等;数据节点负责数据存储与运算;全局事务管理器负责全局事务号的生成,保证事务的全局一致性。这个架构或多或少都受到了Google Spanner F1论文的影响,这篇文章主要分析了这几个组件在实现上有什么难点,该如何进行架构设计。
两阶段提交的问题
我们知道两阶段提交是阻塞性协议,这也是它最大的问题。下图以pgxc架构下的两阶段提交为例,主要分为几个阶段:
①:CN prepare ->②:所有DN prepare ->③:CN commit->④:所有DN commit
试想一下如果在cn commit阶段发生cn/dn宕机会发生什么?
如果在cn下发完cn commit命令后宕机,这时dn收到commit命令后会进行提交,但是返回commit ok时发生cn宕机,事务进入阻塞状态。如果cn下发commit之后某个dn发生宕机,则会造成某些dn commit成功,某些dn commit失败,造成不一致,但是如果dn重新启动后会继续去cn上拿事务提交信息,发现是commit状态,则会继续执行commit操作,提交之前的事务。
在这个地方我们可以探讨一个更极端的情况,如果此时cn也宕机了,那么失败的dn重启后去cn拿状态发现拿不到,这时这个失败dn上的事务就处于一个未决态,不知道是应该提交还是回滚,这时候应该有一个进程能够从其他dn上发现该状态并报告给故障dn,通知它进行提交。这个角色就是pgxc_clean进程,其实之前几种情况下的事务的回滚也是该进程的工作。那我们再深入一下,如果该dn是事务的唯一参与者,那么此时pgxc_clean就无法从其他dn以及cn获取状态,这时该dn就是真正的未决态了。
为了解决两阶段提交的阻塞问题,出现了三阶段提交,三阶段提交在commit之前引入了cancommit的过程,同时加入超时机制。因为如果协调者发生宕机,参与者无法得知协调者到底发出的是commit还是abort,三阶段提交cancommit过程就是告知参与者我发送的是commit或者abort命令,这时如果协调者发生失败,参与者等待超时时间后可以选出新的协调者,而该协调者是知道应该发出什么命令。
虽然三阶段提交解决了阻塞问题,但是无法解决性能问题,分布式系统中为了保证事务一致性需要跟每个参与者通信,一个事务的提交和参与需要分布式系统中每个节点的参与,必然带来延时,不过在万兆、infiniband、roce高速网络的支持下已经不再是问题了。
CAP与BASE的抉择
我们知道分布式系统无法战胜CAP。那么在设计分布式系统的时候该如何进行取舍?首先P(分区容错性)是必须保证的,因为分布式系统必然是多个节点(分区)通过网络进行互联,而网络是不可靠的,分布式系统是为了避免单点故障,如果因为网络问题或者某些节点失败造成整体系统不可用,那么也不符合分布式系统的设计初衷。如果保证A(可用性),那么当网络失败时,网络隔离的不同区域就要继续提供服务,那么就会造成不同分区的数据不一致(脑裂);如果保证C(一致性),那么网络失败时,就需要等待不同网络分区的节点同步完数据,如果网络一直失败,那么系统就会因为无法同步而一直不可用。
2PC就是典型的牺牲可用性保证一致性的例子,而BASE(basically available,soft state,eventual consistency)就是牺牲一致性保证可用性的例子,因为做到实时的强一致要牺牲的代价太大了,它允许数据在某些时间窗口内的不一致,通过记录窗口内的每一个临时状态日志做到在系统故障时,通过日志继续完成未完成的工作或者取消已经完成的工作回退到初始状态,这种方式保证了最终一致性。BASE与传统ACID理论其实是背离的,满足BASE理论的事务也叫柔性事务,在遭遇失败时需要有相应的补偿机制,与业务耦合性较高,其实我并不是很赞同BASE的做法,因为它已经背离了数据库最基本的设计理念。
raft的优势
不管是上面的XA还是BASE都无法彻底解决一致性问题,真正意义上的强一致一定是基于强一致协议的。paxos和raft是目前主流的两种共识算法。Paxos诞生于学院派,是分布式环境下基于消息传递的共识算法,它设计之初是考虑一个通用的模型,并没有过多的考虑实际的应用,而且paxos考虑了多个节点同时写入的情况,这就使得paxos的状态机异常复杂,所以难以理解,不同的人可能理解出不同的意思,这一点一直遭人诟病,比如MGR引入write set的概念来处理多点写入冲突的问题,这在高并发热点数据的场景下是不可接受的。因为paxos的难以理解,斯坦福的两名大学生设计了raft算法,相比来说,raft是工业派,同一时刻leader只有一个,follower通过日志复制实现一致性,相比paxos来说raft的状态机更加简单易懂,实现起来也更加简单,因此在分布式环境上有着广泛的应用,例如TiDB、RadonDB、etcd、kubernetes等。
Raft协议将共识问题分解为三个子问题分别解决:leader选举、日志复制、安全性。
Leader选举:
服务器节点有三种状态:领导者、跟随者和候选者。正常情况下,系统中只有一个领导者,其他的节点全部都是跟随者,领导者处理全部客户端请求,跟随者不会主动发送任何请求,只是简单的响应来自领导者或者候选者的请求。如果跟随者接收不到消息(选举超时),那么他就会变成候选者并发起一次选举。获得集群中大多数选票的候选者将成为领导者,领导者一直都会是领导者直到自己宕机了。
Raft 算法把时间分割成任意长度的任期(term),每一段任期从一次选举开始,一个或者多个候选者尝试成为领导者。如果一个候选者赢得选举,然后他就在这个的任期内充当领导者。要开始一次选举过程,跟随者先要增加自己的当前任期号并且转换到候选者状态,然后他会并行的向集群中的其他服务器节点发送请求投票的 RPCs 来给自己投票,候选者会继续保持着当前状态直到以下三件事情之一发生:(a) 他赢得了这次的选举,(b) 其他服务器成为领导者,(c) 没有任何一个候选者赢得选举。当一个候选者获得了集群大多数节点针对同一个任期号的选票,那么他就赢得了选举并成为领导者。然后他会向其他的服务器发送心跳消息来建立自己的权威并且阻止新的领导人的产生。下图为三种角色的转换状态机。
日志复制:
当leader被选举出来,他就作为服务器处理客户端请求。客户端的每一个请求都被看成复制状态机所需要执行的指令。领导者把这条指令作为一条新的日志条目附加到日志中去,然后并行的发起附加条目 RPCs 给其他的服务器,让他们复制这条日志条目。当这条日志条目被安全的复制,领导者会应用这条日志条目到它的状态机中然后把执行的结果返回给客户端。如果跟随者崩溃或者网络丢包,领导者会不断的重复尝试附加日志条目 RPCs (尽管已经回复了客户端)直到所有的跟随者都最终存储了所有的日志条目。下图为复制状态机模型。
安全性:
安全性指的是每台复制状态机都需要按照同样的顺序执行相同的指令,以保证每台服务器数据的一致性。假想一台跟随者在某段时间处于不可用状态,后来可能被选为领导者,这时就会造成之前的日志被覆盖。Raft算法通过在leader选举时增加一些限制来避免这个问题,这一限制保证所有领导者对于给定的任期号,都拥有了之前任期的所有被提交的日志条目。日志条目只会从领导者传给跟随者,不会出现因为新领导者缺日志而需要跟随者向领导者传日志的情况,并且领导者从不会覆盖本地日志中已经存在的条目。Raft 算法使得在投票时投票者拒绝掉那些日志没有自己新的投票请求,从而阻止该候选者赢得选票。
CN的设计
接入节点的设计可能看起来很简单,但是里面有些地方内容还是有些玄机的。设计cn需要重点考量的地方主要是cn到底是做重还是做轻。这是把双刃剑,主要有下面两方面问题。
1.如何做到sql语法兼容性?
接入节点主要负责sql的解析、执行计划的生成与下发,这些东西其实是sql解析器做的事情,我们可以直接将MySQL或者pg的解析器甚至server层拿过来做sql解析和执行计划生成,而且就天然的兼容了MySQL或者pg的语法。
2.如何处理元数据的问题?
上面的方案看似很完美的事情,但是有个问题:如果直接将MySQL或者pg的server层搬过来的话,元数据怎么办?cn上到底放不放元数据?如果不放元数据,那么就需要一个统一的存放和管理元数据的地方,我在cn上建的表需要到某个固定地方更新元数据信息,查询也是一样。如果cn上存放元数据,那么元数据的更新就需要在各个cn之间进行同步,如果发生某个cn宕机,则任何ddl操作都会hang住,这时就需要有一个机制:在cn超时无响应后将cn剔除出集群。
DN的设计
数据节点的设计主要考虑下面几个方面问题。
1.数据节点如何做高可用?
数据库的数据当然是最宝贵的,任何数据库都要有数据冗余方案,数据节点一定要有高可用,在保证rpo=0的基础上尽量缩短rto。细想一下,其实每个dn都是一个数据库实例,这里以MySQL或者pg为例,MySQL和pg本身是有高可用方案的,不管是基于主从半同步还是流复制,都可以在dn层面作为数据的冗余和切换方案。当然还有些数据库在dn层面引入了paxos、raft、quorum等的强一致方案,这也是在分布式数据库中很常见的设计。
2.如何做到在线扩容?
在线扩容是分布式数据库的一项巨大优点,而扩容数据节点必然涉及到数据向新节点的迁移,目前市面上的分布式数据库基本上都做到了自动的数据重分布。但是做到数据库自动重分布还不够,如何做到只迁移少部分数据以降低服务器IO压力成为关键问题。
传统的散列方式是根据分区键哈希值对分区数量进行取模操作,得到的结果就是数据应该落入的分区,但是这种分布方法在增加删除节点时会造成大量的数据重分布,而一致性哈希的核心思想是每个分区不再是对应一个数字,而是对应一个范围,对计算的散列值进行范围的匹配,大体思路是将数据节点和键的hash值都映射到0~2^32的圆环上,然后从映射值的位置开始顺时针查找,将数据保存到找到的第一个节点上。如果超过2^32仍然找不到服务节点,就会保存到第一个节点上。一致性哈希最大程度解决了数据重分布问题,但是可能会造成节点数据分布不均匀的问题,当然针对这个问题还有一些改进,比如增加虚拟节点。
GTM的设计
GTM顾名思义是一个全局概念,分布式数据库本来就是为了可扩展、提升性能、降低全局风险,然而GTM这个东西打破了这一切。
1.为什么需要GTM?
简单一句话总结就是:GTM是为了保证全局读一致性,而两阶段提交是为了保证写一致性。这里我们可能有个误区,如果没有GTM那么会不会造成数据不一致?会,但是只是某个时间点读的不一致,这个不一致也是暂时的,但是不会造成数据写的不一致,写的一致性通过两阶段提交来保证。
我们知道postgresql通过快照(snapshot)来实现MVCC与事务可见性判断。对于read commit隔离级别,要求每个事务中的查询仅能看到在该事务启动前已经提交的更改,以及当前事务中该查询之前所做的更改,这都要通过快照来实现。快照的数据结构中会包含事务的xmin(插入tuple的事务号)、xmax(更新或者删除事务的事务号)、正在运行的事务列表等相关信息。pg的每条元组(tuple)头信息中也会记录事务的xmin和xmax信息。Pg取得snapshot后会进行事务可见性判断,对于所有id小于xmin的tuple对当前快照可见,同时id大于xmax的tuple对当前事务可见。当前扩展到分布式集群后,每台机器上都存在pg的实例,为了保证全局的读一致性,需要一个全局的组件来负责snapshot的分配,使得快照信息在各个节点之间共享,这就是GTM的工作。
2.GTM高可用的问题?
GTM作为分配全局快照和事务id的唯一组件,只能有一个,当然GTM可以做主备高可用,但是同一时刻只能有一个GTM在工作,gxid信息在主备之间进行同步,这样就造成一个问题,虽然其他节点都分布式了,但是GTM始终是一个单点,单点故障时就会涉及到切换,切换过程是影响全局的,而且为了保证切换后gxid信息不丢失,GTM之间必须做到gxid的同步。
针对高可用这块问题,可以将GTM的事务号存储信息剥离,将事务号信息存在第三方存储中,例如etcd就是个很好的选择,etcd是个强一致高可用的分布式存储集群,etcd比较轻量,适合用来存储事务号信息,同时它自身保证了高可用与强一致,这时GTM就不需要在主备之间同步gxid,如果发生主备切换,新主GTM只需要再去从etcd中取得最新事务号,写事务号也同理,主GTM会向主etcd节点写入事务号信息,通过etcd自身的raft复制协议保证一致性。这样的设计使得GTM的压力减轻很多。
3.GTM性能的问题?
GTM是大部分分布式数据库的性能瓶颈,它使得一套集群的整体性能甚至不如一台单机。也很好理解,任何一个事务开启都要先通过cn到GTM取事务号和快照信息,然后结果解析后下发到dn执行,然后cn进行汇总再返回给应用,路径很明显变长了,那么效率肯定变低,目前优势在于可以利用多台机器的组合能力进行计算,计算资源得到了扩展。
针对GTM的瓶颈问题当然也有解决方案,比如华为GaussDB就提出GTM-Free和GTM-Lite,gtm-free是在那种强一致读要求不高的场景下关闭GTM的功能,所有事物都不走GTM,这种情况下性能基本能够得到线性提升,该功能已经实现;gtm-lite是将事务分类,全局事务就走GTM,本地事务就直接下发,因为大多数情况下都是本地事务,所有性能提升也很明显,该功能还在研发阶段。
分布式数据库如何实现PITR
数据库的PITR一般都是通过一个基础备份加上持续不间断的wal归档来做到的,这个基础备份可以是在线的,因为它并不需要数据库当时处于一致性状态,一致性可以通过replay redo来实现,所以基础备份可以是文件系统tar命令而不需要文件系统级别的快照。
PITR是通过基础备份加上redo日志能够恢复到任意时间点,这个任意时间点不同数据库有不同定义,可能是某个lsn,可能是某个snapshot,可能是某个timestamp。Postgresql数据库中能够基于redo恢复到任意的timestamp。
分布式数据库的PITR理论上和单机区别不大,每个节点备份自己的基础数据,这个数据不需要一致性,但是要考虑到分布式事务的问题,在做基础备份的时候必须保证之前的分布式事务(如果存在)已经全部完成,因为分布式事务是走两阶段提交协议,2PC在提交阶段不同的机器commit肯定有时间差,如果在这个时间差做了备份,会发现最后一台机器有这个事务的redo,另一台没有,这样恢复的话就会造成数据不一致。这个问题可以通过pg中一个barries的概念实现,在分布式事务结束后打一个barrier,获得一致性点,然后再进行基础备份。对于redo的前滚来说,只需要将所有节点的redo前滚到一个一致性位点即可。
作者介绍:
张小海,就职于某大型商业银行,目前主要负责数据库管理及新技术研究,PostgreSQL技术推广者,个人公众号:数据库架构之美。
【51CTO原创稿件,合作站点转载请注明原文作者和出处为51CTO.com】