写点什么

前沿干货!深度揭秘 TDSQL 新敏态引擎 Online DDL 技术原理

  • 2021 年 12 月 21 日
  • 本文字数:5845 字

    阅读完需:约 19 分钟

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

**

与此同时,TDSQL 新敏态引擎还具有对分布式事务完整支持的特性,支撑了上层计算引擎多主读写架构的实现,并与计算引擎结合实现了计算下推、分布式事务一阶段优化等多维度优化,进一步实现分布式数据库系统性能极致提升,有效适配企业新敏态业务需求。在腾讯内部业务实践中,TDSQL 新敏态引擎可支撑业务在保持高性能且连续服务的基础上,一个月内完成高达 1000 次表结构在线变更。

在高频的表结构变更过程中,如何减少对在线业务请求的影响,甚至使得用户能够以原生、不阻塞业务的方式进行,这就成为了 TDSQL 新敏态引擎面对的技术挑战。本期将由腾讯云数据库高级工程师赵东志,为大家深度解读 TDSQL 新敏态引擎 OnlineDDL 的原理与实现。以下是分享实录:

# Instant  DDL

**TDSQL 新敏态引擎的核心架构**。SQLEngine 是计算层,主要负责 SQL 的解析、分发,包括数据查询,将 SQL 转为 KV,再将 KV 收集的结果转化为 SQL 能获取到的结果,最后传输到客户端等环节。其中,DDL 也是计算层负责的部分之一。

过去在单机系统下 DDL 的执行方式分为两种:MySQL 对支持的部分 Online DDL,不支持的部分则通过外部组件 pt 工具对每个 DB 节点做 DDL。在集群规模比较大时,运维会变得更加复杂,需要用外部工具保证多个节点之间 DDL 的原子性,每个节点还需要预留两倍存储空间。

基于上述原因,TDSQL 新敏态引擎的设计目标定为三个方面:

● 保证 Online 性质,做到不阻塞业务读写请求。

● 保证多节点缓存一致性,使得 Crash-safe 等在 TDSQL 系统中自治。

● 兼容 MySQL,方便业务迁移。

我们以加列为例来介绍 Instant  DDL。下图中的 Client,包含一个表结构,是一条映射语句。在 F1 列的基础上,插入一个 pk=10、F1=1 的数据行。插入后再进行加列操作,加入 F2 列,加列后的表如下图 TDstore 所示。这时如果需要读取前述两行数据,就会遇到问题。在读取 pk=11 这行时,可以用最新的表结构直接解析数据行,但在读取 pk=10 这行时,我们需要知道该数据行里是否包含 F2 列。

为此我们在表结构中引入版本号概念。比如初始列表的版本为 1,做加列操作后,schema 变为 2,插入时再将版本 2 写入到 value 字段中。读取数据时需要先判断数据行的版本,如果数据行版本为 2,就用当前的表结构解析;如果数据行版本为 1,比当前版本小,确定 F2 列不在该版本的 Scheme 中后,可直接填充默认值再返回到客户端。

通过版本号概念的引入,在整个加列过程中,只需要更改元数据,即 Scheme 的信息并未更改数据行,加列过程变得更加快速高效。同样的方式也可作用于 Varchar 扩展长度等无损数据类型的转换,Index  invisible 等其他 DDL。但并不是所有的 DDL 都可以仅修改元数据,部分 DDL 还需要生成部分数据才能实现,比如加索引操作。因为索引的生成是从无到有的过程,因此必须要生成部分数据,无法通过直接修改表结构来实现。

# Add/Drop Index

以加索引为例,下图左边所示为 TDSQL 新敏态引擎索引数据库存储结构。图中有两行数据,有一个主键和一个索引。在 TDStore 中,每个索引都会有全局唯一的 index ID,比如主键为 index1,二级索引为 index2。主键数据由 index ID+pk 组成,形成 key,value 为其他字段。在索引中,它的组成为索引 indexID+索引信息+主键信息。

如果要进行 alter table、add index 操作,从无索引状态变为索引,则需要扫主键数据,组建索引的 KV 形式,插入 index 中进行扫描,再修改元数据,以完成索引的添加。如果在扫描主键、修改元数据的同时,存在并发事务如 delete 或 insert 等操作,就会产生扫描回填的索引过程与用户事务并发之间的问题。

