TAE是MatrixOne的存储引擎,取这个名字,是因为它需要同时支撑TP和AP能力。第一个版本的TAE实现,已经随MatrixOne 0.5版本发布,这是一个单机存储引擎。从MatrixOne 0.6版本开始,TAE将进化成为存算分离的云原生分布式架构。我们将分期跟随MatrixOne版本地演进,逐步揭示TAE存储引擎的设计内幕。
本文假定读者对列存有基本的了解,对于列存的数据常见组织,比如block(或者page,最小IO单元),segment(若干block组成的row group),zonemap(column block内的最大/最小值)等都有基本的认知,对普通的Key Value存储引擎实现,比如LSM Tree也有初步了解,比如它的Memtable,WAL,SST文件等概念。下图的TAE逻辑结构的左半部分,涉及到了列存的一些基本概念,可以供不具备相关背景的同学了解。

在介绍TAE设计之前,首先回答一个问题:为什么采用列存结构来设计一个数据库的核心存储引擎?
这是因为MatrixOne期望用一个存储引擎同时解决TP和AP的问题。至于为什么这样做,可以关注矩阵起源的其他文章,简单地讲,就是期望在共享存储的基础之上,可以随意弹性的启动不同计算节点分别处理TP和AP任务,在最大化伸缩性的同时保证不同负载的相互隔离。在这个前提之下,采用以列存为基础的结构,可以具备如下优点:
- 很容易对AP优化
- 通过引入Column Family的概念可以对负载灵活适配。假如所有列都是一个Column Family,也就是所有的列数据保存在一起,这就跟数据库的HEAP文件非常类似,可以表现出行存类似的行为,典型的OLTP数据库如PostgreSQL就是基于HEAP来做的存储引擎。假如每个列都是独立的Column Family,也就是每一列都独立存放,那么就是典型的列存。通过定义Column Family,用户可以方便地在行存和列存之间切换,这只需要在DDL表定义中指定即可。
因此,从物理上来说,TAE就是一个列存引擎。下文的行存,则是指普通的Key Value存储引擎如RocksDB,因为很多典型的分布式数据库都基于它来构建。TAE是一个类似LSM Tree的结构但却没有直接采用RocksDB,是出于一些额外的考虑。
为什么列存比行存难设计?
众所周知SQL计算引擎处理TP请求和AP请求有着巨大的不同,前者以点查询为主,要求高并发能力,后者以Scan请求为主,通常采用MPP引擎,不追求并发而追求并行处理。对应到存储,行存天然是服务TP请求的,列存天然是服务AP请求的,因为前者可以采用基础的火山模型,少量读取若干行即返回结果,后者则必须批处理(所谓的向量化执行),通常还要配合Pipeline,一次读取某列的几千行这样,因此MPP计算引擎,读取完记录,需要极快地对整批数据做集中处理,而不能逐条的读取,反序列化,解码,那样将大大降低系统的吞吐量。
当存储引擎内部需要支持多张表的时候,对于行存来说,处理非常简单,只需要给每行增加TableID的前缀即可,这并没有给系统整体增加多少开销,因为反序列化,解码只需针对若干记录即可。这时的多张表,在存储引擎看来,都是统一的Key Value,表之间并没有什么不同。

可是对于列存来说,首先,每张表的列都是独立存放的,不同的表也包含不同的列,这样表之间的数据摆放,完全不同。假定它也支持主键,那么同样给每行增加TableID的前缀,本质上是对向量化执行的打断,因此,TableID这样的数据,需要存放到元数据。除了TableID之外,列存还需要记录每个列的信息(比如block,segment,zonemap,等等),并且不同的Table之间是完全不同的,而行存就没有这样的问题,所有的Table只要通过TableID作前缀,就可以,因此列存为什么比行存难,核心点之一在于元数据复杂度远高于行存。以树状视角来看,常见的列存元数据组织看起来像是这样:
|-- [0]dentry:[name="/"]
| |-- [1]dentry:[name="db1"]
| | |-- [2]dentry:[name="table1"]
| | | |-- [3]dentry:[name="segment1"]
| | | | |-- [4]dentry:[name="block1"]
| | | | | |-- col1 [5]
| | | | | |-- col2 [6]
| | | | | |-- idx1 [7]
| | | | | |-- idx2 [8]
| | | | |
| | | | |-- [9]dentry:[name="block2"]
| | | | | |-- col1 [10]
| | | | | |-- col2 [11]
| | | | | |-- idx1 [12]
| | | | | |-- idx2 [13]
除了元数据的复杂之外,还有崩溃恢复机制,这就是WAL(Write Ahead Logging),列存要考虑的事情,也会更多。对于行存来说,所有的表都共享同样的Key Value空间,因此就是一个普通的Key Value存储所需要的WAL,记录一个LSN(Last Sequence Number)水位即可。但如果列存也这么做,就会有一些问题:

