曾经沧海难为水,除却巫山不是云。|

Joey-Wang

园龄:4年3个月粉丝:17关注:0

2023-09-19 00:55阅读: 1339评论: 0推荐: 0

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 提出:

  1. 一个事务中支持多个 INSERT 语句插入数据到多个 MergeTree tables 中;
  2. 同一个事务内所有 INSERT 是原子的,要么都成功,要么都失败;
  3. 所有的 INSERT 只有 committed 以后,数据才可见;
  4. 一个事务支持多个 SELECT,且读取一致的 snapshot,也就是读取的数据一致;
  5. 支持 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 对事务可见性判断核心逻辑: 源码分析问题列表

事务执行流程

  1. 事务开始时:获取最新的时间戳 (由 TransactionLog 维护的 latest_snapshot) 作为事务的开始时间 start_csn,获取本地的递增序列作为本地的事务号 local_tid,获取当前 Server 的唯一的编号 host_id,这三个参数组成了事务的 ID,即 TID。
  2. 事务提交和回滚时:会更新 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)事务提交流程

  1. 等待 mutation 完成;因为 mutation 由后台线程异步完成,因此事务提交前要等待 mutation 完成。
  2. 申请一个 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。
  3. 新版本中追加 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)事务回滚流程

  1. 停止正在进行的 mutation;
  2. 给新版本的 txn_version.txt 中追加 creation_csn: RollbackCSN,表示任何事务不可见;
    • RollbackCSN 是 64 位有符号整型的最大数。
  3. 给旧版本的 txn_version.txt 中追加 removal_tid: (0, 0, 0),表示版本有效。

iii)异常捕获

  • 客户端超时异常回滚:客户端连接服务端超时会被服务端捕获,从而进入回滚流程。
  • 运行时异常捕获:在服务启动阶段就会创建一个线程不断的读取日志,分析日志,根据日志的状态提交或回滚未完成的事务
    • 运行时异常包括运行时超时、运行错误、系统故障等。详见 TransactionLogrunUpdatingThread() 函数。

并发控制

冲突控制采用 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 有三个地方可以优化:

  1. SQL 语句中用 in partition 指定 partition 时,只会复制和轮询指定的 data part,也只有指定的 data part 会加锁;而 SQL 中没有指定 partition,即使只涉及部分 data part 也会复制所有的 data part,并发检测时也是轮询所有的 data part。可以下推 filter 条件,提前确定涉及的 data part
  2. 虽然加上 in partition 可以减少 data part,减少轮询的范围,但是并发检测时,不同的事务删除不同的 data part 也会认为是冲突,从而导致后来的事务失败。冲突的条件可以放松,只有删除同一个 data part 时才冲突。也就是说,现在每个 data part 的版本链是相同的,可以优化为不同的 data part 有不同的版本链,不影响现在的可见性判断。
  3. 两个判断机制并发执行,会牺牲性能。可以优化为判断锁不冲突时才 schedule merge/mutate 任务

ii)读写冲突

读事务只需要根据自己的 snapshot 来选择合适的版本即可,并不会判断锁的状态。

无论是写事务在先还是读事务在先,读和写在不同的版本上操作,所以并不冲突。

多表、多语句的支持

BEGINCOMMIT/ROLLBACK之间的语句被认为是一个事务,只会生成一个 MergeTreeTransaction对象,共用一个 session context,每个 query 各自的 context 中的包含的也是同一个事务。事务内的操作会被事务记录,用于提交和回滚。

  1. 不同表的操作互相独立,因此不存在冲突。
  2. 若同一个事务内有多条 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 操作。


版本的可见性基于:版本创建时间,创建版本的事务状态,版本是否已被删除

基于版本信息的并发控制:

  1. 只读事务:根据事务的 snapshot 和 data part 的版本信息选择事务可见的 data part 集合。只读事务不阻塞其他事务。
  2. 写事务:
    • Insert/Update:无需对 data part 加锁
    • delete:要对将被删除的 data part 加锁,同时只有一个事务能删除一个 data part。???

本文作者:Joey-Wang

本文链接:https://www.cnblogs.com/joey-wang/p/17713597.html

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   Joey-Wang  阅读(1339)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开