**针对 DDL 和用户请求的并发问题我们可以将 DML 分为 delete、insert、update 三种来加以讨论。**

对于 delete,我们可以 scan 任意一行数据,再按索引形式将其插回到 TDstore 中。假设存在一个并发,两数据行为同一行,删除操作相当于插入一个类型为 delete 的 key。目标是在主键上删除该数据行,在索引上也删除该数据行。如果不计后果直接插入,就会遇到问题。比如删除后,又插入到该数据行后,最终的结果是,key 被删除后在索引上再次出现。

为解决上述问题,我们引入了托马斯写机制,在插入时先查看版本,看是否存在更新的写入,如果有更新的写入,则该条 key 就不能再被写入。这里采用时间戳的比较机制。在 scan 时,基于 TDStore 提供的全局一致性读,我们在读取时会获取一个时间戳,比如 1。在事务中插入时,其时间戳也通过 TDStore 来获取,读取数据所用时间戳也会带进去,即在该时间戳读,写时也用同一时间戳,TS 为 1。在同一条 key 中,如果发现存在比自己更大的 ts,说明该 key 已被用户更改过,则 put 不生效,以此来解决并发问题。

对于 insert,如果插入一条新数据,与当前数据行无冲突,即当前数据行无该条数据,这时只需要在索引上也插入该行数据即可。update 相当于 delete+insert 的组合,在 delete 和 insert 问题解决后,update 问题也会自然解决。我们通过托马斯写规则机制解决回填索引与用户事务的并发问题。

在分布式系统中我们还会面临另一个问题,即多个计算节点之间的缓存一致性问题。因为在 TDSQL 中,上层计算节点可以有很多个,且每个计算节点还会有自身的缓存。以索引为例,假设某个 DDL 在 SQLEngine1 上执行一个 add index idx_f1,此时 SQLEngine1 上并发的执行一个插入操作,则会在主键,索引上分别插入一行 kv,如果这时另一个计算节点 SQLEngine2 由于缓存更新不及时,获取到的表结构没有 idx_f1,如果接到删除请求,在解析完该表结构后,该计算节点只会删除主键上的数据,而不会删除该条索引记录,最终导致主键上和索引上的数据不一致。

单机系统一般不会出现上述问题。假设将两个节点想象成两个线程,比如 thread1、thread2,线程 1 想要进行表的原数据修改,可以获取一个的元数据锁,将所有的请求先挡住,再到内存中的表结构。可以看出单机系统依靠 mutex 可以实现多线程互斥,不存在两个线程使用不同版本的 t1 的情况。

一个简单的想法是将单机系统中的锁扩展成分布式锁。这种做法在原理上可行,但会存在时耗不可控的问题。以下图为例,假设 sqlengine1 想发起申请锁的请求,它可以在自身节点申请,也可以在其他节点如 sqlengine2、sqlengine3 上申请。但由于分布式系统中网络不太可控,sqlengine 数量非常多,可能会存在网络异常问题,比如 sqlengine3 存在网络异常,回复时间就会比较慢。网络时间的延迟导致不可控问题。如果等到所有节点都申请成功,再去做更改,用户请求的阻塞时间就会被拉长。

分布式锁的实现还有很多方案,比如引入超时机制,但同样也会存在其他问题,例如超时时间定义为多长?太长对用户业务会有影响,太短则可能存在误判。我们进一步思考,能否不依赖分布式锁达到同样的目的。

我们采用 GoogleF1 论文中引入的过渡态的思想。前述问题出现的原因是有的计算节点无法感知到该索引,有的计算节点感知到该索引并去写索引,这就产生了数据不一致问题。F1 的基本思想是在分布式系统中,在没有锁的情况下,无法同时从某个状态迁移到下一个状态,这时就可以引入中间状态。比如某个节点可以先进入到下一个状态,但该状态与上一个状态相互兼容。如图所示,假设目前为 v1 状态,先进入 v2,但 v2 与 v1 可以兼容,相当于还有部分节点处于 v1 状态,两者可以并存一段时间,等所有节点都进入 v2 后,再进入 v3,状态两两兼容,最终推进到完整的过程。但如何保证两两之间不超过两个状态也成为了一个新的问题?假设有个节点 1 先进入到 v2,节点 2 在 v1,过段时间后节点 1 想进入 v3,但要如何确定是否所有节点都进入 v2 呢?

