MatrixOne 悲观事务实现
作者:张旭 MO 研发工程师
Part 1 MatrixOne 事务特性
MO 之前仅支持基于 SI(Snapshot Isolation)的乐观事务。
目前已支持悲观事务以及 RC 隔离级别。RC 和 SI 的事务可以同时在一个 MO 集群中运行。
乐观事务和悲观事务不能同时运行,集群中要么使用悲观事务模型,要么使用乐观事务模型。
Part 2 MatrixOne 事务
一个 MatrixOne 集群由 CN(Compute Node)、DN(Data Node)、LogService 三个内置服务,以及一个外部的对象存储服务组成。
2.1CN(Compute Node)
计算节点,MO 中所有的繁重的工作都在 CN 完成。每一个事务客户端(JDBC,mysql 客户端)都只会和一个 CN 建立链接,在这个链接上发起的事务,都会在对应的 CN 上创建,每个事务都会在 CN 创建一个 workspace,来存放事务的写入的临时数据。在事务提交的时候,workspace 中的事务的写入的临时数据会发送给 DN 节点做 Commit 处理。
2.2DN(Data Node)
数据节点,所有 CN 的事务都会提交到 DN 节点。DN 负责写入事务的提交日志到 LogService,并且写入提交数据到内存。当内存增长满足一定条件,会把内存数据提交到对象存储,并且同时清理 LogService 中对应的日志。
2.3LogService
日志节点,可以认为日志节点是 DN 节点的 WAL。LogService 使用 Raft 协议把日志存储为多份(默认是 3 份),提供高可用和强一致性。MO 可以随时随地通过 LogService 来恢复 DN 节点。
LogService 中存储的 Log 不会一直无限制的增长,当日志的大小满足一定大小的时候,DN 会把存放在 LogService 中的 Log 对应的数据写入到外部对象存储,并且会 Truncate 掉 LogService 中的 Log。
MO 把存放在 LogService 中的数据称为 LogTail。所以对象存储中的数据+LogTail 就是 MO 数据库的所有的数据。
2.4 时钟方案
MO 的时钟方案采用的是使用 HLC,并且和内置的 MORPC 集成起来,来同步 CN,DN 之间的时钟。由于篇幅原因,这里就不展开介绍 HLC 了。
2.5 事务的读操作
事务的读操作发生在 CN 节点,能够看到 MVCC 的哪些版本的数据,取决于事务的 SnapshotTS。
当事务确定了事务的 SnapshotTS 之后,就需要看到一个完整的数据集。完整的数据集包含 2 部分,一部分在对象存储中,还有一部分在 LogTail 中,这部分数据在 DN 的内存中。
读对象存储中的数据,可以直接访问对象存储,并且 CN 提供了一个 Cache 来加速这部分数据的读取。
读 LogTail 中的数据,0.8 版本之前,都会根据 SnapshotTS 来强制和 DN 同步一次需要的 LogTail 数据,我们称之为 Pull 模式。Pull 模式下,只有事务开始后,才主动的和 DN 同步 LogTail,并且在不同的事务中,传输的 LogTail 有很多数据都是重复的。显而易见,Pull 模式的性能是比较差的,延迟高,吞吐差。
0.8 版本开始,MO 实现了 Push 模式。LogTail 的同步不再是事务开始的时候主动发起同步 LogTail 的请求。修改为 CN 级别的订阅的方式,DN 在每次 LogTail 变更的时候,把增量 LogTail 同步给订阅的 CN。
在 Push 模式下,每个 CN 都会持续不断的收到 DN Push 过来的 LogTail,并且在 CN 维护一个和 DN 一样的内存数据结构(数据还是按照 MVCC 的方式组织)以及一个最后消费的 LogTail 的时间戳。一旦一个事务的 SnapshotTS 被确定,只需要等到最后消费的 LogTail 的时间戳>=SnapshotTS 就意味着,CN 拥有用了完整的 SnapshotTS 的数据集。
2.6 数据可见性
一个事务能够读到哪些数据,取决于事务的 SnaphotTS。
如果每个事务都使用最新的时间戳作为事务的 SnapshotTS,那么这个事务一定可以读到这个事务之前 Commit 的任何数据,这样看到的数据是最新鲜的,但是这样会付出一些性能的代价。
在 Pull 模式下,需要在同步 LogTail 的时候,在 DN 节点等待 SnapshotTS 之前的事务全部被 Commit,SnapshotTS 越新,需要等待的 Commit 越多,延迟就越大。
在 Push 模式下,在 CN 节点需要等待 SnapshotTS 之前的事务的 Commit 的 LogTail 被消费,SnapshotTS 越新,需要等待的 Commit 越多,延迟就越大。
但是在很多时候,我们不需要一直看到最新的数据,MO 目前给了 2 种数据新鲜度级别:
永远看到最新的数据,SnapshotTS 使用当前时间戳
使用当前 CN 节点消费完的最大 LogTail 时间戳作为 SnapshotTS
对于第 2 种方式,好处是事务没有任何延迟,就可以立即开始读写数据,因为满足条件的 LogTail 都已经全部具备了,性能和延迟会表现的很好。但是带来的问题就是同一个数据库链接上的多个事务,有可能后一个事务看不到前一个事务的写入操作,这是因为后一个事务开始的时候,DN 还没有把前一个事务的 Commit 的 LogTail Push 到当前 CN,这样后一个事务就会使用一个较早的 SnapshotTS,导致看不到之前事务的写入。
为了解决这个问题,MO 维护了 2 个时间戳,一个是当前 CN 最后一个事务的 CommitTS 记做 CNCommitTS,一个是当前 Session(数据库连接)最后一个事务的 CommitTS 记做 SessionCommitTS。并且给出了 2 个数据可见性的级别(我们把当前 CN 消费的最大的 LogTail 的 TS 记做 LastLogTailTS):
Sessin 级别的数据可见性,使用 Max(SessionCommitTS, LastLogTailTS)作为事务的 SnapshotTS,这样保证了一个 Session 上发生的事务的数据的可见性。
CN 级别的数据可见性,使用 Max(CNCommitTS, LastLogTailTS)作为事务的 SnapshotTS,这样保证了同一个 CN 上发生的事务的数据的可见性。
2.7 冲突处理
MO 以前的事务模型是乐观事务,所有的冲突处理都发生在 Commit 阶段,在 DN 处理。冲突处理比较简单,就不展开了,主要就是检查写写冲突,检查事务的[SnapshotTS, CommitTS]之间有没有交集。
Part 3 RC(Read Committed)
上面的章节主要介绍了 MO 的事务的处理。MO 之前只支持 SI 的隔离级别,基本 MVCC 来实现的,数据都是有多版本的。目前已支持 RC(Read Committed)事务的隔离级别。
需要考虑在多版本上实现 RC 的隔离级别,对于 SI 的事务来说,在事务的生命周期,需要保持一个一致性的 Snapshot,不管什么时候去读,读到数据都是一样的。对于 RC 需要看到最新的提交数据,可以理解成这个一致性的 Snapshot 不再是事务生命周期的,而是属于每个查询。每个查询开始的时候,使用当前的时间戳来作为事务的 SnapshotTS,来保证查询可以看到查询之前的提交的数据。
RC 模式下,对于带有更新的语句(UPDATE, DELETE, SELECT FOR UPDATE),一旦遇到写写冲突,意味着查询修改的数据被其他的并发事务修改了,由于 RC 需要看到最新的写入,所以这个时候,一旦冲突的事务提交了,那么需要更新事务的 SnapshotTS,重试。
Part 4 悲观事务
本章节主要介绍 MO 如何实现悲观事务,以及一些设计思考。
4.1 需要解决的核心问题
MO 实现悲观事务需要有一些问题需要解决:
如何提供锁服务
锁服务,用来锁住单条记录,一个范围,甚至一个 Table。
当发现事务在读写请求的时候,需要加锁的时候,发现锁冲突,需要实现锁等待。当锁等待形成环的时候,需要有死锁检测机制来打破死锁。
可扩展的锁服务的性能
MO 的是事务会发生任意 CN 节点。当多个节点都需要访问锁服务的时候,锁服务的性能需要能够 Scale-out。
去掉 Commit 阶段的冲突检测
悲观模式下,MO 集群存在多个 DN 的情况下,如何确保去掉 Commit 阶段的冲突检测是安全的。
4.2 锁服务
MO 已实现了 LockService 来提供锁服务。提供加锁,解锁,锁冲突检测,锁等待以及死锁检测的能力。
LockService 不是一个独立部署的组件,是 CN 的一个组件。在一个 MO 集群中,与多少个 CN 就有多少个 LockService 实例。
LockService 内部会感知到集群中其他 LockService 的实例,并且协调集群中的所有的 LockService 实例一起工作。每个 CN 都只会访问当前节点的 LockService 实例,不会感知到其他 LockService 实例。对于 CN 来说,当前节点的 LockService 表现的和单机组件一样。
4.2.1 LockTable
一个 Table 的锁信息都放在一个叫 LockTable 的组件中,一个 LockService 会包含很多的 LockTable。
MO 集群中,任意一个 LockService 第一次访问一个 Table 的锁服务的时候,会创建一个 LockTable 实例,这个 LockTable 会被挂载到这个 CN 的 LockService 实例中。这个 LockTable 在 LockService 内部被标记为一个 LocalLockTable,表示这个 LockTable 是一个本地的 LockTable。
当其他 CN 的也访问这个 Table 的锁服务的时候,这个 CN 对应的 LockService 也会持有一个这个 Table 的 LockTable,但是会被标记为 RemoteLockTable,来表示这个是一个在其他 LockService 实例上的 LockTable。
所以在整个 MO 集群中,一个 LockTable 会有 1 个 LocalLockTable 和 N 个 RemoteLockTable 实例。只有 LocalLockTable 是真正保存锁信息的,RemoteLockTable 就是一个访问 LocalLockTable 的代理。
4.2.2 LockTableAllocator
LockTableAllocator 是用来分配 LockTable 的组件,在内存中记录了 MO 集群中所有的 LockTable 的分布情况。
LockTableAllocator 不是一个独立部署的组件,是 DN 的一个组件。
LockTable 和 LockService 的绑定是会发生变化的,比如 LockTableAllocator 检测到 CN 下线,绑定关系就会发生变化,每次发生变化,绑定版本号都会递增。
在[事务开始,事务提交]的时间窗口中,LockTable 和 LockService 的绑定关系是有可能变化的,这种不一致带来了数据冲突,导致悲观事务模型失效。所以 LockTableAllocator 是一个 DN 的组件,在处理事务 Commit 之前,检查一下绑定版本是否有变化,如果发现一个事务访问过的 LockTable 的绑定关系过时了,就会 abort 掉这个事务,来确保正确性。
4.2.3 分布式死锁检测
所有的活跃事务所持有的锁会分布在多个 LockService 的 LocalLockTable 中。所以我们需要一个分布式的死锁检测机制。
每个 LockService 中都有一个死锁检测模块,检测机制大致是这样:
在内存中为每一个锁维护一个等待队列;
当一个新的事务发生冲突的时候,需要把这个事务加入到锁持有者的等待队列中;
启动一个异步任务,递归的查找这个等待队列中所有的事务所持有的锁,检查是否有等待的环。如果遇到远程事务的锁,使用 RPC 来获取远程事务持有的所有的锁信息。
4.2.4 可靠性
整个锁服务的关键数据,都是存放在内存的,比如锁信息以及 LockTable 和 LockService 的绑定关系。
对于 LocalLockTable 内部记录的锁信息,如果 CN 宕机,那么和这个 CN 链接的事务都会失败,因为数据库连接断开了。然后 LockTableAllocator 会重新分配 LockTable 和 LockService 的绑定关系,整个锁服务可以正常服务。
LockTableAllocator 运行在 DN 中,一旦 DN 宕机,HAKeeper 会修复一个新的 DN,所有的绑定关系在新 DN 全部失效,新 DN 全部没有,这意味着所有的当前活跃事务都会因为绑定关系不匹配提交失败。
4.3 如何使用锁服务
为了优雅的使用锁服务,MO 实现了一个 Lock 的算子,这个算子负责调用和处理锁服务。
SQL 在 Plan 阶段,如果发现是悲观事务,会处理对应的情况,在执行的阶段,会在合适的位置插入 Lock 算子。
>>> Insert
对于 insert 操作,Plan 阶段会在 Insert 其他的算子之前首先放入 Lock 算子,后续执行的时候,只有加锁成功后,才会执行后续的算子。
>>> Delete
和 Insert 类似,也是在 Plan 阶段在 Delete 的其他算子之前放入 Lock 算子,后续执行的时候,只有加锁成功后,才会执行后续的算子。
>>> Update
Update 在 Plan 阶段会被拆解成 Delete+Insert,所以会有 2 次加锁的阶段(如果没有修改主键,那么会优化成 1 次加锁,Insert 阶段就不会加锁)。
评论