一文了解 TiDB 8.0 新特性之 Pipelined DML
作者: 数据源的 TiDB 学习之路原文来源:https://tidb.net/blog/e411b9a7
什么是 Pipelined DML
TiDB 的 2PC 协议在提交之前将所有事务的写入操作缓存在 TiDB 的内存中,因此一个大型事务很容易引发 OOM(Out Of Memory)。在批量数据处理中,单个语句处理数百万行数据是很常见的,正常的事务基本无法满足。为了解决这个问题,TiDB 之前也开发了一些功能,比如 批量 DML (已废弃) 和 非事务性 DML。然而,这些功能要么存在安全隐患,要么限制比较多,无法满足所有用户的需求。
Pipelined DML 是一种对原始 2PC 协议的改进,它引入了一种新的事务模式。在这种模式下,事务的写入操作会不断地刷新到 TiKV 中,而不是在内存中缓存直到提交。这种方法使得 TiDB 事务的大小在很大程度上不再受内存限制。通过异步刷新缓冲区,执行阶段和预写阶段形成了一条流水线,从而降低了事务的延迟。
![](https://tidb-blog.oss-cn-beijing.aliyuncs.com/media/unnamed-1719822156839.png)
普通 2PC 流程
![](https://tidb-blog.oss-cn-beijing.aliyuncs.com/media/unnamed-1719822156832.png)
Pipelined DML 流程
TiDB 过去针对大事务的一些应对方法
对于正常的事务,数据变更在提交之前会在内存中缓存,因此事务越大消耗的内存越多。TiDB 有两种机制用于限制事务大小以避免 OOM(内存溢出):
通过配置 txn-total-size-limit 控制事务的大小。如果它被设置为非默认值(默认值 100MB),那么它将控制事务的大小。
如果 txn-total-size-limit 被设置为默认值,内存跟踪器使用系统变量 tidb_mem_quota_query 来设置内存使用的限制。
在 TiDB 中,为了绕过事务大小的限制,曾有一个名为 batch-dml 的特性。batch-dml 的工作方式是将一个 SQL 语句拆分成多个事务并分别提交。这个特性是通过一些系统变量来启用和控制的,包括 tidb_enable_batch_dml、tidb_batch_dml_insert、tidb_batch_dml_delete 和 tidb_dml_batch_size。此功能目前已被废弃,因为它存在安全风险并且极易导致数据损坏。
为了提供一种安全的方法来拆分语句中的事务,TiDB 实现了非事务性 DML 功能。它本质上像一个用户脚本一样工作,将单个 SQL 语句拆分成多个语句来执行,而不是直接拆分事务本身。因此,它避免了数据损坏的风险,然而拆分和连接 SQL 语句的复杂性给这项功能带来了许多限制。有关非事务性 DML 的具体内容,可参考官方文档 非事务 DML 语句
Pipelined DML 的实现目标和要求
TiDB 由于设计原因,对于超大的事务(超过数十 GB)的支持能力有所不足,因为它在提交之前会将 KV(键值对)变更存储在内存中。这种做法存在两个主要的缺点:
内存不足时事务失败。当执行如 ETL 工作这类需要大量数据处理的任务时,如果内存不足以容纳整个事务的变更,那么事务就会失败。
随着内存缓冲区增长,性能开销增加。随着内存缓冲区变得巨大,性能会受到越来越大的影响。例如,在内存缓冲区的红黑树实现中,存在基本的 O(NlogN) 时间复杂度。这意味着随着 N(变更数量)的增加,操作所需的时间会以对数级增长,这会导致在处理大型事务时性能显著下降。
Pipelined DML 实现的主要目标就是能够优雅的解决上述两类问题,同时能够满足如下一些基本的要求。
事务 ACID 保证
对 SQL 语法几乎没有限制
事务大小可以达到 500+ GB
一般负载下的写性能不低于 14 k/s
兼容现有功能
Pipelined DML 的设计方案与验证结论
Pipelined DML 功能在初始设计时提供了 2 套设计方案,它们都是通过刷新请求定期将内存缓冲区的内容刷新到 TiKV。在提交时,一旦内存缓冲区的所有内容都被刷新,就不再需要预写阶段。两种设计方案在提交方法上存在根本性的不同:
方案 A:通过扫描键空间来提交。这是一种被动的方式来查找属于事务的键。
方案 B:通过在树中跟踪二级键(secondary keys)来提交,类似于异步提交事务中的锁定二级键。这是一种主动的方式来指定属于事务的键。
基于以上设计方案进行测试验证,得到如下结论:
性能可接受
TiDB 内存。两种解决方案都能将大事务中 membuffer 的内存消耗降低到 1% 以下,从而允许用户执行涉及数百 GB 语句。
事务延迟。这些解决方案创建了一个连接执行和预写阶段的管道,使得减少了整个事务的延迟。注意,批处理的大小和涉及批处理的 Regions 数量会极大地影响性能。
良好的用户友好性
仅适用于自动提交语句,满足当前用户需求。
不需要额外的部署要求。
对 SQL 语法没有限制。
代码复杂性可接受
仅更改了事务协议,即 TiKV 中的 client-go 和调度器层。
Pipelined DML 的部分实现
提出的解决方案仅影响 TiKV 客户端和 TiKV 的调度器部分,以及 TiDB 中的必要控制代码。这个增强功能对于像优化器和执行器等上层,以及底层存储层来说是透明的。下图中红色方框代表受影响模块,黄色方框代表不受影响的模块。
![](https://tidb-blog.oss-cn-beijing.aliyuncs.com/media/unnamed-1719822156821.png)
数据结构
memdb 修改。为了消除内存限制,不能让 memdb 存储所有的变更。大事务和普通事务共享相同的执行代码,选择修改 memdb 可以避免对 TiDB 代码进行过多的修改。具体方法是为当前的 memdb 提供一个包装器(wrapper):
在内存池中存在 2 个 memdb,一个是可变的(mutable),另一个是不可变的(immutable)
一旦 可变 memdb 满足刷新条件,并且 不可变 memdb 完成刷新,丢弃 不可变 memdb ,将 可变 memdb 切换为不可变,并创建一个新的 可变 memdb。
当 可变 memdb 变得过大且无法刷新时,写入过程将被阻塞。这可能在刷新操作缓慢时发生。
写入操作仅针对 可变 memdb 进行。
![](https://tidb-blog.oss-cn-beijing.aliyuncs.com/media/unnamed-1719822156868.png)
在任何时候,只允许最多一个 memdb 处于刷新过程中,避免并发刷新。因为如果有多个 memdb 同时刷新,可能会造成数据错乱。
保证线性一致性。仅避免并发刷新还不够,为了确保系统的线性一致性,需要为每个刷新操作附加一个自增的编号,称为 generation。每次刷新中的每个预写请求都会携带这个编号到 TiKV。在 LOCK CF 中存储的锁会保存这个编号,并在锁值更新时更新它们。
在锁中跟踪二级键。事务中有三种类型的键:
主键(Primary key):主键存储在客户端内存中。选择方式与之前相同。
子主键(Sub-primary key,SPK):这些键也存储在客户端内存中。键的格式为 spk_<start_ts>_<spk_id> 。这是一种特殊类型的键,用于大型事务。它的值包含许多二级键,有助于跟踪属于一个事务的所有二级键。
二级键(Secondary keys):二级键只能暂时存储在内存中。一旦刷新操作完成,那些在 TiKV 中已经持久化的键和值就会从客户端内存中删除。
事务协议
![](https://tidb-blog.oss-cn-beijing.aliyuncs.com/media/unnamed-1719822156815.png)
上图展示正常事务和与提议的大事务处理过程差异。在事务协议上,TiDB 需要考虑多个接口上的变化,包含但不限于以下相关内容。
适配暂存(staging)接口。暂存接口与 memdb 的实现紧密耦合。针对暂存接口的适配有 2 种方案,为简单起见当前选择方案 2,即在大事务中绕过使用暂存接口。
方案 1:在大事务中实现兼容的暂存接口。
方案 2:大事务绕过使用暂存接口。
写(write)。对于写的接口保持不变。每次对 memtable 进行写操作后,都会触发一个检查以开始刷新。检查条件可以基于当前 memdb 中的键数或 memdb 当前总大小。如果可变 memdb 变得太大,且上一次刷新尚未完成,则写操作将被阻塞,直到上一次刷新完成。除了值之外,写操作还可以写入标志(flags),这些标志存储在 memdb 中,但不存储在 TiKV 中。这些标志通常控制特定的键行为。在大事务中,标志的处理方式有所不同。
一些以前在预写请求中工作的标志现在将在刷新请求中工作,例如 PresumeKNE、AssertExist、AssertNotExist。
一些标志在大事务中不会出现,例如 KeyLocked 和 NeedConstraintCheckInPrewrite。
其他的标志将根据具体情况进行处理。
通过这种方式,可以确保在大事务中,memdb 的使用与常规事务保持一致,同时通过引入额外的刷新逻辑来管理内存使用量和并发写入。此外,对于在 memdb 中处理的特定标志,也根据事务类型进行了相应的调整。
刷新(flush)。将批量数据分成多个组。对于每个组,创建一个新的条目,其键(key)为 spk_<start_ts_<spk_id>,值为该组中所有键的编码(encode)。将该键(不包括其值)存储在内存中,我们称之为 SPK(子主键),与该组一起刷新它。一个组不能无限制地大,因为 SPK 的值必须包含组中的所有键。
读(read)。对于
Get(key)
和BatchGet(keys)
操作,按照以下顺序进行读取,如果在前一层找不到键,则转向下一层:可变 memdb -> 不可变 memdb -> TiKV。对于Iterator
和Scan
操作,类似于现有的UnionIter
,使用三层UnionIter
:可变 memdb- > 不可变 memdb -> TiKV。提交(commit)。在提交阶段,我们需要将预写记录转换为可读的提交记录,关键在于如何找出所有需要提交的键。理想的方式是让每个存储节点扫描其上的锁并提交它们,但处理 Raft Leader 转移时会很复杂。因此最好按 Regions 进行提交。客户端在预写阶段会记录所有键的下界和上界。在提交阶段,TiDB 将迭代相关的 Regions,并限制每个存储节点的并发提交 Regions。当批量大小达到限制(256 个键或 32K 大小)时,它会停止扫描锁,进行异步写入,并生成一个新的任务等待下一轮执行。
并发控制(concurrency control)。在需要大事务的典型场景中,我们预期竞争会很小。因此,大事务采用扩展的乐观事务模型,该模型避免使用悲观锁,并在预写阶段进行存在性检查。
崩溃恢复(crash recovery)。在通过主键检查事务状态后,TiDB 会为该区域解决锁。如果协调器宕机,剩余的锁将由垃圾收集器(GC)处理。大型事务可能会留下许多未解决的锁,TiDB 有事务状态缓存(Transaction Status Cache)。一旦一个属于大型事务的锁被检查过一次,后续的读取请求在遇到同一个 TiKV 中的其他锁时,可以通过检查事务状态缓存来快速忽略这些锁。
GC。GC 会阻塞,直到大型事务完成。在大型事务完成之后的一段时间内,需要 GC 掉相关的 SPK(子主键)。在解决锁之后,GC 工作线程可以安全地扫描这个范围,找到无用的 SPK 并删除它们。
冲突解决。
Large-write x normal-write。无论是使用 LOCK CF 中的锁还是内存中的悲观锁,一旦某个事务获得了锁,其他尝试获取该锁的事务就会失败并收到一个
KeyIsLocked
错误。Large-write x large-wirte。和上述一样,但不建议多个可能冲突的大型事务并行运行。
Large-write x read。当读请求遇到由大事务写入的锁时,根据读请求的时间戳和大事务的开始时间戳之间的关系决定如何处理这个锁。(1)如果
read_ts < start_ts
,说明读请求的时间戳早于大型事务的启动时间,此时读请求可以忽略这个锁,因为它不会读取到大型事务未提交的数据。(2)如果read_ts >= start_ts
,读请求需要更谨慎地处理这个锁。类似于旧的大型事务处理,读请求会尝试在主键(PK)上调用check_txn_status
来获取大型事务的状态,并尝试将其min_commit_ts
(最小提交时间戳)向前推进,以确保min_commit_ts
大于读请求的read_ts
。如果min_commit_ts
成功地被推进,读请求可以忽略这个锁,因为它知道大型事务的提交不会影响到它的读取。但是,如果min_commit_ts
不断被推进,可能会导致大型事务在提交时遇到困难,因为它需要等待所有比它启动时间早但min_commit_ts
更高的读请求都完成。这种情况通常被称为读写冲突,它可能导致事务延迟和性能下降。
备注
本文参考自 tidb/docs/design/2024-01-09-pipelined-DML.md at release-8.1 · pingcap/tidb
版权声明: 本文为 InfoQ 作者【TiDB 社区干货传送门】的原创文章。
原文链接:【http://xie.infoq.cn/article/b3601fb9ddf4d8aa34b719adf】。文章转载请联系作者。
评论