目录
一、概述
二、技术细节和OpenGauss中的实现
1、内存表在内存中的组织
2、事务并发控制算法
3、检查点算法
这篇文档写于2022年6月份,今天打算发到网上,重读时发现可能opengauss mot现在的代码已经有所改变,文中有些代码分支可能已经过时,但是许多思想的出发点仍然很有参考价值,很有助与理解opengauss mot和mot表。
一、概述
OpenGauss的MOT引擎,目的在于将表数据存储到内存,降低IO对性能的影响,且保证可持久性(即已提交的事务,不因断电而丢失)。同时,MOT引擎对表的内存结构、事务的并发控制算法、日志算法、检查点算法使用个更加优化的模型。这些优化针对的是高并发的OLAP业务,事务并发控制使用的是基于有效性检查的乐观算法,这种算法更适于并发时,读写或写写冲突较少,读占大多数的场景。
MOT的设计上参考了下面几篇论文:
1、《Cache Craftiness for Fast Multicore Key-Value Storage》
2、《Speedy Transactions in Multicore In-Memory Databases》
3、《Low-Overhead Asynchronous Checkpointing in Main-Memory Database Systems》
论文1主要描述了一种称为masstree的B+树,创新点在于通过减少对全局变量的争用,提高多核服务器的读写并发性能。一个B+树可以看做一个key-value数据库,以树结构组织的数据库,增删改查时,访问记录,要经过树的内部节点,且内部节点可能改变,当多个线程并发操作时,会对树的中间节点加锁,以防止树结构改变导致读写单个记录时的不一致性,然而这又引入了性能问题。学术界和工业界一直在努力解决一致性和性能的矛盾。论文1中的Masstree数据结构及其操作算法,就是最新的解决方案。
论文2主要描述了基于有效性检查的事务并发控制算法(OCC),创新点也在于通过减少锁和对全局变量的争用,提高多核服务器的读写并发性能。论文1保证了单个记录(key-value)的原子性读写,论文2中保证了包含多个读写操作的事务的原子性。论文2中的事务底层,是单条记录的读写,要调用论文1中的接口。论文1还给出了写redo日志的方法,以保证恢复时的一致性。
论文3描述了对内存数据库做检查点和恢复的算法(CALC)。内存数据库仍然要求崩溃时保证持久性。这是通过磁盘对内存数据库(表)的完整备份和wal日志实现的。内存数据库(表)在磁盘中的完整备份,就是内存数据库所谓的检查点,可见内存数据库的检查点概念和磁盘数据库的检查点概念是完全不同的。对内存数据库做检查点,就是在磁盘中保存一份内存数据库(表)的完整备份,但是,内存中的数据量通常很大,保存到磁盘需要一段时间,这段时间内,内存数据库内容可能改变,如何保证保存到磁盘的备份是一致的呢?
这就是论文3要解决的问题,其算法的特点有:
- 创建检查点时不需要数据库停止运行修改操作,仍然能得到一致性的备份。
- 不需要数据库支持完整的多版本,不会带来多版本导致的内存膨胀,仍然能得到一致性的备份。
- 不需要借助日志,仍然能得到一致性的备份。
其关键就是创建检查点的算法流程。
总的来说,OpenGauss实现了三篇论文中的算法,有些算法没有完全实现,可能是出于工程上的考虑,OpenGauss的代码实现已经很好,但还有改进空间。这次对论文和OpenGauss代码的研究,让我感到内存数据库是未来OLTP数据库的发展方向,未来可能还会有更好的设计出现。
二、技术细节和OpenGauss中的实现
这一章还是以三篇论文为纲,将OpenGauss MOT所有的技术分为三大块:
1、内存表在内存中的组织
OpenGauss中,MOT表数据及其元数据所在的内存,称为全局内存,每个事务(线程)还有私有内存,计算时事务从全局内存读取数据到私有内存,中间结果先写到私有内存,提交时,经有效性检查,再写到全局内存。全局内存中,一个MOT::Table对象存储一个表的元数据,做检查点时,会把所有MOT::Table对象保存到磁盘文件,启动重建时从磁盘文件重建MOT::Table对象。
全局内存中,表数据的组织结构就是论文1所说的masstree,它是一种B+树变种,每个表就是一个masstree,叶子节点就是一个记录,对记录的增删改查,就是对叶子节点的增删改查,操作要使用masstree提供的接口,接口内部自动会处理树结构变化和多线程并发操作的冲突,保证单个记录(key-value)读写的原子性。
上图就是masstree数据结构的示意图,把记录的主键(primay key)看做一个字符串,取前8个字节作为键值形成一个B+Tree(红色框),这是masstree的第一层,如果主键字符串长度超过8字节(例如14个字节),那么后6个字节作为键值再形成一个子B+Tree(蓝色框),前8个字节作为键值的叶子节点(红色圈),有一个指针指向子B+Tree,这个子B+Tree作为masstree的第二层,对于字节数更长主键以此类推,可以形成多层子树。
例如,主键值为‘abcde’的记录,它会落到masstree的第一层B+Tree的叶子节点(黄色圈),主键值为’abcdefgh’的记录,它也会落到masstree的第一层B+Tree的叶子节点(绿色圈),而主键值为‘abcdefgh123456’,它会落到masstree的第二层B+Tree的叶子节点(蓝色圈)。
Masstree提供了put(basic_table::insert)、get(basic_table::find)、remove(basic_table::remove)操作接口,这些操作保证原子性,通过提供尽量高的并发性能(吞吐量),事务读写单个记录时会调用这些接口。
在OpenGauss中,masstree既是内存表的组织结构,又是表的索引结构。如果不为表创建索引,也默认创建masstree索引,以rowid为key,行记录为value。
OpenGauss代码中的masstree数据结构和操作,并不是从头自己写的,而是引用了第三方masstree库。
2、事务并发控制算法
论文1里的并发处理,指的是masstree保证单个记录(key-value)多个线程读写(删除、插入)时的原子性,而多个key-value的操作组成事务,事务的并发控制算法,解决的是多个事务在并发执行时的原子性,和次前提下如何尽量提高并发性,以及冲突时的处理办法。
OpenGauss MOT引擎的事务并发控制机制,与磁盘表不同,与OpengGauss中已有的磁盘表引擎互相独立,所以目前不支持将对磁盘表的操作和MOT表的操作放在同一个事务中(更新,OpengGauss5.0.0已经支持)。MOT引擎使用的是论文2中描述的基于有效性校验的事务并发控制算法(OCC)。
算法描述如下:
事务计算时,把需要读写的数据从全局内存(masstree表),复制到事务本地内存,在commit以前,insert、delete、update结果都缓存在本地内存。(每个事务在commit前的计算过程中,只会读共享内存,不会写共享内存。)
当Commit命令发出,提交分三个阶段:
第一阶段:事务写记录集合,在全局内存中逐条加锁,使用有超时的自旋锁,如果超时(各种原因,例如正被其它事务锁住修改、删除),事务终止。所有记录成功获得锁则进入第二阶段。
如过后面的几步有效性检验通过,成功commit,可以认为,整个事务是在写集合成功加锁这一瞬间完成的。
第二阶段:对事务读记录集合进行校验——事务运行中,读取的记录包含TID(OpenGauss中是一个全局唯一的64位无符号整数),是这条记录的状态标志,所谓校验,就是比较每条记录的TID和此时的全局内存中,对应记录的TID是否相同,如果不同,说明在这个事务运行过程中,其它事务修改了并提交了这条记录,则本事务终止,如果相同,说明整个事务执行过程到第一阶段为止,没有其它事务修改这条记录,则进入第三阶段。
第三阶段:到此为止,写记录集合已经加锁,其它事务不可能修改写记录集合,且本事务整个运行过程中,都没有其它事务修改本事务所涉及的读集合,则此时如果直接修改写集合在全局内存对应的所有记录,是原子性的操作(一致的)。因此,就将写集合修改到全局内存对应的记录,当然还要写redo日志,然后释放锁,commit命令返回。
为了便于理解,这里忽略了论文中的一些细节,例如论文在第一步会读取全局的时间戳变量Epoch,第二步使用它生成事务ID(TID),第三步,如果有修改,将新生成的TID写到被修改记录的全局内存记录中。但是OpenGauss的代码没有这个设计,而是用全局变量CSNManager::m_csn表示事务ID。当通过有效性确认,将修改写到全局内存时,调用CSNManager::GetNextCSN()获得最新的事务ID,这个操作原子性地对全局变量CSNManager::m_csn加1(如果失败则不断尝试直到成功),多个并发事务可以同时调用CSNManager::GetNextCSN()修改CSNManager::m_csn,虽然修改是原子性的,不会导致不一致,但是这导致了争用,并没有达到silo中使用Epoch以避免争用的目的。但是OpenGauss开发人员认为这样并不会带了来争用,见这个关于CSNManager::m_csn的回复:
多事务并发调用CSNManager::GetNextCSN(),导致争用 - Community - mailweb.opengauss.org
MOT对应的日志为redo日志,不支持多版本(更新,opengauss5.0.0支持多版本了)、不支持undo log。有三种可选redo策略:异步日志、同步日志、同步组提交日志。异步日志:将日志记录加到日志缓冲区,然后客户端commit返回,不保证已提交事务不丢失。同步日志:等的日志记录落盘,然后commit返回,保证已提交事务不丢失。同步组提交日志:若干个事务作为一个组,它们的日志记录写到这个组的缓冲区(包括了commit日志记录),但是客户端commit不返回,而是等待,当超时或者缓冲区满,由一个线程将缓冲区记录落盘,然后同一组的、已提交的事务,继续执行,客户端commit返回。
虽然OpenGauss也实现了组提交日志,也不是完全遵循论文2中的组提交设计,而是有较大差别:
1、提交时生成事务ID(TID),OpenGauss的事务通过竞争设置一个全局变量,获得本事务的全局唯一CSN(代码在CSNManager::GetNextCSN),这个操作会带来争用,而论文2的方案,是用全局变化的Epoch,加本事务自己生成的数字,生成局部的TID,虽然不保证全局唯一,但是避免了同时写共享变量,然后以其算法保证提交和恢复的正确性。
2、OpenGauss中,写日志是在通过了commit协议第二阶段的有效性确认,在第三阶段修改已提交记录之前(OccTransactionManager::WriteChanges)之前做的。代码主要可以看GroupSyncRedoLogHandler::WriteToLog、CommitGroup::WaitLeader、CommitGroup::WaitMember、CommitGroup::CommitInternal这几个函数。多个需要写盘的事务,按照先来后到的顺序,加入事务组,一般第一个成为Leader(GroupSyncRedoLogHandler::WriteToLog),加入组时要将自己的m_redoBuffer加入组(CommitGroup::AddToGroup),然后并不立即返回,而是等待返回条件,条件有两个a、等待写盘的事务数CommitGroup::m_groupSize达到阈值。b、超时。条件的检查由leader事务来做,达到条件后也由leader事务线程通知其它事务,leader负责将缓冲区写盘(CommitGroup::WaitLeader)。这个通知的机制使用C++11标准库的条件变量实现(std::condition_variable)。这一点与论文2使用Epoch实现组提交以避免写共享变量的设计又不一样了。
3、检查点算法
OpenGauss选择了论文3描述算法,作为MOT的检查点算法。所谓内存数据库的检查点,就是所有表记录在磁盘中的一个备份,数据库崩溃时,可以从这个备份加redo日志恢复,达到与磁盘数据库相同的效果——已提交事务不丢失。
论文3创建检查点的算法如下:
1、有一个专门的检查点线程,执行检查点流程。
2、事务线程需要配合检查点线程,做一些额外的操作。
3、检查点流程分为5个阶段,分别称为Rest、Prepare、Resovle、Capture和Complete,检查点线程控制检查点管理器的状态(CheckpointManager)在这五个阶段循环往复,在不同的阶段,事务提交时要做不同的额外操作。OpenGauss对算法的实现,与论文稍有不同,下面的描述以OpenGauss的实现为准。
4、内存中,每个活跃事务对应一个TxnManager对象,其中的成员变量m_checkpointPhase,记录了commit开始时这个事务所处的检查点阶段,称为事务开始阶段。
5、Rest阶段,此时检查点尚未开始,事务提交除了记录自己所在的阶段不做任何额外操作。当有信号通知检查点线程开始做检查点时,检查点线程会确认前一阶段(Complete)开始的事务都结束了,然后进入下一阶段(Prepare)。
6、Prepare阶段,如果事务在这个阶段开始commit,那么它写记录时(通过了有效性检查)需要将旧值保留,称为stable version,如果这个事务在Prepare阶段结束,那么就删除这个stable version,否则保留。检查点线程确认前一阶(Rest)段开始的事务都结束了,然后进入下一阶段(Resolve)。
7、Resolve阶段,这一阶段会创建保存检查点的目录,准备将文件写到这个目录里,这个阶段不允许事务开始提交,但允许事务结束提交。检查点线程确认前一阶(Prepare)段开始的事务都结束了,然后进入下一阶段(Capture)。
8、前三个阶段切换的代码在CheckpointManager::CreateSnapShot中。
9、Capture阶段,这个阶段开启一个线程遍历所有表的记录,将记录写盘,如果发现记录有对应的stable version则将stable version写盘,并且删除完成写盘的stable version,当所有表完成写盘,Capture阶段结束进入Complete阶段。在这个阶段,事务提交可以开始也可以结束,写记录时,需要将旧值保留(stable version)。
10、Complete阶段,这个阶段开始的事务行为与Rest相同,即不用创建stable version。只有等到Capture阶段开始的事务结束,Complete阶段才能结束,结束前还要做一些收尾工作。
11、当事务和检查点按照上面的流程操作时,实际上相当于做检查点时,使用了一个局部的快照,这个快照比完整实现多版本的快照占用内存要少的多,而且不依赖redo日志。
12、OpenGauss没有实现CALC论文中的增量检查点,即每次做检查点时,按照上面的步骤,是把整个表保存到磁盘的,而不是相对于前一个检查点变化的记录。这每次做检查点时实际IO是挺大的,因此不能频繁做检查点,这种设计必然要求redo日志会比较多。检查点的创建和内存表重建虽然不依赖redo日志,但是数据库要恢复到最新状态还是需要redo日志的。例如,恢复时使用这个检查点恢复数据库到较早的一个状态,然后使用redo日志追到最新的状态。华为的开发人员认为这种全量的检查点不会有太大问题:OpenGauss没有实现pCALC吗?(OpenGauss does not implement pCALC?) - Community - mailweb.opengauss.org
13、OpenGauss的redo日志,是一种“物理日志”,即是对某个表的增删改操作加上要增删改的数据,但不是SQL形式的,而是二进制形式的,恢复时以非事务方式执行这些操作,直接调用masstree的接口。