分布式场景下如何进行快照读是一个很常见的问题,因为在这种场景下极易读取到分布式事务的“中间状态”。针对这一点,腾讯云数据库TDSQL设计了全局一致性读计划,解决了分布式节点间数据的读一致性问题。
近日腾讯云数据库专家工程师张文就在第十二届中国数据库技术大会上为大家分享了“TDSQL全局一致性读技术”。以下是分享实录:
1. 分布式下一致性读问题
近年来很多企业都会倒退本人的分布式数据库利用,一种常见的倒退路线是基于开源MySQL,典型计划有共享存储计划、分表计划,TDSQL架构是一种典型的分区表计划。
以图例的银行场景为例,是一种典型的基于MySQL分布式架构,前端为SQL引擎,后端以MySQL作为存储引擎,整体上计算与存储相拆散,各自实现横向扩大。
银行的转账业务个别是先扣款再加余额,整个交易为一个分布式事务。分布式事务基于两阶段提交,保障了交易的最终一致性,但无奈保障读一致性。
转账操作先给A账户扣款再给B账户减少余额,这两个操作要么都胜利,要么都不胜利,不会呈现一个胜利一个不胜利,这就是分布式事务。在分布式数据库下,各节点绝对独立,一边做扣款的同时另一边可能曾经减少余额胜利。在某个节点的存储引擎外部,如果事务没有实现提交,那么SQL引擎对于前端仍是阻塞状态,只有所有子事务全副实现之后才会返回客户端胜利,这是分布式事务的最终一致性原理。然而,如果该分布式事务在返回给前端胜利之前,即子事务还在执行过程中,此时,刚好有查问操作,正好查到这样的状态,即A账户扣款还没有胜利,但B账户余额曾经减少胜利,这便呈现了分布式场景下的读一致性的问题。
局部银行对这种场景没有刻薄的要求,出报表的时候如果有数据处于这种“两头”状态,个别通过业务流水或其余形式弥补,使数据达到均衡状态。但局部敏感型业务对这种读一致性有强依赖,认为弥补操作的代价太高,同时对业务的容错性要求过高。所以,这类银行业务心愿依赖数据库自身获取一个均衡的数据镜像,即要么读到事务操作数据前的原始状态,要么读取到数据被分布式事务批改后的最终状态。
针对分布式场景下的一致性读问题,晚期能够通过加锁读,即查问时强制显示加排他锁的形式。加锁读在高并发场景下会有显著的性能瓶颈,还容易产生死锁。所以,在分布式下,咱们心愿以一种轻量的形式实现RR隔离级别,即快照读的能力。一致性读即快照读,读取到的数据肯定是“均衡”的数据,不是处于“中间状态”的数据。对于业务来说,无论是集中式数据库还是分布式数据库,都应该做到对业务通明且无感知。即集中式能够看到的数据,分布式也同样能看到,即都要满足可反复读。
在解决这个问题前,咱们首先须要关注基于MySQL这种分布式架构的数据库,在单节点下的事务一致性和可见性的原理。
MVCC模型,沉闷事务链表会造成高下水位线,高下水位线决定哪些事务可见或不可见。如果事务ID比高水位线还要小,该事务属于在构建可见性视图之前就曾经提交的,那么肯定可见。而对于低水位线对应的事务ID,如果数据行的事务ID比低水位线大,那么代表该数据行在以后可见性视图创立后才生成的,肯定不可见。每个事务ID都是独立的序列并且是线性增长,每个数据行都会绑定一个事务ID。当查问操作扫描到对应的记录行时,须要联合查问时创立的可见性视图中的高下水位线来判断可见性。
两种隔离级别,RC隔离级别能够看到事务ID为1、3、5的事务,因为1、3、5当初是沉闷状态,前面变成提交状态后,提交状态是对以后查问可见。而对于RR级别,将来提交是不可见,因为可反复读要求可见性视图构建后数据的可见性惟一且不变。即原来可见当初仍可见,原来不可见的当初仍不可见,这是Innodb存储引擎的MVCC原理。咱们先要理解单节点是怎么做的,而后才分明如何在分布式下对其进行革新。
这个转账操作中,A账户扣款,B账户减少余额,A、B两个节点别离是节点1和节点2,节点1原来的数据是0,转账后变为10,A节点之前的事务ID是18,转账后变成22,每个节点的数据都有历史版本的链接,事务ID随着新事务的提交而变大。对B节点来说,原来存储的这行数据的事务ID是33,事务提交后变成了37。A、B两个节点之间的事务ID是毫无关联的,各自依照独立的规定生成。
所以,此时一笔读事务发动查问操作,也是绝对独立的。查问操作发往计算节点后,计算节点会同时发往A、B两个MySQL节点。这个“同时”也是绝对的,不可能达到相对同时。此时,查问操作对第一个节点失去的低水位线是23,23大于22,所以以后事务对22可见。查问发往第二个节点时失去的低水位线是37,事务ID 37的数据行对以后事务也可见,这是比拟好的后果,咱们看到数据是平的,查到的都是最新的数据。
然而,如果查问操作创立可见性视图时产生的低水位线为36,此时就无奈看到事务ID为37的数据行,只能看到事务ID为33的上一个版本的数据。站在业务的角度,同时进行了两个操作一笔转账一笔查问,达到存储引擎的机会未必是转账在前查问在后,肯定概率上存在时序上的错位,比方:查问操作产生在转账的过程中。如果产生错位又没有任何干涉和爱护,查问操作很有可能读到数据的“中间状态”,即不平的数据,比方读取到总账是20,总账是0。
目前面对这类问题的思路基本一致,即采纳肯定的串行化规定让其统一。首先,如果波及分布式事务的两个节点数据均衡,首先要对立各节点的高下水位线,即用一个对立标尺能力达到对立的可见性判断成果。而后,由于事务ID在各个节点间互相独立,这也会造成可见性判断的不统一,所以事务ID也要做串行化解决。
在确立串行化的基本思路后,即可结构整体的事务模型。比方:A和B两个账户别离散布在两个MySQL节点,节点1和节点2。每个节点的事务ID强制保持一致,即节点1、2在事务执行前对应的数据行绑定的事务ID都为88,事务执行后绑定的ID都为92。而后,放弃可见性视图的“水位线”统一。此时,对于查问来说要么查到的都是旧的数据,要么查到的都是新的数据,不会呈现“一半是旧的数据,一半是新的数据”这种状况。到这里咱们会发现,解决问题的基本:1、对立事务ID;2、对立查问的评判规范即“水位线”。当然,这里的“事务ID”曾经不是单节点的事务ID,而是“全局事务ID”,所以整体思路就是从部分到全局的过程。
2. TDSQL全局一致性读计划
刚刚介绍了为什么分布式下会存在一致性读的问题,接下来分享TDSQL一致性读的解决方案:
首先引入了全局的工夫戳服务,它用来对每一笔事务进行标记,即每一笔分布式事务绑定一个全局递增的序列号。而后,在事务开始的时候获取工夫戳,提交的时候再获取工夫戳,各个节点外部保护事务ID到全局工夫戳的映射关系。原有的事务ID不受影响,只是会新产生一种映射关系:每个ID会映射到一个全局的GTS。
通过批改innodb存储引擎,咱们实现从部分事务ID到全局GTS的映射,每行数据都能够找到惟一的GTS。如果A节点有100个GTS,B节点也应该有100个GTS,此外分布式事务开启的时候都会做一次获取工夫戳的操作。整个过程对原有事务的影响不大,新增了在事务提交时递增并获取一次工夫戳,事务启动时获取一次以后工夫戳的逻辑。
建设这样的机制后,再来看分布式事务的执行过程,比方一笔转账操作,A节点和B节点首先在开启事务的时候获取一遍GTS:500,提交的时候因为距离一段时间GTS可能产生了变动,因此从新获取一次GTS:700。查问操作也是一个独立的事务,开启后获取到全局GTS,比方500或者700,此时查问到的数据肯定是均衡的数据,不可能查到中间状态的数据。
看似计划曾经残缺,然而还有个问题:即分布式事务都存在两阶段提交的状况,prepare阶段做了99%以上的工作,commit做残余不到1%的局部,这是经典的两阶段提交实践。A、B两个节点尽管都能够绑定全局GTS,但有可能A节点网络较慢,prepare后没有马上commit。因为A节点对应的记录行没有实现commit,还处于prepare状态,导致代表其全局事务状态的全局GTS还未绑定。此时查问操作此时必须期待,直到commit后能力获取到GTS后进而做可见性判断。因为如果A节点的数据没有提交就没方法获取其全局GTS,进而无奈晓得该记录行对以后读事务是否可见。所以,在查问中会有一个遇到prepare期待的过程,这是全局一致性读最大的性能瓶颈。
当然,优化的策略和思路就是缩小期待,这个下一章会详细分析。至此,咱们有了全局一致性读的基本思路和计划,下一步就是针对优化项的思考了。
3. 一致性读下的性能优化
这部分内容的是在上述解决方案的根底上进行的优化。
通过实际后,咱们发现全局一致性读带来了三个问题:
第一个问题是映射关系带来的开销。引入映射关系后,映射肯定十分高频的操作,简直扫描每一行都须要做映射,如果有一千万行记录须要扫描,在极其状况下很可能要进行一千万次映射。
第二个问题是事务期待的开销。在两阶段提交中的prepare阶段,事务没有方法获取最终提交的GTS,而GTS是将来不可预知的值,必须期待prepare状态变为commit后才能够判断。
第三个问题是针对非分布式事务的思考。针对非分布式事务是否也要无差别的进行GTS绑定,包含在事务提交时绑定全局工夫戳、在查问时做判断等操作。如果采纳和分布式事务一样的机制肯定会带来开销,但如果不加干预会不会有其余问题?
针对这三个问题,咱们接下来顺次开展剖析。
3.1 prepare期待问题
首先,针对prepare记录须要期待其commit的开销问题,因为事务在没有commit时,无奈确定其最终GTS,须要进行期待其commit。仔细分析prepare期待的过程,就能够发现其中的优化空间。
下图中,在以后用户表里的四条数据,A、B两条数据是上一次批改的目前曾经commit,而C、D数据最近批改且处于prepare状态,上一个版本commit记录也能够通过undo链找到,其事务ID为63。这个事务开始时GTS 是150,最终提交后变为181。这个181是曾经提交的最终状态,咱们回退到中间状态,即还没有提交时的状态。
如果依照失常逻辑,prepare肯定要等,但这时有个问题,这个prepare未来必定会被commit,尽管当初不晓得它的具体值时多少,然而它“未来”提交后肯定比以后曾经commit最大的ID还要大,行将来commit时的GTS肯定会比179大。此时,如果一笔查问的GTS小于等于179,能够认为就算C、D记录未来提交,也肯定对以后这笔小于等于179的查问不可见,因而能够间接跳过对C、D的期待,通过undo链追溯上一个版本的记录。这就是对prepare的优化的核心思想,并不是只有遇到prepare就期待,而是要跟以后缓存最大曾经提交的GTS来做比拟判断,如果查问的GTS比以后节点上曾经提交的最大GTS还要大则须要期待prepare变为commit。但如果查问的GTS比以后节点曾经提交的最大GTS小,则间接通过undo链获取以后prepare记录的上一个版本,无需期待其commit。这个优化对整个prepare吞吐量和期待时长的影响十分大,能够做到50%~60%的性能晋升。
3.2 非分布式事务问题
针对非分布式事务的一致性读是咱们须要思考的另外一个问题。因为非分布式事务走的路线不是两阶段提交,事务波及的数据节点不存在跨节点、跨分片景象。依照咱们后面的剖析,一致性读是在分布式事务场景下的问题。所以,针对分布式场景下的非分布式事务,是否能够间接放弃对它的非凡解决,而是采纳原生的事务提交形式。
如果放弃解决是否会产生其余问题,咱们持续剖析。下图在银行金融机构中是常见的交易模型,交易启动时记录交易日志,交易完结后更新交易日志的状态。交易日志为独自的记录行,对其的更新可能是非分布式事务,而真正的交易又是分布式事务。如果在交易的过程中随同有查问操作,则查问逻辑中里很可能会呈现这种状态:即交易曾经开始了但交易日志还查不到,对于业务来说如果查不到的话就会认为没有启动,那么矛盾的问题就产生了。
如果要放弃业务语义连续性,即针对非分布式事务,即便在分布式场景下一笔交易只波及一个节点,也须要像分布式事务那样做标记、解决。尽管说针对非分布式事务须要绑定GTS,然而咱们心愿尽可能简化和轻量,相比于分布式事务不须要在每笔commit提交时都拜访一遍全局工夫戳组件申请GTS。所以,咱们也心愿借鉴对prepare的解决形式,能够用节点外部缓存的GTS来在引擎层做绑定。
受prepare优化思路的启发,是否也能够拿最大提交的GTS做缓存。然而如果拿最大已提交GTS做缓存会产生两个比拟显著的问题:第一,不可反复读;第二,数据行“永远不可见”。这两个问题会给业务带来更重大的影响。
首先是不可反复读问题。T1是非分布式事务,T2是查问事务。当T1没有提交的时候,查问无奈看到T1对数据的批改。如果T1从启动到提交的间隔时间较长(没有通过prepare阶段),且这段时间没有其余分布式事务在以后节点上提交。所以,当T1提交后以后的最大commit GTS没有发生变化仍为100,此时绑定T1事务的GTS为100,但因为查问类事务的GTS也是100,所以导致T1提交后会被T2看失去,呈现不可反复读问题。
其次是不可见的问题。接着上一个问题,如果用最大已提交的GTS递增值加1是否能够解决上一个不可反复读问题,看似能够解决然而会带来另外一个更重大的问题:该事务批改的数据行可能“永远”不可见。如果T1非分布式事务提交之后,零碎内再无写事务,导致“一段时间”内,查问类事务的GTS永远小于T1批改数据会绑定的GTS,进而演变为T1批改的数据行“一段时间内”对所有查问操作都不可见。
这时咱们就须要思考,在非分布式场景下须要缓存怎么的GTS。在下图的事务模型中,T1时刻有三笔沉闷事务:事务1、事务2、事务3。事务2是非分布式事务,它的提交咱们心愿对事务3永远不可见。如果对事务3不可见的话,就必须要比事务3开启的GTS大。所以,咱们就须要在非分布式事务提交时,绑定以后沉闷事务里“快照最大GTS加1”,即绑定GTS 为106后,因为查问的GTS为105,无论两头开启后执行多少次,肯定对后面不可见,这样就得以保障。
再看第二个时刻,在事务4和事务5中,随着GTS的递增,事务5的启动GTS曾经到达到106,106大于等于上一次非分布式事务提交的GTS值106,所以事务2对事务5始终可见,满足事务可见性,不会导致事务不可见。
通过前述优化,造成了分布式场景下事务提交的最终计划:事务启动时获取以后全局GTS,当事务提交时进行二次判断。首先判断它是不是一阶段提交的非分布式事务,如果是则须要获取以后节点的最大快照GTS并加1;如果是分布式事务则须要走两阶段提交,在commit时从新获取一遍全局GTS递增值,绑定到以后事务中。这样的机制下除了性能上的晋升,在查问数据时更能保证数据不丢不错,事务可见性不受影响。
3.3 高性能映射问题
最初是事务ID和全局GTS的映射问题。这里为什么没有采纳暗藏列而是应用映射关系呢?因为如果采纳暗藏列会对业务有很强的入侵,同时让业务对全局工夫戳组件产生适度依赖。比方:若应用一致性读个性,那么必须引入全局的工夫戳,每一笔事务的提交都会将全局工夫戳和事务相绑定,因而,全局工夫戳的可靠性就十分要害,如果略微有抖动,就会影响到业务的连续性。所以咱们心愿这种个性做到可配置、可动静开关,适时启用。所以,做成这种映射形式可能使下层对底层没有任何依赖以及影响。
全局映射还须要思考映射关系高性能、可持久性,当MySQL异样宕机时可能主动复原。因而,咱们引入了新的零碎表空间Tlog,依照GTS工夫戳和事务ID的形式做映射,外部按页组织治理。通过这种形式对每一个事务ID都能找到对应映射关系的GTS。
那么怎么整合到Innodb存储引擎并实现高性能,即如何把映射文件嵌入到存储引擎里?下图中能够看到,革新后对GTS的映射拜访是纯内存的,即GTS批改间接在内存中操作,Tlog在加载以及扩大都是映射到Innodb的缓冲池中。对于映射关系的批改,往往是事务提交的时候,此时间接在内存中批改映射关系,内存中Tlog关联的数据页变为脏页,同时在redo日志里减少对GTS的映射操作,定期通过刷脏来保护磁盘和内存中映射关系的一致性。因为内存批改的开销较小,而在redo中也仅仅减少几十字节,所以整体的写开销能够忽略不计。
这种优化的作用下,对于写事务的影响不到3%,而对读事务的影响可能管制在10%以内。此外,还须要对undo页清理机制做革新,将原有的基于最老可见性视图的删除形式改为以最小沉闷GTS的形式删除。
GTS和事务ID的映射是有开关的,关上能够做映射,敞开后退化为单节点模式。即TDSQL能够提供两种一致性服务,一种是全局一致性读,即基于全局GTS串行化实现,另外一种是敞开这个开关,只保障事务最终一致性。因为任何革新都是有代价,并不是全局一致性读个性关上比不关上更好,而是要依据业务场景做判断。开启一致性读个性尽管可能解决分布式场景下的可反复读问题,然而因为新引入了全局GTS组件,该组件肯定水平上属于要害门路组件,如果其故障业务会受到短暂影响。除此之外, 全局一致性读对性能也有肯定影响。所以,倡议业务联合本身场景评估是否有分布式快照读需要,若有则关上,否则敞开。