硬核干货!TDSQL全局一致性读技术详解

2021-12-14 00:00:00 数据 事务 分布式 节点 全局

分布式场景下如何进行快照读是一个很常见的问题,因为在这种场景下极易读取到分布式事务的“中间状态”。针对这一点,腾讯云数据库TDSQL设计了全局一致性读方案,解决了分布式节点间数据的读一致性问题。


近日腾讯云数据库专家工程师张文在第十二届中国数据库技术大会上为大家分享了“TDSQL全局一致性读技术”。以下是分享实录:



分布式下一致性读问题


近年来很多企业都会发展自己的分布式数据库应用,一种常见的发展路线是基于开源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”,所以整体思路就是从局部到全局的过程。



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等待的过程,这是全局一致性读大的性能瓶颈。


       


        


当然,优化的策略和思路就是减少等待,这个下一章会详细分析。至此,我们有了全局一致性读的基本思路和方案,下一步就是针对优化项的考虑了。



一致性读下的性能优化


这部分内容的是在上述解决方案的基础上进行的优化。


经过实践后,我们发现全局一致性读带来了三个问题:


个问题是映射关系带来的开销。引入映射关系后,映射一定非常高频的操作,几乎扫描每一行都需要做映射,如果有一千万行记录需要扫描,在极端情况下很可能要进行一千万次映射。


第二个问题是事务等待的开销。在两阶段提交中的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组件,该组件一定程度上属于关键路径组件,如果其故障业务会受到短暂影响。除此之外, 全局一致性读对性能也有一定影响。所以,建议业务结合自身场景评估是否有分布式快照读需求,若有则打开,否则关闭。

文章来源:https://mp.weixin.qq.com/s/hjL5T0904SIYEVP4rZP5tQ

相关文章