【DB】列式存储 & OLAP

对于分析型场景,列式存储本质上优于行式存储。

其中关键的几大杀器是:编码压缩(compression)、向量化查询处理(vectorized query processing)、隐式连接(invisible join)、延迟物化(late materialization)等。

而这些技术的应用,需要从存储层到计算层的全面支持才行,显然,目前主流的行式存储并不具备这样的条件。

1、编码压缩

列式存储的数据属于同一种类型,如数值类型,字符串类型等。相似度很高,试用合适的编码压缩可减少数据的存储空间,进而减少IO提高读取性能。

C-Store中针对数值类型的数据是否排序、区分度(Number of Distince Values)排列组合出4种情况,并给出了一些编码方案。

  • 有序且区分度不多
    可以使用一系列三元组(v,f,n)对列数据编码,表示值 v 从第 f 行出现,一共有 n 个(即 f 到 f+n−1 行)。如:数值 2 出现在 10-15行,则编码为 (2,10,6)。
  • 无序且区分度不多
    可以使用位图构造每个列取值出现的行位置,如:一列的数据为0,0,1,1,1,0,0,2,2,
    则编码为 (0, 110001100)、(1, 001110000) 和 (2,000000011)
    同时对稀疏的位图可进一步进行行程编码(Run Length Encoding,RLE)压缩数据。
  • 有序且区分度多
    这时候可以使用等差数列(每个数值表示为前一个数值加上一个变化量)来减小数据的存储。如:对于一列数据 1,4,7,7,8,12,
    可以表示为序列 1,3,3,0,1,4。
  • 无序且区分度多
    这种情况数据的规律性不强,没有很好的编码方式。

其他场景的编码还有varint、字符串字典编码(Dictionary Encoding)等,这些轻量级的编码技术仅需要多付出一些CPU,就可以节省不小的IO。

对于复杂类型,嵌套类型的可以使用Google Dremel提出Striping/Assembly算法(开源Parquet),使用Definition Level+Repetition Level做编解码。一些数值类型有时也可以尝试大一统的用bitshuffle做转换,配合压缩效果也不错,例如KUDU和百度Palo(Doris)中有应用。

同时,对于有些压缩/编码算法,比如RLE算法,针对压缩后的数据无需解压就可以直接做谓词下推,加快计算速度。如:AAAAABBCCCDDDDA --> A5B2C3D4A1,如果要以where col = 'C’过滤数据,平均计算复杂度等于总行数/列的基数,列基越大过滤越快(当然副作用是结果集很大);另外,如果输出的列数据是排过序的,那where col = 'C’过滤数据的计算复杂度降为log(总行数/列的基数),速度更快。

在编码基础上,还可以进行传统的压缩,如snappy等支持流式处理、吞吐量高的压缩算法。

2、向量化执行

向量化是指一个CPU指令可以同时处理多条数据,如下图:

在这里插入图片描述

当把 v1 和 v2 数组中的数据分别加载到寄存器 XMM0 和 XMM1 中时,可以通过一条指令就完成两个数组的和vec_res计算。

这需要 CPU 对向量化指令的支持,如 SSE2, AVX 等。

在软件层面上,向量化代码的书写方式体现在:先准备好待处理的批量数据,然后在对批量数据在一个 for 循环内做简单操作。

对于一个实现两个 int 相加的 expression:

  • 没有向量化的代码书写方式:
    class ExpressionIntAdd extends Expression {
          
          
            Datum eval(Row input) {
          
          
                    int left = input.getInt(leftIndex);
                    int right = input.getInt(rightIndex);
                    return new Datum(left+right);
            }
    }
    
  • 向量化后的代码书写方式:
    class VectorExpressionIntAdd extends VectorExpression {
          
          
            int[] eval(int[] left, int[] right) {
          
          
                    int[] ret = new int[input.length];
                    for(int i = 0; i < input.length; i++) {
          
          
                            ret[i] = new Datum(left[i] + right[i]);
                    }
                    return ret;
            }
    }
    

对比向量化之前的版本,向量化之后的版本不再是每次只处理一条数据,而是每次能处理一批数据,而且这种向量化的计算模式在计算过程中也具有更好的数据局部性。这样可以让计算更多的停留在函数内,而不是频繁的交互切换,提高了CPU的流水线并行度,而且还可以使用SIMD指令,例如AVX指令集来实现数据并行处理。

向量化执行引擎以列存为前提,每次从磁盘上读取一批列,这些列以数组形式组织。每次operator(如实际执行中的scan扫表算子,agg聚合算子)的next操作都通过for循环处理列数组。这么做可以大幅减少next的调用次数。相应的CPU的利用率得到了提高,另外数据被组织在一起。可以进一步利用CPU硬件的特性,如SIMD,将所有数据加载到CPU的缓存当中去,提高缓存命中率,提升效率。在列存储与向量化执行引擎的双重优化下,查询执行的速度会有一个非常巨大的飞跃。但是向量化也会带来额外的开销,就是物化中间结果(materlization),以牺牲物化的开销换取更高的计算性能。

3、延迟物化

物化指的是在SQL的执行过程中,获得最终数据的所处执行时机。如:

select R.b from R where R.a=X and R.d=Y

延迟物化是指只有在算出过滤条件所对应的准确记录时,才去取记录所对应的结果值b.

在这里插入图片描述

