共计 7098 个字符,预计需要花费 18 分钟才能阅读完成。
分布式场景下如何进行快照读是一个很常见的问题,因为在这种场景下极易读取到分布式事务的“中间状态”。针对这一点,腾讯云数据库 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 组件,该组件肯定水平上属于要害门路组件,如果其故障业务会受到短暂影响。除此之外,全局一致性读对性能也有肯定影响。所以,倡议业务联合本身场景评估是否有分布式快照读需要,若有则关上,否则敞开。