上面的图很粗略显示了一个列存Memtable的样例,为方便管理,我们认定Memtable的每个Block(Page)只能包含一张表的某列数据。假设在Memtable里包含多张表同时写入的数据,由于不同的表写入速度的不同,因此每张表在Memtable包含数据的多少也必然不同。如果我们在WAL中只记录一个LSN,这就意味着当发生Checkpoint的时候,我们需要把Memtable每张表的数据都Flush到硬盘,哪怕这张表的数据在Memtable中只有1行。同时,由于列存的schema无法像行存那样完全融入到单一的Key Value中,因此,即使一行表数据,也会生成对应的文件,甚至是每列一个文件,在表的数量众多的时候,这会产生大量的碎片文件,导致巨大的读放大。当然,也可以不考虑这么复杂的场景,毕竟,很多列存引擎连WAL都还没有,而即使有WAL的列存引擎,也大都不这样考虑问题,比如所有表固定到某行数的时候才做Checkpoint,那么表多的时候,Memtable可能就会占据大量内存,甚至OOM。TAE是MatrixOne数据库主要甚至是唯一的存储引擎,它需要承载不仅AP还有TP的业务,因此对于数据库使用来说,它必须要能够像普通Key Value存储引擎那样任意创建表,因此,最直接的方案,就意味着在WAL中需要为每张表都维护一个LSN,也就是说,在统一的WAL中,每张表都有自己独立的逻辑日志空间记录自己当前写入的水位。换句话,如果我们把WAL看做是一个消息队列,普通行存的WAL就相当于只有一个Topic的消息队列,而列存的WAL则相当于有一堆Topic的消息队列,而且这些Topic在物理上连续存放,并不像普通消息队列那样各个Topic数据独立存放。因此,列存的WAL,需要更加精细化的设计,才能让它使用方便。
下面正式介绍TAE存储引擎的设计。
数据存储
TAE以表的形式存储数据。每个表的数据被组织成一个LSM树。目前,TAE是一个三层LSM树,称为L0、L1和L2。L0很小,可以完全驻留在内存中,就是上文提到的Memtable,而L1和L2都驻留在硬盘上。在TAE中,L0由transient block组成,不排序,L1由sorted block组成。传入的新数据总是被插入最新的transient block中。如果插入导致该块超过了一个块的最大行数,该块将被按主键排序,并作为sorted block刷入L1。如果被排序的块的数量超过了一个segment的最大数量,那么将使用merge sort方法按主键进行排序并写入L2。column block是TAE的最小IO单元,目前它是按照固定行数来组织的,对于blob列的单独处理,会在后续版本中改进。
L1和L2存放的都是按主键排序的数据。排序的数据之间,主键会有范围重叠。L1和L2的区别在于,L1是保证block内按主键排序,而L2则是保证一个segment内按主键排序。这里segment是一个逻辑概念,它在同类实现中也可以等价为row group,row set等。如果一个segment有许多更新(删除),它可以被compact成一个新的segment,多个segment也可以merge成一个新segment,这些都通过后台的异步任务来完成,任务的调度策略,主要是写放大和读放大之间的权衡——基于此考虑TAE不推荐提供L4层,也就是说全部segment按照主键全排序,尽管从技术上可以这么做(通过后台异步任务反复merge,比如ClickHouse等列存的行为)。

索引和元数据
跟传统列存一样,TAE没有引入行存的次级索引,只有在block和segment级分别引入了Zonemap(Min/Max数据)信息,未来也会根据需要增加Bloom Filter数据,为查询执行的Runtime Filter优化提供支持。作为支撑TP的存储引擎,TAE提供完整的主键约束,包含多列主键和全局自增ID。TAE默认为每个表的主键创建一个主键索引。主要功能是在插入数据时做去重满足主键约束,以及根据主键进行过滤。主键去重是数据插入的关键路径。TAE需要在以下三个方面做出权衡:
- 查询性能
- 内存使用
- 跟上述数据布局的匹配
从索引的粒度来看,TAE可以有两类,一类是表级索引,另一类是segment级。例如,可以有一个表级的索引,或者每个segment有一个索引。TAE的表数据由多个segment组成,每个segment的数据都经历了从L1到L3,从无序,通过压缩/merge到有序的过程,这种情况对表级索引非常不友好。所以TAE的索引是构建在segment级。有两种类型的segment。一种是可以追加修改的,另一种是不可修改的。对于后者,segment级索引是一个两级结构,分别是bloomfilter和zonemap。对于bloomfilter有两种选择,一种是基于segment的bloomfilter,另一种是基于block的bloomfilter。当索引可以完全驻留在内存中时,基于segment的是一个更好的选择。一个可追加修改的segment至少由一个可追加的块加上多个不可追加的块组成。可追加的block索引是一个常驻内存的ART-tree加上zonemap,而不可追加的则是bloomfilter加上zonemap。

