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