热门标签 | HotTags
当前位置:  开发笔记 > 后端 > 正文

详解腾讯云TDSQL全局一致性读技术

全局一致性读方案,解决分布式节点间

分布式场景下如何进行快照读是一个很常见的问题,因为在这种场景下极易读取到分布式事务的“中间状态”。针对这一点,腾讯云数据库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组件,该组件一定程度上属于关键路径组件,如果其故障业务会受到短暂影响。除此之外, 全局一致性读对性能也有一定影响。所以,建议业务结合自身场景评估是否有分布式快照读需求,若有则打开,否则关闭。


GDCC


演讲

姓名|吴昊

电话|185 1611 6966

赞助、参展

姓名|林婷婷

电话|180 1781 9081

赞助、参展、听众

姓名|朱艳萍

电话|138 1644 2176

点击“阅读原文”报名参会



推荐阅读
  • 本文深入探讨了NoSQL数据库的四大主要类型:键值对存储、文档存储、列式存储和图数据库。NoSQL(Not Only SQL)是指一系列非关系型数据库系统,它们不依赖于固定模式的数据存储方式,能够灵活处理大规模、高并发的数据需求。键值对存储适用于简单的数据结构;文档存储支持复杂的数据对象;列式存储优化了大数据量的读写性能;而图数据库则擅长处理复杂的关系网络。每种类型的NoSQL数据库都有其独特的优势和应用场景,本文将详细分析它们的特点及应用实例。 ... [详细]
  • 本文详细介绍了Java代码分层的基本概念和常见分层模式,特别是MVC模式。同时探讨了不同项目需求下的分层策略,帮助读者更好地理解和应用Java分层思想。 ... [详细]
  • Web开发框架概览:Java与JavaScript技术及框架综述
    Web开发涉及服务器端和客户端的协同工作。在服务器端,Java是一种优秀的编程语言,适用于构建各种功能模块,如通过Servlet实现特定服务。客户端则主要依赖HTML进行内容展示,同时借助JavaScript增强交互性和动态效果。此外,现代Web开发还广泛使用各种框架和库,如Spring Boot、React和Vue.js,以提高开发效率和应用性能。 ... [详细]
  • 在当今的软件开发领域,分布式技术已成为程序员不可或缺的核心技能之一,尤其在面试中更是考察的重点。无论是小微企业还是大型企业,掌握分布式技术对于提升工作效率和解决实际问题都至关重要。本周的Java架构师实战训练营中,我们深入探讨了Kafka这一高效的分布式消息系统,它不仅支持发布订阅模式,还能在高并发场景下保持高性能和高可靠性。通过实际案例和代码演练,学员们对Kafka的应用有了更加深刻的理解。 ... [详细]
  • Python 数据可视化实战指南
    本文详细介绍如何使用 Python 进行数据可视化,涵盖从环境搭建到具体实例的全过程。 ... [详细]
  • 从0到1搭建大数据平台
    从0到1搭建大数据平台 ... [详细]
  • 本文详细介绍了数据库并发控制的基本概念、重要性和具体实现方法。并发控制是确保多个事务在同时操作数据库时保持数据一致性的关键机制。文章涵盖了锁机制、多版本并发控制(MVCC)、乐观并发控制和悲观并发控制等内容。 ... [详细]
  • MySQL的查询执行流程涉及多个关键组件,包括连接器、查询缓存、分析器和优化器。在服务层,连接器负责建立与客户端的连接,查询缓存用于存储和检索常用查询结果,以提高性能。分析器则解析SQL语句,生成语法树,而优化器负责选择最优的查询执行计划。这一流程确保了MySQL能够高效地处理各种复杂的查询请求。 ... [详细]
  • B站服务器故障影响豆瓣评分?别担心,阿里巴巴架构师分享预防策略与技术方案
    13日晚上,在视频观看高峰时段,B站出现了服务器故障,引发网友在各大平台上的广泛吐槽。这一事件导致了连锁反应,大量用户纷纷涌入A站、豆瓣和晋江等平台,给这些网站带来了突如其来的流量压力。为了防止类似问题的发生,阿里巴巴架构师分享了一系列预防策略和技术方案,包括负载均衡、弹性伸缩和容灾备份等措施,以确保系统的稳定性和可靠性。 ... [详细]
  • 2021年Java开发实战:当前时间戳转换方法详解与实用网址推荐
    在当前的就业市场中,金九银十过后,金三银四也即将到来。本文将分享一些实用的面试技巧和题目,特别是针对正在寻找新工作机会的Java开发者。作者在准备字节跳动的面试过程中积累了丰富的经验,并成功获得了Offer。文中详细介绍了如何将当前时间戳进行转换的方法,并推荐了一些实用的在线资源,帮助读者更好地应对技术面试。 ... [详细]
  • DAO(Data Access Object)模式是一种用于抽象和封装所有对数据库或其他持久化机制访问的方法,它通过提供一个统一的接口来隐藏底层数据访问的复杂性。 ... [详细]
  • 网站访问全流程解析
    本文详细介绍了从用户在浏览器中输入一个域名(如www.yy.com)到页面完全展示的整个过程,包括DNS解析、TCP连接、请求响应等多个步骤。 ... [详细]
  • 秒建一个后台管理系统?用这5个开源免费的Java项目就够了
    秒建一个后台管理系统?用这5个开源免费的Java项目就够了 ... [详细]
  • 在什么情况下MySQL的可重复读隔离级别会导致幻读现象? ... [详细]
  • 在Java分层设计模式中,典型的三层架构(3-tier application)将业务应用细分为表现层(UI)、业务逻辑层(BLL)和数据访问层(DAL)。这种分层结构不仅有助于提高代码的可维护性和可扩展性,还能有效分离关注点,使各层职责更加明确。通过合理的设计和实现,三层架构能够显著提升系统的整体性能和稳定性。 ... [详细]
author-avatar
babelbat_786
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有