写点什么

突破底层基础架构瓶颈,揭秘 TDSQL 存储核心技术

  • 2021 年 12 月 30 日
  • 本文字数:8539 字

    阅读完需:约 28 分钟

TDSQL 是腾讯面向企业级应用场景的分布式数据库产品,目前已在众多金融、政务、电商、社交等客户应用案例中奠定金融级高可用、强一致、高性能的产品特性和口碑,帮助 20 余家金融机构完成核心替换,有力推动了国产数据库的技术创新与发展。


日前,TDSQL 新敏态引擎正式发布,高度适配金融敏态业务。该引擎可完美解决对于敏态业务发展过程中业务形态、业务量的不可预知性,实现 PB 级存储的 Online DDL,可以大幅提升表结构变更过程中的数据库吞吐量,有效应对业务的变化;最关键的是,腾讯独有的数据形态自动感知特性,可以使数据能够根据业务负载情况自动迁移,打散热点,降低分布式事务比例,获得极致的扩展性和性能。


本期将由腾讯云数据库专家工程师朱翀深度解读 TDSQL 新敏态引擎存储核心技术。以下是分享实录:


# TDSQL 新敏态存储引擎

TDSQL 在银行核心系统及常见业务上表现出优秀性能和良好稳定性,但在某些敏态业务中,其底层基础架构遭遇新的问题。

首先是兼容性的问题。TDSQL 的架构包括计算层及分布式的存储层。分布式存储层中存在众多 DB,利用中间层即计算层,再通过 hash 的方式将数据分片,分别存放在不同的 DB。这种方式在建表时会遇到兼容性问题,需要指定 shardkey 才能将用户产生的数据存放到指定 DB 上。面对经常变化的敏态业务,如果每次建表都要指定 shardkey,当业务变化时,指定的 shardkey 在未来业务中就不可用,需要重新去分布数据,整个流程将变得更繁琐。


其次是运维的问题。在 TDSQL 中,后端的存储节点是众多 DB,如果容量不够则需要扩容。DBA 需要在前端发起操作,过程较为简单,但途中会有部分事务中断。随着敏态业务的发展,需要不停扩容,扩容过程中的事务中断也会对敏态业务造成影响。


最后是模式变更的问题。随着业务的发展,敏态业务的表结构也在变化,需要经常加字段或加索引。在 TDSQL 中加索引等表结构变更必须锁表。如果想避免锁表,就需要借助周边生态工具。

基于上述问题,我们研发了 TDSQL 新敏态存储引擎架构。考虑到敏态业务变化较大,我们希望在 TDSQL 新敏态存储引擎架构中,用户可以像单机数据库一样去使用分布式数据,不需要关注存储变化,可以随时加字段、建索引,业务完全无感知。


目前该引擎完全兼容 MySQL,具备全局一致性,扩缩容业务完全无感知,完全支持原生在线表结构变更。与此前架构最大的区别在于,该存储引擎为分布式 KV 系统,同时提供事务和自动扩缩容能力。在该引擎中,数据按范围分片,分成一个个 Region,Region 内部的数据有序排列。每个 KV 节点上有许多 Region,每次扩容时只需要将指定 Region 搬迁走即可。


# TDSQL 新敏态存储引擎技术挑战


TDSQL 新敏态存储引擎中数据是如何存储的以及 SQL 是如何执行的呢?以下图为例,t1 表中有三个字段,分别是 id、f1、f2,其中 id 是主键,f1 是二级索引。在建 t1 表时,计算层会为其获取两个索引 id,假设主键的索引 id 为 0x01,二级索引的索引 id 为 0x02。当我们为 t1 表插入一行数据时,insert into t1 value(1,3,3),计算层会把 Key 编码成 0x0101(16 进制表示法,下同,第一个字节 0x01 表示主键索引 ID,第二个字节 0x01 表示主键值),value 会被编码成 0x010303。因为该表存在二级索引,所以插入一条主键 Key 还不够,二级索引也要进行编码保存;二级索引的编码中需要包含主键值的信息,故将其 Key 编码为 0x020301(第一个字节 0x02 表示二级索引 ID,第二个字节 0x03 表示二级索引值,第三个字节 0x01 表示主键值),因为 Key 中已经包含了所有需要的信息,所以二级索引的 value 是空值。


