作者介绍:李伟超,数据库系统架构师,YashanDB架设技术开发负责人,10年以上数据库内核技术开发经验。
背景
海量数据OLAP场景,通常具有数据规模大、查询复杂度高、处理速度要求高等特点,对SQL引擎的执行效率要求非常高。面向行式存储的行式执行引擎由于逐行扫描的方式,往往会导致大量的函数调用开销,性能方面无法满足业务需求。
为了解决这个问题,基于列式存储的向量化执行引擎技术应运而生,该方式通过批量计算和充分利用CPU高速缓存和流水线,使得查询分析的性能相较于行式执行引擎得到数量级的提升。
面向OLAP场景,YashanDB在列式存储基础上引入了向量化执行引擎技术,并取得了显著的查询性能提升。如下图,在TPC-H基准测试下,YashanDB基本维持秒级的查询响应时延,达到了行业领先水平。
本文将为大家深入介绍向量化执行引擎的场景价值、技术优势以及YashanDB的实现机制。
图1 TPC-H测试结果
硬件配置:2288虚拟机(16核,160G内存,3.4T SSD)
软件版本:OS(CentOS 7),DB(YashanDB 22.2)
测试模型:TPC-H 100G数据
为什么需要向量化计算?
讲到向量化执行引擎,首先想到的问题是为什么需要向量化计算以及它是如何提升计算性能的?要回答这个问题,我们先回顾一下传统的行式执行引擎是如何做的。
传统的行式执行引擎普遍采用的是经典的火山模型,例如MySQL、PostgreSQL等数据库都采用的是该执行模型。
火山模型的执行方式是从底层数据源向上拉取数据,这种方式也被称为“Pull模型”。如下图所示,对于一个查询计划,上层算子每调用一次下层算子的next函数,下层算子向上返回一条记录,持续这个过程直至下层算子不再返回记录。它的逻辑简洁,易于理解和实现,一次一条记录的执行方式比较适合对查询响应时间有较高要求的OLTP场景,但是在面向海量数据分析时存在着严重的不足:每次一条记录的执行模式导致上下层算子之间以及表达式计算存在大量的函数调用开销,并且不能充分利用CPU高速缓存和流水线。
图2 火山模型执行方式
针对上面问题的改进方案,业界目前主要有两种方式:一种是基于JIT即时编译(Just-in-time Compilation)的查询优化,利用LLVM等编译框架对查询计划做运行时优化,通过内联优化等手段消除函数调用开销;另一种方式就是本文要讲的基于列存的向量化计算。
要实现基于JIT的查询优化,需要对编译原理和LLVM等编译框架有深入的理解,对开发人员的技术要求以及工程实现的复杂度都较高,并且对编译框架有深度的耦合。相较而言向量化计算模型在OLAP场景批处理方面更有优势:基于局部性原理,对批量数据的计算能够更好的利用CPU缓存和流水线;同时,针对批量数据还可以利用SIMD指令实现向量化计算。
图3 向量化模型执行方式
如何实现向量化执行引擎?
实现向量化执行引擎主要包括以下几个方面的工作:
l 基于列存的组织结构:为了实现对数据的向量化计算,需要设计按列组织的内存结构,以充分利用CPU的缓存以及使用SIMD指令加速计算。
l 向量化的计算框架:在列存内存组织的基础上,提供一套基于列存的算子和表达式框架,以支持灵活可扩展的定义和实现各类算子和表达式。
l 针对查询计划执行的优化技术:通过优化器、向量化执行引擎和存储引擎的紧密配合,实现将查询条件下推到存储引擎以及针对HashJoin实现运行时过滤(Runtime Filter)。
l 内存管理:包括运行时的动态内存管理和针对物化算子的物化内存管理。
基于列存的组织结构
在向量化执行引擎模型中,列式存储占据着天然的优势。因为列存中数据以数组的形式存储,一列中的所有数据都会被同时读取和处理,这种方式与向量化计算非常吻合。向量化执行引擎以每次一批记录的方式执行,每批记录都是以列存的方式组织的。
在向量化执行引擎中,列存数据的组织结构非常重要,因为它直接影响着计算效率。我们首先来看一下在向量化执行引擎中列存数据的组织结构。
图4:YashanDB基于列存的组织结构
l ColumnSet:我们将以列存的方式组织到一起的一批记录称为ColumnSet,它是一个二维的数据集,由许多相邻的向量组成,每个向量的记录数相同,不同向量之间按行逻辑对齐。与表类似,ColumnSet也有一个Schema,该模式必须匹配其向量的数据类型。ColumnSet是一个便于序列化和计算的工作单元,每个ColumnSet还有一个可选的位图用来表示ColumnSet中的行是否有效。
l Schema:用来描述二维数据集的结构。它包含一系列Field和一些可选的模式范围的元数据。Field描述列的名称及其元数据。
l Column:是一个与Field绑定在一起的分块向量,同时列还有一个可选的位图用来表示列中的值是否为空值。根据数据类型分为定长列(Fixed Length Column)和变长列(Variable Length Column)。定长列可以对数据直接按下标随机访问;而变长列需要先根据偏移向量计算出数据的起始位置和长度,然后访问数据。
l Vector:表示已知长度并具有相同数据类型的标量值的序列。向量中的值由一块连续的内存存储,值的数量和意义由向量的数据类型决定。
计算框架
YashanDB基于Rust语言自主研发了高性能的向量化执行引擎,具备以下特点:
l 内存安全,高性能;
l 基于ColumnSet的批量计算,实现只读数据、无锁并发的向量化计算;
l 支持功能丰富的表达式计算和算子,完整支持TPC-H、TPC-DS语法;
l 高度灵活的可扩展性。
如下所示,在向量化计算框架中有两个基本概念及其接口:算子(Operator)和表达式(Expression)。任何算子和表达式只要实现了对应的接口,就可以对接到向量化执行引擎中。它们在运行时通过绑定资源生成可执行的游标(Cursor)和绑定表达式(BoundExpression),消费下层节点的数据,并向上层节点返回生成的数据,消费和生成数据都是每次一批记录。
pub trait Operator { fn bind(&self, ctx: Arc<dyn Context>) -> Result<Box<dyn Cursor>>;}pub trait Cursor { fn next(&mut self) -> Result<Option<ColumnSet>>;}pub trait Expression { fn bind(&self, ctx: Arc<dyn Context>, schema: &Schema) -> Result<Box<dyn BoundExpression>>;}pub trait BoundExpression { fn evaluate(&mut self, column_set: &ColumnSet) -> Result<Arc<dyn Column>>;}
条件下推
条件下推是指过滤条件从执行引擎下推到存储引擎,列存存储利用稀疏索引进行快速过滤,大部分场景可显著减少数据的读取,提高查询性能。存储支持模糊过滤(仅仅用稀疏索引过滤)和精确过滤,如果存储执行的是模糊过滤,执行引擎还会进行过滤。
条件下推的规格:
l 支持多个字段的AND条件下推;
l 单个字段的多个条件,条件是OR的关系;
l 条件为等值查询和范围查询,比较值必须是常量。
列存存储对条件下推的支持:
l Extent粒度的布隆过滤:支持等值条件;
l 块粒度的稀疏索引过滤:支持and,or,<,>,=,>=,<=,in等运算下的常量表达式;
l 支持编码数据的行级过滤;
l 向量化过滤计算。
图5:YashanDB 条件下推示意
运行时过滤
条件下推是将用户输入的查询条件下推,但还有一种类型就是HashJoin这类场景,会使用运行时过滤(Runtime Filter)来加速。根据字面意思,这是一种"运行时过滤条件",和普通的Filter的区别在于它不是在SQL语句中定义的,而是在运行时根据中间数据生成的。
图6 No RuntimeFileter 和 RuntimeFileter示意
目前YashanDB实现的Runtime Filter支持的过滤方式是Bloom Filter(布隆过滤器)。HashJoin通常右表为小表,左表为大表,分别称为Build表和Probe表,其执行过程大致为:
l 取出Build表所有数据;
l 根据Build表数据构建HashTable;
l 再取出Probe表所有数据,同时基于HashTable生成Join结果;
HashJoin中的Runtime Filter是在构建HashTable时同时创建的:利用计算得到的hash值生成Bloom Filter。然后在取出Probe表数据之前,将生成的Runtime Filter在Probe表侧进行下推,通常是下推到最底层的TableScan上。这样TableScan扫描出来数据之后,可以利用下推的Runtime Filter先过滤一部分数据,减少返回的数据量,更少的数据量带来的是更小的计算量,性能自然就会提升。
但是Runtime Filter并不总是有效的,如果Runtime Filter的过滤效果不好,TableScan不能有效减少返回的数据量,同时由于应用Runtime Fiter引入了额外的计算Hash值的开销,性能反而可能会下降。针对这种情况,YashanDB在应用Runtime Filter时会检测其过滤效果,过滤效果较差时会禁用掉下推的Runtime Filter,避免性能劣化。
以TPC-H模型的Q17为例,开启Runtime Filter之后耗时从7s左右变成1s左右。
动态内存管理
OLAP场景的计算过程中需要处理大批量的记录,系统通常需要进行频繁的内存申请和释放以应对这种需求。然而,这种频繁的内存操作会导致内存碎片化,进而增加了内存管理开销。
在列存组织结构中我们介绍到,计算过程中每次处理一批记录,即一个ColumnSet。每批记录的行数我们称为BulkSize,在一次计算过程中BulkSize是不变的,那么对于定长的数据类型列,比如int类型列,其每批记录占用的内存大小是固定的,都为size_of(int) * BulkSize。这块内存理论上来讲是可以被不同批次的ColumnSet中的相同列重复使用。
为解决这些问题,我们采取了一些优化策略。根据向量化执行引擎的运行时特点,YashanDB实现了一个定制化的动态内存管理机制——基于MemPool和Allocator的两级内存缓存机制,通过一套全局的缓存内存池和细粒度的内存分配器实现了高效的内存管理。
图7 YashanDB动态内存管理机制
MemPool是一个全局共享的内存池,通过MMAP/MUNMAP向操作系统申请和释放内存,支持设定内存配额,在达到内存配额上限时,可以按策略淘汰空闲内存。
Stage(YashanDB中可执行的最小单元)粒度的Allocator:每个Stage会分配一个Allocator,Satge执行过程中的内存申请和释放优先在Allocator中进行,可以有效减少并发的内存申请释放的锁冲突。Allocator的内存从MemPool分配,Stage执行结束时,Allocator的内存会全部归还给MemPool。Allocator同样支持自定义的空闲内存淘汰机制。
MemPool和Allocator的内存缓存都是基于Arena实现的。Arena就是一个空闲内存块(Block)的缓存池,以链表进行管理。根据常用的内存大小定义了多个SizeClass,并且根据内存大小进行不同的管理:
l 大内存:不同的SizeClass使用大小不同的Block,进行Block级别管理;
l 小内存:不同的SizeClass使用大小相同的Block,Block切分成大小不同的Region,进行Region级别的管理;由于不同SizeClass使用的Block大小是相同的,在某个SizeClass无空闲内存时,可以先从具有相同Block大小的SizeClass中窃取空闲内存块,都没有时,再向内存池申请;
l HUGE内存:大于2M的内存块,不进行缓存处理,执行通过MMAP/MUNMAP向操作系统申请和释放。
物化内存管理
向量化执行引擎在执行查询计划过程中,当遇到需要物化的算子时,会在内存中缓存数据。然而,当内存不足时,需要把内存数据写到外部存储。在执行计划中,可能有多个需要物化的算子,这些算子所使用的内存总量受到限于可用内存资源的影响。
传统的方法是按照计划给出来的评估值给出一个配额,超过这个值就写盘。这种方式的主要问题是执行所需要的内存是动态的,单一的配额导致不能有效利用内存。
YashanDB采用动态分配配额的方式来管理物化内存,将物化内存分成全局、SQL、Stage、算子四个级别。一条SQL语句执行前,根据计划评估结果,为物化内存分配合理的内存配额,并为其对应的Stage、算子也会分配合理的配额。执行过程中,可以在配额范围内动态申请内存,配额不足时可以自下而上申请更多的配额。当配额用完后,会将数据写到外部存储。
图8 YashanDB物化内存管理
总结
向量化执行引擎的设计需要充分考虑多个方面的因素,包括存储数据结构、计算框架、优化技术以及内存管理等,这直接决定数据库的性能和效率。为了更好地满足各种复杂业务场景的需求,YashanDB的向量化执行引擎已经完整支持了国际基准测试TPC-H和TPC-DS的语法,并且在TPC-H数据分析型基准测试中取得了优异的性能表现。
向量化执行引擎是一个复杂的系统工程,随着硬件的不断演进,向量化执行引擎的技术演进将会是一个持续发展和优化的过程。在下一个版本中,我们会进一步提升TPC-DS的查询性能以及在行存列存混合计算场景方面的支持。
随着不断的优化改进,我们相信YashanDB的功能和性能会持续增强,进而更好的满足各种复杂业务场景的需求。