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