当我们为 t1 表再插入一行数据 insert into t1 value(2,3,2)时,是同样的过程,这里不再赘述,这条数据会被编码成主键 Key-value 对即 0x0102-0x020302,和二级索引 Key-value 对即 0x020302-null。

假设后端有两个敏态引擎存储节点即 TDStore,第一个 TDStore 上 Region 的范围为 0x01-0x02,这样两个记录的主键就存储在 TDStore1 上。第二个 TDStore 上的 Region 的范围是 0x02-0x03,这两个值的二级索引存储在 TDStore2 上。计算层收到客户端发过来的查询语句 select * from t1 where id=2 时,经过 sql parse、bind 等一系列工作之后,知道这条语句查询的是表 t 主键值为 2 的数据。表 t 的主键索引 ID 为 0x01,于是计算层编码查询 Key 为 0x0102,计算层再根据路由表可知该值在 TDStore1 上,于是通过 RPC 将值从 TDStore1 上读取出来,该值 value 为 0x020302,再将其反编码成(2,3,2)返回给客户端。


接着计算层收到客户端发过来的第二条查询语句 select * from t1 where f1=3,计算层同样经过 sql parse、bind 等一系列工作之后,知道这条语句查询的是表 t 二级索引字段为 3 的数据,表 t 的二级索引 ID 为 0x02,这样计算层可以组合出 Key:0x0203,利用前缀扫描,计算层从 TDStore2 中得到两条数据 0x020301,0x020302。这意味着 f1=3 有两条记录主键值分别为 1 和 2,但是此时还没有获取到 f3 这个列的值,需要根据主键值再次编码去获取相应记录的全部信息(这个过程我们也称之为回表)。


经过上面的过程,我们可以看到当往 t 表中插入一行记录时,TDSQL 新敏态引擎会产生两个 Key,这两个 Key 还可能会存放在不同的 TDStore 上。这时我们就会遇到事务原子性的问题。例如我们可能会遇到这样一种场景:插入第一个 Key 成功了,但在插入第二个 Key 过程中,第二个 Key 所在的节点故障了。如果没有处理好可能就会出现第一个 Key 保存成功,而第二个 Key 丢失的情况,这种情况是不允许出现的。所以 TDSQL 新敏态引擎要保证一次事务涉及的数据要么全部插入成功、要么全部插入失败。

TDSQL 新敏态引擎面临的另一个问题是事务的并发处理。如上图所示:TDSQL 新敏态引擎支持多计算层节点写入,因此可能会出现两个客户端连上两个不同的计算层节点同时写入同一个主键值。我们知道记录插入时首先要判定主键的唯一性,因此在收到 insert 语句时计算层节点 SQLEngine 会在存储节点 TDStore 上根据主键 Key 读取数据,看其是否存在,在上图中主键 Key 编码为 0x0103,两个 SQLEngine 都同时发现在 TDStore 上 Key:0x0103 并不存在,于是都将 Key:0x0103 发到 TDStore 上要求将其写入,但它们对应的 value 又不相同,最终要保留哪条记录呢?这就成为了问题。


TDSQL 新敏态引擎还面临另一个问题,就是如何保证数据调度过程中事务不受影响。如下图所示,假设此时 DBA 正在导入大量数据,TDSQL 新敏态引擎发现存储节点存储空间不够,于是决定扩容,将部分数据搬迁到空闲机器上。搬迁过程中,要屏蔽影响,保证导入数据的事务不中断。


综上所述,TDSQL 新敏态存储引擎要解决三方面的挑战:

事务原子性。一个事务涉及到的数据可能分布在多个存储节点上,必须保证该事务涉及到的所有修改全部成功或全部失败。

事务并发控制。并发事务之间不能出现脏读(事务 A 读到了事务 B 未提交的数据)、脏写(事务 A 和事务 B 同时基于某个相同的数据版本写入不同的值,一个覆盖另一个)。

数据调度时不杀事务。新敏态存储引擎的重要设计目标之一,是让业务在敏态变化中无感知,因此要确保在数据搬迁时,不影响事务的正常进行。


