关于数据库:突破底层基础架构瓶颈揭秘TDSQL存储核心技术

40次阅读

共计 9479 个字符,预计需要花费 24 分钟才能阅读完成。

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 将继续推动技术创新,开释当先的技术红利,持续推动国产数据库的技术创新与倒退,帮忙更多行业客户实现数据库国产化替换。

正文完
 0