TiCDC 作为 TiDB 的数据同步组件,负责直接从 TiKV 感知数据变更同步到下游。其中比较核心的问题是数据解析正确性问题,具体而言就是如何使用正确的 schema 解析 TiKV 传递过来的 Key-Value 数据,从而还原成正确的 SQL 或者其他下游支持的形式。本文主要通过对 TiDB Online DDL 机制原理和实现的分析,引出对当前 TiCDC 数据解析实现的讨论。
数据同步组件是数据库生态中不可或缺的生态工具,比较知名的开源单机数据库 MySQL 就将数据同步作为 Server 能力的一部分,并基于 MySQL binlog 实现异步/半同步/同步的主从复制。由于 MySQL 悲观事务模型和表元数据锁的存在,我们总是可以认为 MySQL binlog 中存在因果关系的 data 和 schema 符合时间先后顺序的,即:
New data commitTs > New schema commitTs
但是对于 TiDB 这种存储计算分离的架构而言,schema 的变更在存储层持久化,服务层节点作为多缓存节点,总是存在一个 schema 状态不一致的时间段。为了保证数据一致性和实现在线 DDL 变更,现有的分布式数据库大都采用或者借鉴了 Online, Asynchronous Schema Change in F1 机制。所以我们要回答的问题变成了,在 TiDB Online DDL 机制下,TiCDC 如何正确处理 data 和 schema 的对应关系,存在因果关系的 data 和 schema 是否仍然满足:
New data commitTs > New schema commitTs
为了回答这个问题,我们首先需要先阐述原始的 F1 Online Schema Change 机制的核心原理,然后描述当前 TiDB Online DDL 实现,最后我们讨论在当前 TiCDC 实现下,data 和 schema 的处理关系和可能出现的不同的异常场景。
F1 Online Schema Change 机制要解决的核心问题是,在单存储多缓存节点的架构下,如何实现满足数据一致性的 Online Schema 变更,如图 1 所示:
图 1: 单存储多缓存节点的架构下的 schema 变更
这里我们定义数据不一致问题为数据多余(orphan data anomaly)和数据缺失(integrity anomaly),Schema 变更结束后出现数据多余和数据缺失我们就认为数据不一致了。这类系统的 schema 变更问题特点可以总结成以下 3 点:
一份 schema 存储,多份 schema 缓存
部分 new schema 和 old schema 无法共存
直接从 old schema 变更到 new schema 时,总是存在一个时间区间两者同时存在
特点 1 和特点 3 是系统架构导致的,比较容易理解。特点 2 的一个典型例子是 add index,加载了 new schema 的服务层节点插入数据时会同时插入索引,而加载了 old schema 的服务层节点执行删除操作只会删除数据,导致出现了没有指向的索引, 出现数据多余。
Schema 变更问题的特点 2 和特点 3 看起来是互相矛盾的死结,new schema 和 old schema 无法共存,但又必然共存。而 F1 Online Schema 机制提供的解决方案也很巧妙,改变不了结果就改变条件。所以该论文的解决思路上主要有 2 点,如图 2 所示:
图 2: F1 Online DDL 解决方案
1. 引入共存的中间 schema 状态,比如 S1->S2’->S2, S1 和 S2’ 可以共存,S2’ 和 S2 可以共存;
2. 引入确定的隔离时间区间,保证无法共存的 schema 不会同时出现;
具体来讲:
因为直接从 schema S1 变更到 schema S2 会导致数据不一致的问题,所以引入了 delete-only 和 write-only 中间状态,从 S1 -> S2 过程变成 S1 -> S2+delete-only -> S2+write-only -> S2 过程,同时使用 lease 机制保证同时最多有 2 个状态共存。这时只需要证明每相临的两个状态都是可以共存的,保证数据一致性,就能推导出 S1 到 S2 变更过程中数据是一致的。
定义 schema lease,超过 lease 时长后节点需要重新加载 schema,加载时超过 lease 之后没法获取 new schema 的节点直接下线,不提供服务。所以可以明确定义 2 倍 lease 时间之后,所有节点都会更新到下一个的 schema。
我们需要引入什么样的中间状态呢?那要看我们需要解决什么问题。这里我们仍然使用 add index 这个 DDL 作为例子,其他 DDL 细节可以查阅 Online, Asynchronous Schema Change in F1 。
Delete-only 状态
我们可以看到 old schema 是无法看到索引信息的,所以会导致出现删除数据,遗留没有指向的索引这种数据多余的异常场景,所以我们要引入的第一个中间状态是 delete-only 状态,赋予 schema 删除索引的能力。在 delete-only 状态下,schema 只能在 delete 操作的时候对索引进行删除,在 insert/select 操作的时候无法操作索引,如图 3 所示:
图 3: 引入 delete-only 中间状态
原始论文对于 delete-only 的定义如下:
假设我们已经引入了明确的隔离时间区间(下一个小节会细讲),能保证同一时刻最多只出现 2 个 schema 状态。所以当我们引入 delete-only 状态之后,需要考虑的场景就变成:
old schema + new schema(delete-only)
new schema(delete-only) + new schema
对于场景 1,所有的服务层节点要么处于 old schema 状态,要么处于 new schema(delete-only) 状态。由于 index 只能在 delete 的时候被操作,所以根本没有 index 生成,就不会出现前面说的遗留没有指向的索引问题,也不会有数据缺失问题,此时数据是一致的。我们可以说 old schema 和 new schema(delete-only) 是可以共存的。
对于场景 2,所有的服务层节点要么处于 new schema(delete-only) 状态,要么处于 new schema 状态。处于 new schema 状态的节点可以正常插入删除数据和索引,处于 new schema( delete-only) 状态的节点只能插入数据,但是可以删除数据和索引,此时存在部分数据缺少索引问题,数据是不一致的。
引入 delete-only 状态之后,已经解决了之前提到的索引多余的问题,但是可以发现,处于 new schema( delete-only) 状态的节点只能插入数据,导致新插入的数据和存量历史数据都缺少索引信息,仍然存在数据缺失的数据不一致问题。
Write-only 状态
在场景 2 中我们可以看到,对于 add index 这种场景,处于 new schema( delete-only) 状态节点插入的数据和存量数据都存在索引缺失的问题。而存量数据本身数量是确定且有限的,总可以在有限的时间内根据数据生成索引,但是 new insert 的数据却可能随时间不断增加。为了解决这个数据缺失的问题,我们还需要引入第二个中间状态 write-only 状态,赋予 schema insert/delete 索引的能力。处于 write-only 状态的节点可以 insert/delete/update 索引,但是 select 无法看到索引,如图 4 所示:
图 4: 引入 write-only 状态
原始论文中对于 write-only 状态的定义如下:
引入 write-only 状态之后,上述的场景 2 被切分成了场景 2‘ 和场景 3:
2’: new schema(delete-only) + new schema(write-only)
3: new schema(write-only) + new schema
对于场景 2‘,所有的服务层节点要么处于 new schema(delete-only) 状态,要么处于 new schema(write-only) 。处于 new schema(delete-only) 状态的服务层节点只能插入数据,但是可以删除数据和索引,处于 new schema(write-only) 可以正常插入和删除数据和索引。此时仍然存在索引缺失的问题,但是由于 delete-only 和 write-only 状态下,索引对于用户都是不可见的,所以在用户的视角上,只存在完整的数据,不存在任何索引,所以内部的索引缺失对用户而言还是满足数据一致性的。
对于场景 3,所有的服务层节点要么处于 new schema(write-only) 状态,要么处于 new schema。此时 new insert 的数据都能正常维护索引,而存量历史数据仍然存在缺失索引的问题。但是存量历史数据是确定且有限的,我们只需要在所有节点过渡到 write-only 之后,进行历史数据索引补全,再过渡到 new schema 状态,就可以保证数据和索引都是完整的。此时处于 write-only 状态的节点只能看到完整的数据,而 new schema 状态的节点能看到完整的数据和索引,所以对于用户而言数据都是一致的。
小节总结
通过上面对 delete-only 和 write-only 这两个中间状态的表述,我们可以看到,在 F1 Online DDL 流程中,原来的单步 schema 变更被两个中间状态分隔开了。每两个状态之间都是可以共存的,每次状态变更都能保证数据一致性,全流程的数据变更也能保证数据一致性。
为了保证同一时刻最多只能存在 2 种状态,需要约定服务层节点加载 schema 的行为:
所有的服务层节点在 lease 之后都需要重新加载 schema;
如果在 lease 时间内无法获取 new schema,则下线拒绝服务;
通过对服务层节点加载行为的约定,我们可以得到一个确定的时间边界,在 2*lease 的时间周期之后,所有正常工作的服务层节点都能从 schema state1 过渡到 schema state2, 如图 5 所示:
图 5: 最多 2*lease 时长后所有的节点都能过渡到下一个状态
要正确理解原始论文的中间状态,需要正确理解中间状态的可见性问题。前面小节为了方便我们一直使用 add index 作为例子,然后表述 delete-only 和 write-only 状态下索引对于用户 select 是不可见的,但是 write-only 状态下,delete/insert 都是可以操作索引的。如果 DDL 换成 add column,那节点处于 write-only 状态时,用户 insert 显式指定新增列可以执行成功吗?答案是不能。
总得来说,中间状态的 delete/insert 可见性是内部可见性,具体而言是服务层节点对存储层节点的可见性,而不是用户可见性。对于 add column 这个 DDL,服务层节点在 delete-only 和 write-only 状态下就能看到 new column,但是操作受到不同的限制。对用户而言,只有到 new schema 状态下才能看到 new column,才能显式操作 new column,如图 6 所示:
图 6: 中间状态可见性
为了清晰表述可见性,我们举个例子,如图 7 所示。原始的表列信息为 , DDL 操作之后表列信息为
图 7: 中间状态过渡
小图 (1) 中,服务层节点已经过渡到了场景 1,部分节点处于 old schema 状态,部分节点处于 new schema(delete-only) 状态。此时 c2 对用户是不可见的,不管是 insert
小图 (2) 中,服务层节点已经过渡到了场景 2,部分节点处于 new schema(delete-only) 状态,部分节点处于 new schema(write-only) 状态,此时 c2 对用户仍是不可见的,不管是 insert
小图 (3) 中,服务层所有节点都过渡到 write-only 之后,c2 对用户仍是不可见的。此时开始进行数据填充,将历史数据中缺失 c2 的行进行填充(实现时可能只是在表的列信息中打上一个标记,取决于具体的实现)。
小图 (4) 中,开始过渡到场景 3,部分节点处于 new schema(write-only) 状态,部分节点处于 new schema 状态。处于 new schema(write-only) 状态的节点,c2 对用户仍是不可见的。处于 new schema 状态的节点,c2 对用户可见。此时连接在不同服务层节点上的用户,可以看到不同的的 select 结果,不过底层的数据是完整且一致的。
上面我们通过 3 个小节对 F1 online Schema 机制进行了简要描述。原来单步 schema 变更被拆解成了多个中间变更流程,从而保证数据一致性的前提下实现了在线 DDL 变更。
对于 add index 或者 add column DDL 是上述的状态变更,对于 drop index 或者 drop column 则是完全相反的过程。比如 drop column 在 write-only 阶段及之后对用户都不可见了,内部可以正确 insert/delete,可见性和之前的论述完全一样。
TiDB Online DDL 是基于 F1 Online Schema 实现的,整体流程如图 8 所示:
图 8 TiDB Online DDL 流程
简单描述如下:
TiDB Server 节点收到 DDL 变更时,将 DDL SQL 包装成 DDL job 提交到 TIKV job queue 中持久化;
TiDB Server 节点选举出 Owner 角色,从 TiKV job queue 中获取 DDL job,负责具体执行 DDL 的多阶段变更;
DDL 的每个中间状态(delete-only/write-only/write-reorg)都是一次事务提交,持久化到 TiKV job queue 中;
Schema 变更成功之后,DDL job state 会变更成 done/sync,表示 new schema 正式被用户看到,其他 job state 比如 cancelled/rollback done 等表示 schema 变更失败;
Schema state 的变更过程中使用了 etcd 的订阅通知机制,加快 server 层各节点间 schema state 同步,缩短 2*lease 的变更时间。
DDL job 处于 done/sync 状态之后,表示该 DDL 变更已经结束,移动到 job history queue 中;
详细的 TiDB 处理流程可以参见: schema-change-implement.md 和 TiDB ddl.html
前面我们分别描述了 TiDB Online DDL 机制的原理和实现,现在我们可以回到最一开始我们提出的问题:在 TiDB Online DDL 机制下,是否还能满足:
New data commitTs > New schema commitTs
答案是否定的。在前面 F1 Online Schema 机制的描述中,我们可以看到在 add column DDL 的场景下,当服务层节点处于 write-only 状态时,节点已经能够插入 new column data 了,但是此时 new column 还没有处于用户可见的状态,也就是出现了 New data commitTs < New schema commitTs,或者说上述结论变成了:
New data commitTs > New schema(write-only) commitTs
但是由于在 delete-only + write-only 过渡状态下,TiCDC 直接使用 New schema(write-only) 作为解析的 schema,可能导致 delete-only 节点 insert 的数据无法找到对应的 column 元信息或者元信息类型不匹配,导致数据丢失。所以为了保证数据正确解析,可能需要根据不同的 DDL 类型和具体的 TiDB 内部实现,在内部维护复杂的 schema 策略。
在当前 TiCDC 实现中,选择了比较简单的 schema 策略,直接忽略了各个中间状态,只使用变更完成之后的 schema 状态。为了更好表述在 TIDB Online DDL 机制下,当前 TiCDC 需要处理的不同场景,我们使用象限图进行进一步归类描述。
Old schema | New schema | |
---|---|---|
Old schema data | 1 | 2 |
New schema data | 3 | 4 |
1 对应 old schema 状态
此时 old schema data 和 old schema 是对应的*;*
4 对应 new schema public 及之后
此时 new schema data 和 new schema 是对应的;
3 对应 write-only ~ public 之间数据
此时 TiCDC 使用 old schema 解析数据,但是处于 write-only 状态的 TiDB 节点已经可以基于 new schema insert/update/delete 部分数据,所以 TiCDC 会收到 new schema data。不同 DDL 处理效果不同,我们选取 3 个常见有代表性的 DDL 举例。
2 对应直接从 old schema -> new schema
说明这类 schema 变更下,old schema 和 new schema 是可以共存的,不需要中间状态,比如 truncate table DDL。TiDB 执行 truncate table 成功后,服务层节点可能还没有加载 new schema,还可以往表中插入数据,这些数据会被 TiCDC 直接根据 tableid 过滤掉,最终上下游都是没有这个表存在的,满足最终一致性。
TiCDC 作为 TiDB 的数据同步组件,数据解析正确性问题是保证上下游数据一致性的核心问题。为了能充分理解 TiCDC 处理 data 和 schema 过程中遇到的各种异常场景,本文首先从 F1 Online Schema Change 原理出发,详细描述在 schema 变更各个阶段的数据行为,然后简单描述了当前 TiDB Online DDL 的实现。最后引出在当前 TiCDC 实现下在 data 和 schema 处理关系上的讨论。