作者介绍:
- 廖坚钧,PingCAP Data Platform 研发工程师,专注于数据库架构和生态工具方向。
- 张文珺,华中科技大学研究生,database enthusiast,rust learner。
简介
在数据同步的场景下, 上下游数据的一致性校验 是非常重要的一个环节,缺少数据校验,可能会对商业决策产生非常负面的影响。。Sync-diff-inspector 是 Data Platform 团队开发的一款一致性校验工具,它能对多种数据同步场景的上下游数据进行一致性校验,如多数据源到单一目的(mysql 中分库分表到 TiDB 中)、单一源到单一目的( TiDB 表 到 TiDB 表)等,在数据校验过程中, 其效率和正确性 是至关重要的。首先我们看下 Sync-diff-inspector 的架构图,对 Sync-diff-inspector 的作用和实现原理有一个大致的认知。
Sync-diff-inspector 2.0 架构图
Why Sync-diff-inspector 2.0?
在 1.0 版本中,我们遇到客户反馈的一些问题,包括:
- 针对大表进行一致性校验时出现 TiDB 端发生内存溢出。
- 不支持 Float 类型数据校验的问题。
- 结果输出对用户不友好,需要对校验结果进行精简。
- 检验过程中发生 GC,导致校验失败。
造成以上问题的原因 与原版的实现方式有关 :
- 采用单线程划分 Chunk,该表中所有已被划分的 Chunk 需要等待该表中所有 Chunk 全部被划分才会开始进行比对,这会导致这段时间内,TiKV 的使用率降低
- Checkpoint 功能将校验过的每个 Chunk 的状态写入数据库,所以写入数据库的 IO 成为校验过程的瓶颈。
- 当 chunk 范围内的 checksum 不同时,直接进行按行比对,消耗大量 IO 资源。
- 缺少自适应 GC 的功能,导致正在校验的 Snapshot 被 GC,使得校验失败。
- …
Sync-diff-inspector 2.0 新特性
Chunk 划分
对于比较两个表数据是否相同,可以通过分别计算两个表的 checksum 来判断,但是确定哪一行出现了不同则需要逐行比对。为了缩小 checksum 不一致时需要进行逐行比对的行数, Sync-diff-inspector 采用了折衷的方案:将表按照索引的顺序划分成若干块(chunk),再对每个 chunk 进行上下游数据比对。
chunk 的划分沿用了之前的方法。TiDB 统计信息会以索引作为范围将表划分为若干个桶,再对这些桶根据 chunk 的大小进行合并或切分。切分过程则选择随机行作为范围。
原版 Sync-diff-inspector 采用单线程划分 chunk,已被划分的 chunk 需要等待该表划分完所有 chunk 才会开始比对,这里采用异步划分 chunk 的方法来提高这段时间的资源利用率。这里有 两种降低资源利用率 的情况:
- chunk 划分过程中可能由于 chunk 的预定大小小于一个桶的大小,需要切分这个桶为若干个 chunk,这是个相对比较慢的过程,因此消费端也就是 chunk 的比对线程会出现等待的情况,资源利用率会降低。这里采用两种处理方法:采用多个桶异步划分来提高资源利用率;有些表没有桶的信息,因此只能把整个表当作一个桶来切分,采用多表划分来提高总体的异步划分桶数。
- chunk 的划分也会占用一定的资源,chunk 划分过快会一定程度减慢 chunk 比对的速度,因此这里在消费端通过 channel 来限制多表划分chunk的速度。
总结来说,优化后的 Sync-diff-inspector 对 chunk 的划分由 三部分 组成。如下图所示,这里指定存在 3 个 chunk_iter,每个 chunk_iter 划分一个表,这里通过全局的 channel 调整 chunks_iter 划分的进度。注意这里只按表限流,每个 chunk_iter 开始划分时,会异步划分所有 chunk,当全局的 channel 的 buffer 满了,chunk_iter 会阻塞。当 chunk_iter 的所有 chunk 都进入全局 channel 的 buffer 后,该 chunk_iter 会开始划分下一个表。
Checkpoint 和修复 SQL
Sync-diff-inspector 支持在断点处继续进行校验的功能。Diff 进程每十秒钟会记录一次断点信息,当校验程序在某个时刻发生异常退出的时候,再次运行 Sync-diff-inspector 会从最近保存的断点处继续进行校验。如果在下一次运行时,Sync-diff-inspector 的配置文件发生改变,那么 Sync-diff-inspector 会抛弃断点信息,重新进行校验。
该功能的完整性和正确性依赖于在 Chunk 划分过程中定义的全局有序性和连续性 。相比于原版,Sync-diff-inspector 2.0 实现的 checkpoint 不需要记录每个 chunk 的状态,只需要记录连续的、最近校验完成的 chunk 的状态,大大减少了需要记录的数据量。chunk 的全局有序特性由一个结构体组成,结构体包含了该 chunk 属于第几个表,属于该表的第几个桶到第几个桶(如果该 chunk 由两个或多个桶合并而成,则记录桶的首末),这个桶被切分成多少个 chunk,这个 chunk 是切分后的 chunk 的第几个。同时这种特性也可以判断两个 chunk 是不是连续的。每次断点时钟触发时,会选择已完成比对的连续的 chunk 的最后一个 chunk 作为检查点,写入该 chunk 的信息到本地文件。
当校验出不同行时,Sync-diff-inspector 会生成修复 SQL 并保存在本地文件中。因为检验的 chunk 是乱序且并行的,所以这里为每个 chunk 创建(若该 chunk 存在不同行)一个文件来保存修复 SQL,文件名是该 chunk 的全局有序的结构体。修复 SQL 和 checkpoint 的记录肯定存在先后顺序:
- 如果先写入修复 SQL 的记录,那么此时程序异常退出,这个被写入修复 SQL 但没被 checkpoint 记录的 chunk 会在下一次生成,一般情况下,这个修复 SQL 文件会被重新覆盖。但是由于桶的切分是随机分的,因此尽管切分后的 chunk 个数固定,上一次检查出的不同行在切分后 chunks 的第三个,这次可能跑到了第四个chunk 的范围内。这样就会存在重复的修复 SQL。
- 如果先写入 checkpoint,那么此时程序异常退出,下一次执行会从该 checkpoint 记录的 chunk 的后面范围开始检验,如果该 chunk 存在修复 SQL 但还没有被记录,那么这个修复 SQL 信息就丢失了。
这里采用了先写入修复 SQL 记录,下一次执行时会将排在 checkpoint 记录的 chunk 后的所有修复 SQL 文件(文件是以该 chunk 的全局有序结构体命名,因此可以很容易判断两个 chunk 的先后顺序)都移到 trash 文件夹中,以此 避免出现重复的修复 SQL 。
二分校验和自适应 chunkSize
大表做 checksum 和切分成 chunks 做 checksum 的性能损耗在于每次做 checksum 都会有一些额外消耗(包括一次会话建立传输的时间),如果把 chunk 划分的很小,那么这些额外消耗在一次 checksum 花费的时间占比会变大。通常需要把 chunk 的预定大小 chunkSize 设置大一些,但是 chunkSize 设置的过大,当上下游数据库对 chunk 做 checksum 的结果不同时,如果对这个大 chunk 直接进行按行对比,那么开销也会变得很大。
在数据同步过程中,一般只会出现 少量的数据不一致 ,基于这个假定,当校验过程中,发现某个 chunk 的上下游的 checksum 不一致,可以通过二分法将原来的 chunk 划分成大小接近的两个子 chunk,对子 chunk 进行 checksum 对比,进一步缩小不一致行的可能范围。这个优化的好处在于,checksum 对比所消耗的时间和内存资源远小于逐行进行数据比对的消耗,通过 checksum 对比不断的缩小不一致行的可能范围,可以减少需要进行逐行对比的数据行, 加快对比速度,减少内存损耗 。并且由于每次计算 checksum 都相当于遍历一次二分后的子 chunk,理论上不考虑多次额外消耗,二分检验的开销相当于只对原 chunk 多做两次 checksum。
由于做一次 checksum 相当于遍历范围内的所有行,可以在这个过程中顺便计算这段范围的行数。这样做是因为 checksum 的原理是对一行的数据进行 crc32 运算,再对每一行的结果计算异或和,这种 checksum 的无法校验出三行重复的错误,在索引列不是 unique 属性的情况下是存在这种错误的。同时计算出每个 chunk 的行数,可以使用 limit 语法定位到该 chunk 的中间一行数据的索引,这是二分方法使用的前提。
但是 chunkSize 也不能设定的过大,当一次二分后两边的子 chunk 都存在不同行,那么会停止二分,进行行比对。过大的 chunk 就更有可能同时包含多个不同行,二分校验的作用也会减小。这里设置每张表默认的 chunkSize 为 50000 行,每张表最多划分出 10000 个 chunk。
索引处理
上下游数据库的表可能会出现 schema 不同,例如下游表只拥有一部分上游的索引。不恰当的索引的选择会造成一方数据库耗时加大。在做表结构校验时,只保留上下游都有的索引(若不存在这种索引,则保留所有索引)。另一方面,某些索引包含的列并不是 unique 属性的,可能会有大量的行拥有相同的索引值,这样 chunk 会划分的不均匀。Sync-diff-inspector 在选择索引时, 会优先选择 primary key 或者 unique 的索引,其次是选择重复率最低的索引 。
where 处理
假设存在一张表 create table t (a int, b int, c int, primary key (a, b, c));
并且一个划分后的 chunk 范围是 ((1, 2, 3), (1, 2, 4)]
原版 Sync-diff-inspector 会生成 where 语句:
- ((a > 1) OR (a = 1 AND b > 2) OR (a = 1 AND b = 2 AND c > 3))
- ((a < 1) OR (a = 1 AND b < 2) OR (a = 1 AND b = 2 AND c <= 4))
可以优化为 (a = 1) AND (b = 2) AND ((c > 3) AND (c <= 4))
自适应 GC
在原版 Sync-diff-inspector 中,校验过程中可能会出现大量表被 GC 导致校验失败。Sync-diff-inspector 工具支持自适应 GC 的功能,在 Diff 进程初始化阶段启动一个后台 goroutine,在检验过程中不断的更新 GC safepoint TTL 参数,使得对应的 snapshot 不会被 GC,保证校验过程的顺利进行。
处理 Float 列
根据 float 类型的特性,有效精度只有6位,因此在 checksum SQL 中对 float 类型的列使用 round(%s, 5-floor(log10(abs( column
)))) 取 6 位有效数字作为 checksum string 的一部分,当 column 取特殊值为 0 时,该结果为 NULL,但是 ISNULL(NULL) 也作为 checksum string 的一部分,此时不为 true,这样可以把 0 和 NULL 区分开来。
用户交互优化
Sync-diff-inspector 显示如下信息:
- 将日志写入到日志文件中。
- 在前台显示进度条,并提示正在比较的表。
- 记录每个表校验相关结果,包括整体对比时间、对比数据量、平均速度、每张表对比结果和每张表的配置信息。
- 生成的修复 SQL 信息。
- 一定时间间隔记录的 checkpoint 信息。
其效果如下图:
具体细节可参考 overview
性能提升
基于以上的优化手段,我们进行了性能测试,在 Sysbench中, 构造 668.4GB 数据,共 190 张表,每张表一千万行数据,测试结果如下:
从测试结果可以看出,Sync-diff-inspector 2.0 相比于原版, 校验速度有明显提升,同时在TiDB 端内存占用显著减少 。
未来展望
开放性的架构
在 Sync-diff-inspector 中我们定义了 Source 抽象,目前只支持 TiDB 端到 TiDB 端,MySQL 端到 MySQL 端以及 MySQL 端到 TiDB 端的数据一致性校验,但是在未来,通过实现 Source 对应的方法, 可以适配多种其他数据库进行数据一致性校验 ,例如 Oracle, Aurora 等。
支持更多类型
由于部分列类型特殊,目前 sync-diff-inspector 暂不支持(例如 json,bit,binary,blob )。需要在 checksum SQL 语句中对它们特殊处理,例如对于 json 类型的列,需要通过 json_extract 提取出现在 json 中的每一个 key 的值。
更激进的二分 checksum
新版 sync-diff-inspector 采用二分 checksum 方法来减小逐行比对的数据量,但是在发现二分后的两个 chunk 都存在不一致数据时就停止继续二分,进行逐行比对。这种方法比较悲观,认为此刻 chunk 可能存在多个不一致的地方。但是根据实际情况,sync-diff-inspector 的应用场景一般是只存在少量不一致的情况,更加激进的做法是,继续二分,最后得到的是一组拥有最小行数(默认 3000 行)的且存在不一致数据的 chunk 数组,再对这些数组分别进行逐行比对。