# 事务原子性

解决事务原子性问题的经典方法是两阶段提交。如果我们让计算层节点 SQLEngine 作为两阶段提交的协调者,那么当一个事务提交时,SQLEngine 需要先写 prepare 日志,再发送 prepare 请求给存储节点 TDStore,如果 prepare 都成功了,再写 commit 日志,发送 commit 请求。一旦 SQLEngine 节点发生了故障,只要能够恢复,就可以从日志中读取出当前有哪些悬挂事务,然后根据其对应的阶段继续推动两阶段事务。但是如果 SQLEngine 发生了永久性故障,无法恢复,那么日志就会丢失,就无从得知有哪些悬挂事务,也就永远无法继续推进悬挂事务。在 TDSQL 新敏态存储引擎设计目标里,要求计算层 SQLEngine 节点可以随时增减和替换,也要求 SQLEngine 节点能够随时承受永久性故障。所以经典的两阶段提交方法不可取。

经典的两阶段提交方法不可取的主要原因是本地日志可能会丢失,我们可以对经典的方案进行改进,将日志放在存储层节点 TDStore 中。因为存储层是基于 raft 多副本的,这样就能够在不出现多数派节点永久故障的情况下,保证日志的安全。但这种做法带来的坏处是网络层次太多,首先两阶段的日志先发送到存储层 TDStore 的 Leader,再同步到 TDStore 的 Follow,然后才能进行真正的两阶段请求。除了延迟高,这个方案还存在故障后悬挂事务恢复慢的缺点。比如当一个计算层 SQLEngine 节点发生了永久性故障,就需要另一个 SQLEngine 节点感知到这件事情,然后才能继续推进涉及的悬挂事务。感知 SQLEngine 节点存活问题,往往会归纳成心跳超时的问题。因为要防止进程夯住假死等问题,超时一般不能设置的太短,这里的设计就导致了一个计算层 SQLEngine 节点故障后,需要较长时间其涉及的悬挂事务才能被其它节点接管,恢复起来很慢。

最终我们采用了协调者下沉到存储节点的方法来解决分布式原子性事务。因为存储节点本身使用了 raft 协议保证多数派一致性,不存在单点问题。只要选一个存储节点的参与者作为协调者,将参与者的列表信息包含在参与者日志一起提交。这样当故障发生时,就可以利用日志恢复 raft 状态机的方式,将协调者也恢复出来。这样的好处是网络层次相对较少,提交延迟较低,同时故障恢复也比较确定。

# 分布式事务并发控制

接下来我们一起看下,TDSQL 新敏态存储引擎是如何解决分布式事务并发控制的。

**我们首先构造了以下规则:**

数据存储是基于时间戳的数据多版本,以下图中左下方的表为例,数据有多个版本,每个版本都会有一个时间戳。比如数据 Key:A 有三个版本,它的时间戳分别为 1、3、5,对应的值也不同。


TDMetaCluster 模块提供全局逻辑时间戳服务,保证逻辑时间戳在全局单调递增。


事务开始时会从时间戳服务模块获取一个时间戳,我们称之为 start_ts。事务读取指定 Key 的 value 时,读取的是从数据存储中第一个小于等于 start_ts 的 key value(上图例子中是从下往上读,因为图例中的新数据在下面)。


事务未提交前的写入都在内存中(我们称之为事务私有空间),只有事务提交时才写入数据存储里对其他事务可见。


事务提交前需要再获取一个时间戳,我们称之为 commit_ts。事务提交时写入数据存储中的数据项需要包含这个时间戳。


举个例子,见上图右侧的事务执行空间,假设正在执行一条 update A=A+5 的 SQL,它需要先从存储中 get A 的值,再对值进行+5 操作,最后把+5 的结果写回存储中。从图中可以看到事务拿到的 start_ts 为 4,当事务去数据存储中读取 A 的值的时候,读取到的值是 10,原因是 A 的多个版本中时间戳 3 是第一个小于等于该事务 start_ts 的版本,因此要读到时间戳 3 这个版本,读到的值为 10。拿到 A=10 后,事务对 10 进行+5 操作,把结果 15 暂时保存在自己的私有空间中,再获取 commit_ts 为 5,最后再把 A=15 写回到数据存储中,此时数据存储中多了一条 A 的版本,该版本为 5,值为 15。