对于OLAP场景,延迟物化的好处有:

  • 很多聚合与选择计算,压根不需要整行数据,过早物化会浪费严重;
  • 很多列是压缩过的,过早物化会导致提前解压缩,但很多操作可以直接下推到压缩数据上的;
  • 面向真正需要的列做计算,CPU的cache效率很高(100%),而行存因为非必要列占用了cache line中的空间,cache效率显然不高;
  • 针对定长的列做块迭代处理,可以当成一个数组来操作,可以利用CPU的很多优势(SIMD加速、cache line适配、CPU pipeline等);相反,行存中列类型往往不一样,长度也不一样,还有大量不定长字段,难以加速。

4、隐式连接

对于多表Join,传统的做法一般是找到过滤性最强的维度表来关联事实表并过滤,然后再与其他维度表一一关联,得到最终的结果。

而隐式连接主要分三个步骤:

对于SQL:

SELECT c.nation, s.nation, d.year,
sum(lo.revenue) as revenue
FROM customer AS c, lineorder AS lo,
supplier AS s, dwdate AS d
WHERE lo.custkey = c.custkey
AND lo.suppkey = s.suppkey
AND lo.orderdate = d.datekey
AND c.region = 'ASIA'
AND s.region = 'ASIA'
AND d.year >= 1992 and d.year <= 1997
GROUP BY c.nation, s.nation, d.year
ORDER BY d.year asc, revenue desc;

1.下推相关条件到各个维度表,提炼出被事实表关联的主键列表(也就是事实表的外键),并构建对应的hash table(key是外键值,value是外键在维度表中的position);

在这里插入图片描述

Figure 2: The rst phase of the joins needed to execute Query 3.1 from the Star Schema benchmark on some sample data

2.对多个事实表以外键关联维度表的列进行探测,查找对应的hash table,过滤出多个position list(与被关联的列相关),然后对多个position list求交集(比如bitmap的AND计算等),得到一个最终的position list;

在这里插入图片描述

Figure 3: The second phase of the joins needed to execute Query 3.1 from the Star Schema benchmark on some sample data

3.基于前面的position list,最终从事实表中找到需要投影的其他列,而通过hash table从维度表找到需要投影的其他列,hash table中的value是维度表中的position,所以可以快速定位维度表的其他列。

在这里插入图片描述

Figure 4: The third phase of the joins needed to execute Query 3.1 from the Star Schema benchmark on some sample data

这里的“隐式”是指,没有通过传统的join方式(两两表迭代,生成两个表联合在一起的宽行数据,再做过滤)来实现join,而是通过维持不同列的相同行之间的position对应关系来完成多个表join。与倒排索引很类似。

除此之外,动态代码生成、块IO(相比page IO,一次读取的数据量更大),针对块建立的稀疏索引(因为块较大,索引量小,可尽可能将索引数据加载到内存),倒排索引,Join索引等都可加速基于列式存储的SQL执行效率。

但是仅仅将数据按照列式存储就可以解决所有问题了吗?

列式存储本质上方便的是列式数据的读取,但当SQL的查询结果是需要行相关的数据时,如何兼顾列式存储重构出行的数据,这和列式存储的存储格式和索引结构有很大关系。

存储格式和索引结构
典型的列式存储数据库系统有Microsoft SQL Server、C-Store (2005) / Vertica、Dremel、Apache Kudu、MonetDB等。文末的参考资料中分别对其存储结果有一些介绍。这里我列举一下Apache ORC文件格式帮助对列式的存储格式和索引结构有所了解。

Apache ORC的分区索引结构如下:

在这里插入图片描述

ORC将数据结构分成以下 3 个层级,在每个层级上都有索引信息来加速查询。

File Level:即一个 ORC 文件,Footer 中保存了数据的 meta 信息,还有文件数据的索引信息,例如各列数据的最大最小值(范围)、NULL 值分布、布隆过滤器等,这些信息可用来快速确定该文件是否包含要查询的数据。每个 ORC 文件中包含多个 Stripe。
Stripe Level 对应原表的一个范围分区,里面包含该分区内各列的值。每个 Stripe 也有自己的一个索引放在 footer 里,和 file-level 索引类似。
Row-Group Level :一列中的每 10000 行数据构成一个 row-group,每个 row-group 拥有自己的 row-level 索引,信息同上。
ORC 里的 Stripe 就像传统数据库的页,它是 ORC 文件批量读写的基本单位。

其中Row-Group的概念方便对行数据的重新整合。数据分区、分区内索引、行列混合等这些思想都可见于分布式存储系统中。

后记

本文简要总结了列式存储的好处,Apache ORC的存储格式等。旨在帮助你我对OLAP场景下的列式存储有一个初步认识。更深一步的话题:如列式存储到底是如何实现的,CRUD过程是如何的,涉及的分布式存储问题又是如何解决的…开头的一些专栏和文末参考资料中有一些阐述。因为233酱离数据库开发还很远,就不进一步尬聊了。。

文中有不懂的地方欢迎和233酱交流,一起进步。后面233酱也打算分享更多OLAP相关的知识,如Presto。不是这种总结笔记的方式。期待与你再次相遇~

参考资料:

[1].https://zhuanlan.zhihu.com/p/144926830

[2].https://zhuanlan.zhihu.com/p/35622907

[3].https://zhuanlan.zhihu.com/p/54484592

[4].https://zhuanlan.zhihu.com/p/100933389

[5].https://www.bilibili.com/video/BV1Bt411v7ST

[6].https://www.the-paper-trail.org/post/2013-01-30-columnar-storage/

猜你喜欢

转载自blog.csdn.net/weixin_42364929/article/details/143351695