F1 中还提到 lease 机制。假设 sqlengine 是一个执行 DDL 的节点,如果想进入下一个状态,就需要等 2t 的时间。所有 sqlengine 节点,每隔一个 t 周期,都会看自己的 scheme 是否过期,如果过期就会重新加载,通过 2t 和 t 的交叉,保证推进时其他节点必定将新 scheme 加入进来。如果部分节点加载不上来出现异常,就会主动下线。但如果单纯的 lease 还是不可靠的。比如在下图中比如,节点 1 间隔 2t 时间进入 v2,再间隔 2t 进入 v3。假设节点 2 在 v1 时进行 put key 操作,但该请求在存储层面执行的时间较久,刚好遇到了 io 100%,阻塞时间较长,比如阻塞 5T 的时间才把请求写下去。这时存在一个节点,在间隔 2t 后误以为其他节点都已经进入新状态,因此进入到 v3。这就违反前述规则,即同一时刻不能有两个相邻版本以外的写入并存。即使 v2 知道自身超过 lease 选择主动下线也没有用,因为写入请求已经发到存储层,该写入的生命周期已经由存储层来控制。对于上述问题,F1 中也提到可以引入 deadline 时间来控制,但是目前我们并没有这种机制,而是采用了一种版本判定机制来解决这个问题。

从本质上来看,这个问题属于计算层与存储层联动的问题,因为该请求已经发到 TDStore,我们需要在推进版本前让 TDStore 感知到相关情况,具体流程如下:在进入下一状态前,需要先推一个版本下去。推下去后,存储层会感知到该节点想要进入 v2。与此同时,存储层发现 v1 状态下还有一个请求未完成,等该请求写完后存储层再返回同意。如果存储层中一旦存在旧版本请求没有完成,它会等到完成后再反馈。

在这种约束机制下,只要 push 版本成功,说明存储层里已经没有比 v2 更小的写入,即此时任意节点都没有过期版本正在写入,可以进入 v3 状态。同时在该机制下,存储层不会接受后续请求中比 v 小的读写请求。在极端异常的场景中,假设某一节点在 push 已经成功的情况下,发送仍处于 v1 状态的请求,这时存储层就会发现该请求比当前版本的 v 要小,只能拒绝。通过存储层的版本校验机制,进一步保证了系统中任意时刻的有效写入只能在两个相邻的状态之间。

最后对缓存与执行进行总结。我们采用 F1 的思想引入过渡态,将 Add  Index 分成多个阶段,每相邻的两个阶段两两兼容,这样就无需依赖全局的分布式锁。在存储层进行该版本的有效性检验,进一步保证每时每刻的有效写入只能位于两个相邻状态之间。大多数情况下,我们可以认为该版本检验无效。因为每个节点都能加载新的表结构,且能用新的表结构进行读写,版本检验仅适用于预防阶段场景,为防止此类极端场景对数据造成一致性的破坏,保证整体算法运行的正确性。整体过程为:由计算层直接向下推送版本,演变为先向 TDStore push 当前版本,再进入下一状态,通过此类方式来完成整体的变更操作。

删索引则相对容易,可以看成加索引的反向操作,具体过程如下图。

# 通用 Online DDL

**在 Instant DDL 中,仅需更改表结构、修改元数据即可。在 Varchar 扩展长度等无损数据类型的转换中,还需要生成部分数据才能实现。要如何使得更广泛的其他 DDL 通过 Online 方式执行,这就成了新的挑战。**

为此我们结合了 pt-online-scheme-change 的思想。pt 的原理为:在执行 OnlineDDL 时,会生成一个新的表结构即临时表,再将旧表数据拷贝到新表中,过程中还会进行建触发器等操作,保证拷表过程中的增量同步。在 TDSQL 新敏态引擎的设计中我们借鉴了上述拷表思想。拷表过程中的新表的过程可以想象成在原表上加一个特殊的索引,即回归到托马斯写问题,针对拷表过程中的问题我们也设计了过渡态问题的解决方案。