从上述过程中我们可以看出,我们当前定义的几条规则很自然地解决了脏读问题,原因是未提交的事务写入的数据都暂存在其私有内存中,对其他事务都不可见,如果该事务回滚了我们只需要将其在私有内存中的数据释放掉,期间不会对数据存储产生任何影响。


尽管上述规则定义了事务读写的方式,也解决了脏读问题,但是仅有这几条规则还是不够,我们可以看看下图这个问题。

这是一个常见的数据并发更新的场景。假设有两个客户端在同时执行 update A=A+5 的操作,对于数据库来说就产生了两个并发的更新事务 T1、T2。假设这两个事务的执行顺序如上图所示,T2 先拿到 start_ts:4,把 A 时间戳为 3 的版本 value=10 读取出来了。事务 T1 同时进行,它拿到的 start ts:5,也把 A 事务戳为 3 的版本 value=10 读取出来。随后它们都对 10 加 5,得到 A=15 的新结果,暂存于各自的私有内存中。事务 T2 再去拿 commit_ts:6,再将 A=15 写回数据存储中。事务 T1 也拿到了 commit_ts:7,再把 A=15 写回数据存储。最终会产生两个 A 的新版本,但是其 value 都等于 15。这样相当于数据库执行了两次 update A=A+5,并且都返回客户端成功,但是最终 A 的值只增加了一个 5,相当于其中一个更新操作丢失了。


为什么会这样呢?我们回顾上述过程会发现 T2 的值被 T1 错误地覆盖了:T1 读取到了 T2 更新前的值,然后覆盖了 T2 更新后的值。因此要想得到正确的结果有两个方法,要么 T1 应该读取到 T2 更新后的值再去覆盖 T2 更新后的值,要么 T1 在获取到 T2 更新前的值的基础上去覆盖 T2 更新后的值时应该失败。(方法 1 是悲观事务模型,方法 2 是乐观事务模型)

在 TDSQL 新敏态引擎中,我们采用了方法 2,引入了冲突检测的规则,当然以后我们也会支持方法 1。

怎么保证 T1 在获取到 T2 更新前的值再去覆盖 T2 更新后的值时应该失败呢,我们引入了一个新的规则:事务在提交前需要做一次冲突检测。冲突检测的具体过程为:按照前述执行顺序,在获取 commit_ts 前,读取本事务所有更新数据项在数据存储中的最新的版本对应的时间戳,将其与本事务的 start_ts 比较,如果数据版本对应的 timestamp 小于 start _ts 才允许提交,否则应失败回滚。


当事务 T2 提交前做冲突检测时,会再次读取数据项 A 最新的版本 timestamp=3,小于事务 T2 的 start_ts:4,于是事务 T2 进行后续流程,将更新数据成功提交。但是当事务 T1 执行冲突检测时,再次读取数据项 A 最新版本时其已经变成 timestamp=6,大于它的 start_ts:5,这说明数据项 A 在事务 T1 执行期间被其它事务并发修改过,这里已经产生了事务冲突,于是事务 T1 需要回滚掉。


通过引入新的规则:事务在提交前需要做一次冲突检测,我们似乎看起来解决了脏写的问题,但是真正的解决了吗?上图的示例中我们给出了一种并发调度的可能,这个调度就是下图的左上角的情况,通过冲突检测确实可以解决问题。但是还存在另一种可能的并行调度。两个事务在 client 端同时 commit,这个调度在数据库层可能会同时做冲突检测(两个不同的执行线程),然后冲突检测都判定成功,最终都成功提交,这样相当于又产生了脏写。

这个问题其实可以用另一种可能的调度去解决。虽然 client 同时 commit,但是在数据库层事务 T2 提交完之后事务 T1 才开始进行,这样事务 T1 就能检测到 A 的最新版本发生的变化,于是进入回滚。这种调度意味着事务提交在数据项上要原子串行化,在单节点情况下(或者简单的主备同步)这种操作是可行的。但在分布式事务的前提下,获取时间戳需要网络交互,如果仍然采用这种串行化操作,事务并发无法提高,延迟会非常大。


