ClickHouse 目前的事务
各版本对事务的支持
- v22.4 开始在单机 MergeTree 上支持事务,需要 "Begin Transaction" 和 "Commit/Rollback"。只支持 insert, update, delete 语句。
- v22.7 增加参数
implicit transaction
,设为 true 时,事务无需 "Begin", "Commit/Rollback" 包裹,单条语句自动在事务中执行。 - 最新的 lts 版本 v22.8 已包含以上两个功能。
目前仍不支持 ReplicatedMergeTree。ReplicatedMergeTree 支持事务需要解决多副本之间的一致性,目前社区有在讨论这个问题。
不支持跨节点的分布式事务,跨节点的分布式事务的支持需要实现 2PC 这样的分布式协议。短期内社区不会实现。//猜测因为对性能有很大的影响
对事务的要求
#22086 提出:
- 一个事务中支持多个 INSERT 语句插入数据到多个 MergeTree tables 中;
- 同一个事务内所有 INSERT 是原子的,要么都成功,要么都失败;
- 所有的 INSERT 只有 committed 以后,数据才可见;
- 一个事务支持多个 SELECT,且读取一致的 snapshot,也就是读取的数据一致;
- 支持 UPDATE 和 DELTE。
上面的功能在 simple MergeTree 上支持,不支持 ReplicatedMergeTree,隔离级别是读已提交。
CK 事务的实现 (MVCC)
MVCC 通常有多版本时间戳排序 MVTO,MVOCC,和多版本两阶段锁 MV2PL。
ClickHouse 采用 MVTO,统一由 ZooKeeper 提供单调递增的序列作为时间。
传统数据库的版本信息为 page 级别,ClickHouse 采用 data part 级别。
data part Version:创建/删除 data part 的事务 ID (creation_tid, removal_tid) 与事务提交时间 (creation_csn, removal_csn)。
- 磁盘中,data parts 的版本信息保存在每个 data part 目录下的 txn_version.txt 中,每次事务操作都往这个文件中追加事务信息。
- 内存中,以
VersionMetadata
形式保存。
事务 ID:三元组组成 (start_csn, local_tid, host_id)
事务开始时会分配一个事务号。在 TransactionLog::beginTransaction()
中生成。ZK 负责全局唯一时间戳的生成(CSN),TransactionLog 会维护一个 latest_snapshot, 有线程定期更新。
CK 中使用 latest_snapshot 来标记最新的时间点,由 ZooKeeper 创建 sequential node 而来。创建事务、写数据、删除数据时就创建一个 sequential node,从而推进时间。
- start_csn:事务开始时的最新时间戳,也是这个事务可见的 snapshot。(snapshot 指一个事务能看到的数据库的对象的集合)
- local_tid:本地递增的事务号,由本地产生,不需要 ZK 参与。
- host_id:服务器的唯一标识符,并且固定。(如不考虑 host_id,则节点局部唯一)
空事务 ID 由全 0 的三元组组成。
Comment: 上述机制加重了 ZooKeeper 的 workload,同时导致单机也需要部署 ZooKeeper,所以社区讨论也提到 使用ClickHouse Keeper 替代 ZooKeper 会有帮助。
可见性判断
Data Part 对于某个事务可见的条件:
- data part 没有 version metadata。意味着这个 data part 是在事务启用之前创建的,或者是被一个非事务创建的。需注意,若此时有一非事务写操作在进行,则隔离级别会降为读未提交。
- data part 由当前事务创建。
- data part 在当前事务开始时就已提交 (data part 的 create_csn 不为空,且 create_csn < 当前事务的 start_csn),且当前事务没有删除这个 data part。
具体实现时有一些优化的手段,可见性判断详细见 part 对事务可见性判断核心逻辑: 源码分析问题列表。
事务执行流程
- 事务开始时:获取最新的时间戳 (由 TransactionLog 维护的 latest_snapshot) 作为事务的开始时间 start_csn,获取本地的递增序列作为本地的事务号 local_tid,获取当前 Server 的唯一的编号 host_id,这三个参数组成了事务的 ID,即 TID。
- 事务提交和回滚时:会更新 CSN 作为事务结束的时间戳,该时间戳作为 data part version 中的 creation_csn 或 removal_csn。
在事务提交/回滚之前,data part 新的版本已经创建并写盘。
- 对于 insert,只会新增 data part,不会影响其他 data part,新建 data part 的同时添加 txn_version.txt,写入 creation_tid。
- 对于 update/delete,所有的 data part 都会被复制一份,会同时存在新旧两个版本。新版本的 txn_version.txt 中追加 creation_id,旧版本的 txn_version.txt 中追加 removal_tid。
i)事务提交流程
- 等待 mutation 完成;因为 mutation 由后台线程异步完成,因此事务提交前要等待 mutation 完成。
- 申请一个 CSN,该 CSN 作为 log entry 名字的一部分,ZooKeeper 创建 log entry,写入 (tid, local_id, uuid)。
- log 写入成功事务就成功了,哪怕后面追加版本信息到 txn_version.txt 失败,服务重启时也能恢复;这条 log 就相当于 redo log。因为是先写数据再写 txn_version.txt,所以一旦 log 写入 ZooKeeper 就意味着事务提交成功,可以根据这条 log 重新写 txn_version.log。
- 新版本中追加 creation_csn,旧版本中追加 removal_csn。
Redo log
写入 ZooKeeper 的 log entry 保存了成功提交的事务的信息,相当于 Redo log,但是这里的 redo log 仅仅用于恢复 txn_version.txt 的信息,因为数据已经写到磁盘了。
Undo log
因为是先写数据再写 ZooKeeper 日志,最后写 txn_version.txt 的提交信息,如果 ZooKeeper 日志写失败,意味着事务失败,此时 txn_version.txt 中没有追加提交信息,所以服务重启时可以根据 txn_version.txt 中是否有提交信息来确定事务是否成功。如果没有提交信息,说明事务不成功,就 undo。所以 txn_version.txt 中的 creation_csn、removal_csn 则相当于 undo log。
ii)事务回滚流程
- 停止正在进行的 mutation;
- 给新版本的 txn_version.txt 中追加 creation_csn: RollbackCSN,表示任何事务不可见;
- RollbackCSN 是 64 位有符号整型的最大数。
- 给旧版本的 txn_version.txt 中追加 removal_tid: (0, 0, 0),表示版本有效。
iii)异常捕获
- 客户端超时异常回滚:客户端连接服务端超时会被服务端捕获,从而进入回滚流程。
- 运行时异常捕获:在服务启动阶段就会创建一个线程不断的读取日志,分析日志,根据日志的状态提交或回滚未完成的事务
- 运行时异常包括运行时超时、运行错误、系统故障等。详见 TransactionLog
runUpdatingThread()
函数。
- 运行时异常包括运行时超时、运行错误、系统故障等。详见 TransactionLog
并发控制
冲突控制采用 MVTO 机制。
每个 data part 的 VersionMetadata
中维护变量 removal_tid_lock
,用作写锁,也是删除锁,只有删除 data part 的事务才会获取该锁。获取锁的方法:删除事务开始时,若 removal_tid_lock
为 0,表示没有其他事务删除该 data part,则将该事务 TID 对应的哈希值赋值给 removal_tid_lock
表示加锁,从而防止其他事务删除这个 data part。
只有在删除 data part 时才会通过 removal_tid_lock
对 data part 加锁,因此只有同时删除 data part 时才会有冲突。update 和 delete 事务都会删除 data part,所以会加锁,可能产生写写冲突。
虽然每个 data part 都有一个版本信息,都有 removal_tid_lock
,但是 update/delete 时,会删除所有的旧版本,并为每一个旧 data part 创建新的 data part。也就是说每个新、旧 data part 的元信息是一致的,包括加锁信息。也就意味着加锁是全表加锁。
i)写写冲突
update/delete 先将旧版本加锁,再复制所有 data part 形成新版本。会轮询所有的 data part,若有其他事务删除某个 data part (该 data part 已加锁),抛异常,该事务操作失败。此时旧版本还没有被复制,版本信息也还没有修改,所以不需要回滚操作。
❗️CK 目前的实现并没有区分两个事务是否删除的是同一个 data part,即使是不同的 data part 上有锁也认为是冲突,导致第二个事务失败。
除了
removal_tid_lock
作为冲突判断的方式以外,实现事务之前的 ClickHouse 也有避免冲突的机制:类
StorageMergeTree
的成员currently_merging_mutating_parts
保存了正在 mutate 和 Merge 的data parts,第二个事务要 mutate 或 merge 时会判断本事务所选的 data part 是否在currently_merging_mutating_parts
,如果在,第二个事务退出执行。
因为 Merge 和 Mutate 都有后台线程异步执行,所以事务开始后会立即将 mutate、merge 任务加入到执行队列,另外的线程异步的判断锁是否冲突。理论上说这种做法既不像乐观锁,也不像悲观锁,这取决于 currently_merging_mutating_parts
的判断和锁的判断的先后顺序。在实际测试中没有发现产生新的 data parts 是因为锁判断在前面。因此这种做法有优化的空间。
❓不是说事务提交前要等待 mutation 完成吗,那应该 currently_merging_mutating_parts
的判断在锁判断之前呀?
写写冲突的表现总结如下:
-
update/delete 事务之间会冲突。判断冲突的方式就是上述两种。
-
多个 insert 事务不会有写写冲突,即使新增的是同一个分区的内容也不会冲突。因为 insert 只会新增 data part,每次 insert 都只产生新的版本,旧版本的数据和 txn_version.txt 不会有任何的改动,不存在竞争。
-
insert 事务和 update/delete 事务没有写写冲突。因为 update/delete 是对旧版本加锁,而 insert 不会写旧版本。
目前 CK 有三个地方可以优化:
- SQL 语句中用 in partition 指定 partition 时,只会复制和轮询指定的 data part,也只有指定的 data part 会加锁;而 SQL 中没有指定 partition,即使只涉及部分 data part 也会复制所有的 data part,并发检测时也是轮询所有的 data part。可以下推 filter 条件,提前确定涉及的 data part。
- 虽然加上 in partition 可以减少 data part,减少轮询的范围,但是并发检测时,不同的事务删除不同的 data part 也会认为是冲突,从而导致后来的事务失败。冲突的条件可以放松,只有删除同一个 data part 时才冲突。也就是说,现在每个 data part 的版本链是相同的,可以优化为不同的 data part 有不同的版本链,不影响现在的可见性判断。
- 两个判断机制并发执行,会牺牲性能。可以优化为判断锁不冲突时才 schedule merge/mutate 任务。
ii)读写冲突
读事务只需要根据自己的 snapshot 来选择合适的版本即可,并不会判断锁的状态。
无论是写事务在先还是读事务在先,读和写在不同的版本上操作,所以并不冲突。
多表、多语句的支持
在 BEGIN
和 COMMIT/ROLLBACK
之间的语句被认为是一个事务,只会生成一个 MergeTreeTransaction
对象,共用一个 session context,每个 query 各自的 context 中的包含的也是同一个事务。事务内的操作会被事务记录,用于提交和回滚。
- 不同表的操作互相独立,因此不存在冲突。
- 若同一个事务内有多条 update/delete 语句操作同一个表,因为是同一个事务,按照现在的判断规则,写锁没有冲突;语句是按顺序执行,mutate 任务按顺序提交到队列,单线程从队列获取任务执行,所以也不会有冲突。
事务对 Merge 的影响
整体上说,增加事务后不影响 Merge 的整体流程,但会对 Merge 的行为有细微的影响:
-
影响 data parts 的可见性,从而影响待合并的 data parts 的选择。没有事务之前,涉及多个任务的 data part 会放到一个 Merge 任务中完成,而有事务以后只会选择当前事务可见的 data parts 来合并。
-
带事务的 optimize 语句触发的 Merge 会将事务信息贯穿整个 Merge 的过程。选择 Merge 的 data parts 时根据事务的 snapshot 和数据的版本信息判断 data part 是否可见;
-
非事务 Merge 由事务创建的 data part 会破坏隔离性,因此会报异常,也就是说非事务不能合并有版本信息的 data part。这一点与非事务update/delete的行为不一致,非事务的update/delete允许修改有版本信息的 data part,但是重启服务或查询时会报异常。
-
后台执行的 Merge 任务会默认创建事务,添加新的 data part 、删除旧的 data part 时都由
MergeTreeTransaction
的函数执行。一旦出现异常就会被捕获,会自动执行 rollback 操作。
版本的可见性基于:版本创建时间,创建版本的事务状态,版本是否已被删除
基于版本信息的并发控制:
- 只读事务:根据事务的 snapshot 和 data part 的版本信息选择事务可见的 data part 集合。只读事务不阻塞其他事务。
- 写事务:
- Insert/Update:无需对 data part 加锁
- delete:要对将被删除的 data part 加锁,同时只有一个事务能删除一个 data part。???
本文作者:Joey-Wang
本文链接:https://www.cnblogs.com/joey-wang/p/17713597.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步