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