除了这个问题,分布式场景也给事务并发控制带来一些新的挑战——当事务涉及到多个节点时要如何统一所有节点的时序,从而保证一致性读?(这里的一致性读指的是:一个事务的修改要么被另一个事务全部看到,要么全不被看到)


我们详细阐述一下一致性读问题。在下图中 A、B 两个账户分别存储在两个不同的存储节点上;事务 T1 是转账事务,从 A 账户中转 5 元到 B 账户,在 T1 执行完所有流程正在提交时,查总账事务 T2 开启,其要查询 A、B 两个账户的总余额。这时可能会出现下面这个执行流程:事务 T1 将 A=5 元提交到存储节点 1 上时,事务 T2 在存储节点 2 上读取到了 B=10 元,然后事务 T1 再把 B=15 元提交到存储节点 2 上,最后事务 T2 再去存储节点 1 上读取 A=5 元。最终的结果是虽然事务 T1 执行前后总余额都是 20,但是事务 T2 查询到的总余额却等于 15,少了 5 元。


我们的分布式事务并发控制模型除了要解决上述问题,还需要考虑一个非常重要的点:如何与分布式事务原子性解决方案 2pc 结合。


最终我们给出了处理模型:


首先,我们将两阶段提交与乐观事务模型相结合,在事务提交时先进入 prepare 阶段,进行写写冲突检测。这样做的原因是保证两阶段提交中,如果 prepare 成功,commit 就必定要成功的承诺。

其次,我们引入 prepare lock map 来进行活跃并发事务的冲突检测,而原本的冲突检测流程继续保留,负责已提交事务的冲突检测。这样我们就把冲突检测与数据写入解绑,不再需要这里进行原子串行化,提高了事务并发的能力。具体到事务执行流程里面就是在 prepare 阶段需要将对应的更新数据项的 key 插入到 prepare lock 中,如果发现对应 Key 已经存在,说明存在并发活跃的事务冲突,如果对应更新数据项插入全部成功,说明 prepare 执行成功。


最后,在事务执行读取操作时还需要根据读取的 Key 查询 prepare lock map。如果事务的 start_ts 大于在 prepare map 中查询到的 lock 项的 prepare ts,就必须等到 lock 释放后才能去数据存储中读取 Key 对应的数据。这里包含的原理是:已提交事务的 commit_ts 和读取事务的 start_ts 决定了数据项的可见性,当读取事务的 start_ts 大于 prepare map 中查询到的 lock 项的 prepare ts 时,意味着有一个事务其 commit_ts 可能小于读取事务 start_ts 正在提交,读取事务需要等待其提交成功之后才能执行读取操作,否则有可能会漏掉要读取数据项的最新版本。


有了这些新规则,我们再回到上面一致性读的例子中,如下图所示,事务 T2 在存储节点 2 上面的读取需要延迟到事务 T1 将 B=15 提交到数据存储后才可以执行,这样就保证读到的是 B 最新的版本 15 元,然后再去存储节点 1 上将 A=5 元读取出来,这样最后的总余额才是准确的。


# 数据调度不杀事务


在 TDSQL 敏态存储引擎中,数据分段管理在 Region 中,数据调度通过 Region 调度实现。Region 调度又可分为分裂、迁移和切主。


首先我们看一下 Region 的分裂,以下图为例,假设数据在不停写入,写入的数据并不是完全均匀的,出现了某个 Region 比较大的情况,我们不能放任这个 Region 一直增大下去,于是我们在该 Region 中找到一个合适的分裂点,将其一分为二。在下图中,Region1 分裂完后,原本每个存储节点三个 Region 变成每个存储节点四个 Region。

我们继续前面的示例,写入数据一直源源不断,存储节点的磁盘空间即将不足,于是我们增加了一个存储节点,并且开始迁移数据到新节点上。数据迁移则是通过增减副本的方式进行,假设我们选定了 Region2 做迁移,那么我们先在存储节点 4 上增加 Region2 的副本,然后再到存储节点 1 上将 Region2 的副本移除,这样就相当于 Region2 对应的数据从存储节点 1 迁移到存储节点 4。依次选择不同 Region 重复这个过程,最终实现效果如下图所示——从每一个存储节点上都迁移了部分数据到新存储节点上。