旧表为 status0,建立一张临时表为 tmp1,状态为 delete only。我们会在内部建立一张新表,将旧表与新表进行关联,并且会将表 status0 上的删除相关的操作同步临时表 tmp1,接下来进入 write only 状态。write only 的过程与加索引过程相同,会在执行过程中将 delete、update、insert 等新的增量同步到 tmp1 上。

准备开始 thoma write 回填数据之前,需要在存储层推版本,确保当前没有处于 delete only 状态的节点,保证任何新的请求都会增量同步到新的临时表中。之后再进行 thomas write 操作按照加索引的方式,从 MC 获取时间戳,再用时间戳扫数据,从老表上将旧数据回迁到新表,thomas write 机制可以保证整体回迁过程与原表事务并发的正确性,最后再进行临时表命名。

在此之前,我们还会进行其他的检查操作,比如检查旧表与新表数据的一致性。因为在这种拷表方式中,如果 alter 影响到主键,就容易引起数据方面的问题。假设原表的主键为一个 Varchar,属于大小写敏感类型,上面有 A 和 a 两条数据。如果变更字符序,将其变为大小写不敏感,在新表中 A 和 a 就会变成一条数据,从而覆盖掉原始数据。我们需要通过类似的二次检查来确定是否存在该种情况,避免拷贝过程中的数据遗失。

检查完成后,我们会进行 rename 操作,更改旧表表名,再将新表替换成原表表名,相当于将整个原表替换到新表的状态。我们还会进行反向同步操作,因为可能有部分节点仍处于 status2,此时原表上还有读请求,我们需要将这些请求转发到这张表上,保证处于该状态的计算节点仍能读到这些新增的数据请求。在这些请求转移完成后,再取消关联,将版本推掉,最终将旧表用异步方式进行清理。

结合 pt-online-scheme-change 的思想,我们将拷表的过程想象成添加一个特殊的索引,从而进一步推广到支持 MySQL 所有类型的 DDL。

# Online DDL 原子性

**在 TDSQL 新敏态引擎中,所有计算节点为无状态,持久化操作通过存储层来实现,DDL 的发起操作则在计算节点中进行。如果某一计算节点在执行 DDL 过程中挂掉,就会面临中间状态由谁来负责推进的问题。**

实际操作中,每个计算节点在执行前,会在存储层持久化一个 DDL 任务队列。每次开展 DDL 任务时,就会将该 DDL 任务插入到 DDL 任务队列中。如果正常结束,就会将该任务删除。如果非正常结束如异步挂掉,其他的计算节点,会感知到任务队列中有未完成的任务,根据该任务当前执行信息,再去界定该 DDL 任务的下一步操作,例如继续推进或回滚。

在上述过程中,恢复线程与工作线程之间通过 MC 的 lock 来互斥。这看似引入了分布式锁,但实际上该锁只作用于 DDL 之间。因为 TDSQL 新敏态引擎的整体设计原则是 DML 优先,在 DDL 过程中尽量避免影响 DML。

# 总结

综上所述,TDSQL 新敏态引擎 Online DDL 核心技术可以总结为四个方面:

● Instant DDL:通过多版本的解析规则,使得加列或 varchar 扩展长度等无损类型变更这些只需修改元数据的 DDL 瞬间完成。

● Add/Drop Index:通过托马斯写机制,解决生成索引数据和用户事务的并发问题;采用 F1 过渡态+存储层版本交验机制,解决多个节点间缓存一致性问题。

● 通用 Online DDL:抽象出适用于所有 DDL 的 copy table 流程,进一步将 Online DDL 推广到可支持绝大多数 MySQL 的 DDL。

● DDL 原子性:通过任务队列+恢复线程的工作机制,保证 DDL 整体的原子性。

用户头像

还未添加个人签名 2018.12.08 加入

还未添加个人简介

评论

发布
暂无评论
前沿干货!深度揭秘TDSQL新敏态引擎Online DDL技术原理