关于TAE(Transactional Analytical Engine)的那些事

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

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任务,在最大化伸缩性的同时保证不同负载的相互隔离。在这个前提之下,采用以列存为基础的结构,可以具备如下优点:

  1. 很容易对AP优化
  2. 通过引入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需要在以下三个方面做出权衡:

  1. 查询性能
  2. 内存使用
  3. 跟上述数据布局的匹配

从索引的粒度来看,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个区域:

  1. Mutable:固定size的buffer,用来存放L0的transient column block
  2. SST:给L1和L2的block使用
  3. Index:存放索引信息
  4. 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

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值