Buffer Manager
严肃的存储引擎需要Buffer Manager实现对内存的精细化控制。尽管Buffer Manager原理上只是一个LRU Cache,但是没有数据库会直接采用操作系统Page Cache来取代Buffer Manager,尤其是TP类数据库。TAE用Buffer Manager管理内存buffer,每个buffer node是固定大小,它们总共被划分到4个区域:
- Mutable:固定size的buffer,用来存放L0的transient column block
- SST:给L1和L2的block使用
- Index:存放索引信息
- Redo log:用来服务事务未提交数据,每个事务的local需要至少一个Buffer
Buffer Manager的每个buffer node有Loaded和Unloaded 两种状态,当使用者请求buffer manager对一个buffer node 进行Pin操作时,如果该node处于Loaded状态,那么它的引用计数会增加1,如果节点处于Unloaded状态,它将从硬盘或远程存储读取数据,增加节点引用计数。当内存没有剩余空间时,将采用LRU策略把一些buffer node换出内存以腾出空间。当使用者卸载Unpin一个node时,只需调用节点句柄的Close。如果引用次数为0,则该节点将成为被换出内存的候选节点,引用次数大于0的节点永远不会被换出。
WAL和日志回放
如前所述,列存引擎的WAL设计会比行存更加复杂。在TAE中,redo log不需要记录每个写操作,但必须在事务提交时记录。TAE通过使用Buffer Manager来减少io的使用,对于那些时间不长,可能因为各种冲突而需要回滚的事务,避免任何IO事件。它也可以支持长的或大的事务。TAE的WAL的Log Entry Header采用如下的格式:
| Item | Size(Byte) |
|---|---|
GroupId |
4 |
LSN |
8 |
Length |
4 |
Type |
1 |
事务Log Entry包含如下类型:
| Type | Datatype | Value | Description |
|---|---|---|---|
AC |
int8 | 0x10 | 完整的写操作的提交事务 |
PC |
int8 | 0x11 | 由部分写操作组成的已提交事务 |
UC |
int8 | 0x12 | 未提交的事务的部分写操作 |
RB |
int8 | 0x13 | 事务回滚 |
CKP |
int8 | 0x40 | Checkpoint |
大多数事务只有一个Log Entry。只有那些长的或大的事务可能需要记录多个Log Entry。所以一个事务的日志可能是1个以上UC类型的日志条目加上一个PC类型的Log Entry,或者只有一个AC类型的Log Entry。TAE为UC类型的Log Entry分配了一个专用Group。下图是六个已提交事务的事务日志。

一个事务Log Entry的Payload包括多个transaction node,正如图中所示。transaction node包含有多种类型,比如DML的Delete,Append,Update,DDL的Create/Drop Table,Create/Drop Database等。一个node是一个原子命令,它可以理解为一个已提交Log Entry的sub-entry的索引。正如在Buffer Manager部分所提到的,所有活动的事务共享固定大小的内存空间,该空间由Buffer Manager管理。当剩余空间不足时,一些transaction node将被卸载(Unload)。如果是第一次卸载node,它将作为一个Log Entry保存在Redo Log中,而当加载时,相应的Log Entry将从Redo Log回放。这个过程举例说明如下:


图中TN1-1 表示事务Txn1的第一个transaction

本文深入探讨了MatrixOne的存储引擎TAE的设计,它是一个列存引擎,旨在同时支持TP和AP场景。TAE采用列存结构以优化AP性能,并通过ColumnFamily实现行存与列存的灵活切换。文章详细阐述了列存相比于行存在设计上的复杂性,尤其是在元数据管理、崩溃恢复机制(WAL)和事务处理方面。TAE采用三级LSM树结构,对内存进行精细化管理,并实现了MVCC机制以支持事务。此外,文章还介绍了TAE的版本链存储机制,以应对大规模更新带来的读放大问题。TAE的未来发展将进化为云原生分布式架构。
最低0.47元/天 解锁文章
3856

被折叠的 条评论
为什么被折叠?