仅仅只是执行副本迁移的操作会遇到 leader 不均衡的问题,此时还需要辅助主动切主的操作,来实现 leader 数目动态平衡。


在实际应用场景中,业务的需求是:不论数据如何调度和动态均衡,服务不能中断。在上面介绍的 Region 调度过程中,Region 迁移是通过 raft 增减副本的方式进行,与提供服务的 leader 无直接关系,不会影响到业务。但分裂和切主都在 leader 节点上执行,不可避免地会存在与事务并发执行的问题,要想保证业务服务不受 Region 调度的影响,其实就是要保证事务不受 Region 的影响,这其中最关键的是要让事务的生命周期跨越分裂和切主。

我们看看上图的示例:在磁盘上存储着 A 和 H 的值分别为 A=10、H=2,有一个事务 T,其执行过程应该是先 put A=1、put H=5,然后再 Get H 的值,最后再提交。假设该事务在执行过程中 Region 发生了分裂,分裂的时机在 Put H=5 之后,Get H 之前;同时 Region 的分裂点为 G。在把磁盘上的数据迁移过去后,我们会发现在磁盘上 Region1 有 A=10,而新的 Region2 上有 H=2。当事务继续执行 Get H 时,根据最新的路由关系,它应该需要在 Region2 上去读取最新的值,此时如果我们没有其它规则的保证,就会读到 H=2,这就产生了问题:该事务刚刚写了的数据似乎丢了。为了解决这个问题,需要将 Region 上的活跃事务的私有数据在分裂时迁移到 new Region 上,这样在上面例子中事务在执行 get H 时读到的最新值为 5。

上述例子中事务还有一种可能的执行流(如下图所示):不进行 get H 操作,而是做完两次 put 操作后直接提交;并且分裂时机在 Put H 之后,commit 之前。由于没有执行过 Get H,计算层只感知到该事务只有 Region1 参与,于是在执行 commit 时,计算层就会只提交 Region1 上的数据,导致 Region2 上的数据没有提交,破坏了事务的原子性。所以我们还需要额外的规则来保证在提交事务时感知到 Region 的分裂,保证事务的原子性。

具体过程如下图中的时序图所示。假设最初只涉及到两个 Region,计算层在提交时会将参与者列表告诉协调者,协调者会在 Region1 和 Region2 上做 prepare。假设 Region2 经历一次分裂,分裂出的新的 Region3,当收到 prepare 请求时,Region2 发现协调者包含的 region 列表中没有新 Region3,于是跟协调者说明分裂情况。协调者感知到 Region2 的分裂后,会重新补齐参与者列表,再次发起一轮 prepare,从而保证了事务的原子性。

还有一种情况,当事务提交时,Region 正在分裂,处于数据迁移过程中。这时 Region2 会告诉协调者,说明自身状态正处在分裂过程中。协调者会等待一段时间后再去重试。通过重试协调者最终可以知道这次分裂是否成功,如果成功新的参与者是谁,然后协调者就可以将参与者列表补齐,最终提交事务。


# 结语

作为腾讯企业级分布式数据库产品 TDSQL 的又一突破,TDSQL 新敏态引擎高度适配金融敏态业务,完美解决对于敏态业务发展过程中业务形态、业务量的不可预知性。


在突破原有底层基础架构瓶颈的基础上,TDSQL 新敏态引擎采用协调者下沉方法解决分布式事务原子性问题,保证事务涉及到的所有修改全部成功或全部失败;采用乐观事务模型,引入冲突检测环节,解决分布式事务并发控制问题;通过 raft 增减副本方式实现数据迁移,同时保证事务周期跨越分裂和切主,实现数据调度不杀事务。


未来 TDSQL 将持续推动技术创新,释放领先的技术红利,继续推动国产数据库的技术创新与发展,帮助更多行业客户实现数据库国产化替换。

用户头像

还未添加个人签名 2018.12.08 加入

还未添加个人简介

评论

发布
暂无评论
突破底层基础架构瓶颈,揭秘TDSQL存储核心技术