Paper 翻译 The design and implementation of modern column-oriented database systems Abadi et al., Foundations and trends in databases, 2012
Title: 现代列数据库系统的设计与实现
paper-reading-fntdbs2013-late-materialization
3. Advantage of Column-stores 优势
3.4 Operating Directly on Compressed Data
- Compression-aware
- SUM & Run-length encoding sum(c1) = sum((4, 1000)) = 4000
- Bit-vector, Dictionary …
Results from experiments in the literature show that compression not only saves space, but significantly improves performance.
文献中的实验结果表明,压缩不仅可以节省空间,而且可以显著提高性能。
3.5 Late Materialization 延迟物化
什么是物化?
首先解释一下什么是物化:为了能够把底层存储格式(面向Column的), 跟用户查询表达的意思(Row)对应上,在一个查询的生命周期的某个时间点,一定要把数据转换成Row的形式,这在Column-Store里面被称为物化(Materization)。
延迟物化
延迟物化就很好理解了,意思是把这个物化的时机尽量的拖延到整个查询生命周期的后期。
把从各个列中获取的数据重新组装为行的过程称之为 tuple construction
,late materialization 的目的就是尽可能推迟 tuple construction 的时机。
延迟物化意味着在查询执行的前一段时间内,查询执行的模型不是关系代数,而是基于 Column 的。
More recent column-stores such as VectorWise, C-Store, Vertica, and to a lesser extent, SybaseIQ, choose to keep data in columns until much later into the query plan, operating directly on these columns.
大部分的列存储,在设计上都是选择将数据保存在列中,直到查询计划的后期才直接在这些列上操作。
eg. a late materialization query plan for a select-project-join query
SQL:
select sum(R.a) from R, S
where R.c = S.b and 5 < R.a < 20 and 40 < R.b < 50 and 30 < S.a < 40
举个更简单的例子
论文中例子过于复杂,不利于我们的理解。 我们在这里举个简单例子。 SQL 如下:
SQL:
select name from person where id > 10 and age > 20
一般的做法是从文件中读出三列的所有数据,物化成一行行的 person 数据,然后应用两个过滤条件:id > 10 and age > 20,过滤完了之后从数据里面抽出 name 字段,作为最后的结果进行output,大致的转换过程如下图:
而延迟物化的做法则会先不物化数据,直接在 Column 数据上分别应用两个过滤条件,从而得到两个满足过滤条件的 bitmap,然后再把两个 bitmap 做 位与操作
得到同时满足两个条件的所有的 bitmap。因为最后需要的是 name 字段,因此我们拿着这些 position 对 name 字段的数据进行过滤就得到了最终的结果。如下图:
这两者(Earlymaterialization vs Late materialization)的 Tradeoff 在于,虽然延迟加载能够减少数据的加载量,但 需要维护原始数据的位置 ,这样才能找到对应行的其他列的值。然而如果筛选条件(person.id > 10 and person.age > 20)不能大量过滤数据,延迟加载反而低效。对于这种情况,就需要根据一些统计信息选择合适的加载算法,来最大限度的提高效率。
分析 Paper 中的SQL示例
Paper 示例中,主要是 select-project-join query, 它主要是对两个独立的表(R、S)的三列(5<R.a<20、 40<R.b<50、30<S.a<40)进行过滤,然后在两列上连接这两个表(R.c=S.b),而随后它对其中一个表(R)进行了一个求和(sum(R.a))。
我们以图形方式显示了回答这个查询的各个步骤,以及它显示了MAL代数中的查询计划和每个MAL运算符的 behaves。
选择运算符(select operators) 独立过滤每个列,最大限度地利用内存带宽,因为每个运算符只读取相关数据。The select operators filter each column independently, maximising the utilisation of memory bandwidth.
论文原文中对于 Example 的每一个 step的解释:
Example. Figure 4.1 shows a simple example of a late material-
ization query plan and execution in a modern column-store. Here, we
assume that intermediate results are represented with position lists
and, in order to focus solely on the late materialization issues, and for
ease of presentation, no compression is used and we show the example
using bulk processing. The query in Figure 4.1 is a select-project-join
query; it essentially filters three columns of two separate tables (R,S)
and then joins these two tables on two columns, while subsequently it
performs a sum aggregation on one of the tables (R). Figure 4.1 shows
graphically the various steps performed to answer this query, as well as
it shows the query plan in MAL algebra and how each MAL operator
behaves.
Late materialization means that we always operate on individual
columns, i.e., in Figure 4.1 the select operators filter each column in-
dependently, maximizing utilization of memory bandwidth as only the
relevant data is read for each operator. In this way, after having fil-
tered column R.a in Step 1 of Figure 4.1, a position list contains the
positions of the qualifying tuples. Recall that positions are used as row
IDs. All intermediate results which are position lists in Figure 4.1 are
marked with a dashed line. Then, in Step 2, we reconstruct column
R.b which is needed for the next filtering action. Since the positions in
the position list (inter1) are ordered (as we sequentially scanned R.a
in Step 1), we can project the qualifying R.b values in a cache-friendly
skip sequential access pattern. In Step 3, we scan intermediate result
inter2 to apply the second filter predicate (on R.b). In the same way
as before, this results in a new intermediate array that contains the
qualifying positions which we then use in Step 4 to fetch the qualifying
values from column R.c (which we need for the join).
Subsequently, we filter column S.a in the same way as we did for
table R and we fetch the qualifying values from the other join input
column (S.b). In Step 7 we reverse this intermediate result in order to
feed it in the proper order to the join operator. The join operator in Step
8 operates on the individual join input columns, and it produces two
position lists which may be used for projecting any attributes needed in
the select clause by either table. In this case, we need only the position
list for table R so we “void” the tail of the join result and use this
position list in order to fetch the qualifying values from column R.a
which we need for the aggregation. Finally, in Step 11, we perform the
aggregation (again on one column-at-a-time) enjoying CPU and cache
friendly access patterns, reading only the relevant data.
Every time we fetch the values of a column given a position list
(which is the result of a previous operator) we say that we perform a
tuple reconstruction action. Such actions have to be performed multiple
times within a query plan, i.e., at least N ≠ 1 times for each table,
where N is the number of attributes of a given table referenced in a
query. Tuple alignment across columns and enforcement of sequential
access patterns reduces the costs of tuple reconstruction actions. In
Figure 4.1, we demonstrated an architecture where intermediate results
are materialized in the form of row-id lists (positions). However, as
we discussed earlier, many more alternatives are possible (typically
depending on selectivity) such as using bit vectors or filtering columns
independently and merging results as in [80].
Another interesting observation is that with C-Store projections,
tuple reconstruction becomes a more lightweight action. Given that
each projection is sorted by one leading column, then a query which
selects a range on this column immediately restricts its actions for
tuple reconstruction to the range defined by the first selection. Since
the projection is sorted on this attribute, this is a contiguous range
on the projection, which in turn means that any tuple reconstruction
actions take place only in a restricted horizontal partition as opposed
to the whole projection; this inherently provides better access patterns
as there will be less cache misses. Sideways database cracking [50] (to
be discussed later on) provides the same effect but in a self-organizing
way, i.e., partially sorting columns as the workload evolves, adapting
to workload patterns and avoiding creating whole projections a priori.
翻译:
步骤分析:
- (1) 过滤R.a列。一个位置列表包含了合格图元(论文中描述为 qualifying tuples)的 位置, positon 从1开始标记, 得到了上图中的
inter1
的 Ra列的 position 列表。 - (2) 根据 inter1 得到 Rb 列的 对应 inter1 位置的position和R.b列数据。组合记为 inter2
- (3) TODO
Advantages of late materialization 延迟物化带来的好处
- 关系代数里面的 selection 和 aggregation 都会产生一些不必要的物化操作,从一种形式的tuple, 变成另外一种形式的tuple。如果对物化进行延迟的话,可以减少物化的开销(因为要物化的字段少了),甚至直接不需要物化;
- 如果数据是被压缩过的,物化的过程就必须对数据进行解压,这会影响压缩带来的好处;
- 列式的内存组织形式对 CPU Cache 非常友好,从而提高计算效率,相反行式的组织形式因为非必要的列占用了 Cache Line 的空间,Cache 效率低;
- 块遍历的优化手段对 Column 类型的数据效果更好,因为数据以 Column 形式保存在一起,数据是定长的可能性更大,而如果 Row 形式保存在一起数据是定长的可能性非常小(因为你一行数据里面只要有一个是非定长的,比如 VARCHAR,那么整行数据都是非定长的)。
延迟物化的缺点
延迟物化且多表 Join 连接后, 许多 Join Algorithms 会对左(外)侧输入位置关系排序,右(内)侧输出位置不会排序(准确的说至少有一侧不会被排序),因为以这种无序的方式从列中提取值需要为每个位置跳转存储, 随机访问,相比顺序访问会产生较大的开销。
3.6 Joins
-
Hash-join
以 Hash-join(散列连接,典型连接算法) 为例,column store 可以让 probe 探测表更紧凑(会产生更紧凑的散列表,从而在探测期间[during probing]产生更好的访问模式),cache 局部性更好[a smaller hash table leads to less cache misses.]。 -
Jive-Join
Jive-Join(两次排序) 解决 Unordered positional lookups。基本思想是在我们想要提取的位置列表中添加一个额外的列,这是一个密集递增的整数序列。
The basic idea of the
Jive join
is to add an additional column to the list of positions that we want to extract, that is a densely increasing sequence of integers。
在延迟物化部分提到,Join 后,许多 Join Algorithms 会对左(外)侧输入位置关系排序,右(内)侧输出位置不会排序,这是因为左列中的位置通常按顺序迭代,而右列中的位置会被探测以查找连接谓词匹配项。因此需要添加一个排序列。
但是也有例外: 对两组输入进行排序或重新分区的 Join 算法,不会对左右位置列表进行排序。但无论哪种方式,至少有一组输出位置不会被排序。
为了解决这个问题,又提出了一个新的Join算法:Invisible Join隐式连接, 此处不讨论。
执行引擎与关系算子
唠一唠执行引擎和关系算子与列存之间又有怎样的故事。
3.6.1 执行引擎
一条 SQL 会经过词法语法解析、语义校验、逻辑执行计划生成优化等一系列步骤,生成最后的物理执行计划,例如,对于如下 SQL:
select * from R where a = 1
其物理执行计划如下图所示:
图5
执行引擎所做的事情就包括,定义 TableScan,Filter 等一系列关系算子(Operator)的实现框架,从而可以组合使用多个关系算子,构建它们之间的数据依赖关系(也就是执行计划),最终实现不同 SQL 的功能。
在数据库新时代,OLAP 的发展导致将大量数据加载到内存进行计算,瓶颈慢慢从存储端向 CPU 端倾斜,榨干 CPU 每一滴性能的企图就变得越发强烈,于是 CodeGen,向量化执行 等方法应运而生,它们从不同的方向入手来优化 CPU 的利用率,能够极大的提高执行效率。向量化执行正是利用列式存储的优势,可以一次性对整列数据进行批量处理,减少 CPU 的消耗。
3.6.2 关系算子
有了执行引擎奠定的框架,关系算子只需要一个萝卜一个坑,逐一实现即可。但是算法的世界是千变万化的,比如 Join 相关的算法就有 BroadcastJoin,LookupJoin,SortJoin 等等, 列存呢?
对于 Join 而言,运算的核心在于两表中 Joinkey 的匹配上,而对于其他列数据匹配上了就复制,匹配不上就丢弃。那么结合延迟物化的思想,是否可以等到匹配完成后再加载其他列数据,从而减小不必要的数据加载。
3.6.3 举例
举个例子,对于如下 SQL:
SELECT emp.age, dept.name FROM emp, dept WHERE emp.dept_id = dept.id
我们先抽出 emp 表的 dept_id 和 dept 表的 id 列数据,进行匹配,并输出匹配结果对应原表的位置信息,如下图所示,显示了 大小为 5 的列
与 大小为 4 的列
的Join结果:
其中 等于号的左边
为 dept_id 和 id 列的数据,等于号的右边
为匹配结果对应原表的位置信息,比如第一行 1,2 代表 dept_id 列的第一个值 42 和 id 列的第 2 个值 42。
假设右表的4行数据为别为:
name |
---|
Smith |
Johnson |
Williams |
Jones |
然后根据输出的位置信息,就可以从原始数据中抽取 age、name 列的数据得到 Join 最后的结果。当然该算法能够产生明显优化效果的前提是 Join 的结果相较于原始数据比较小,这样才能够有效避免加载过多数据。另外 由于上图输出结果的第二列是无序的,如果回表查必然造成大量随机 IO ,为了解决这个问题,Jive Join[参考文献 Fast Joins Using Join Indices]采用了对其进行排序之后再查询,即将随机 IO 转化为顺序 IO 的方法进行优化。
基于 Jive-Join 思想,我们在右侧表position列上添加一个额外的列(一个密集递增的整数序列):
And then sort the output by the first column:
We can now scan the columns from the table(右表) efficiently to get the data:
dept#id position | sort idx | name |
---|---|---|
1 | 4 | Smith |
2 | 1 | Johnson |
2 | 3 | Johnson |
4 | 2 | Jones |
最后,为了保持原SQL语义的一致性,我们对数据结构再次排序,这次是按最初添加到连接输出的列,将当前数据结构恢复为原始连接顺序(以便与另一个表的连接输出相匹配)。
dept#id position | sort idx | name |
---|---|---|
2 | 1 | Johnson |
4 | 2 | Jones |
2 | 3 | Johnson |
1 | 4 | Smith |
sort idx 列是一个冗余列,根据该属性可以快速的进行过滤,可以实现显著的性能提升。
3.6.4 进一步的优化思路
Further research has resulted in additional improvements to the above algorithm. It turns out that a complete sort is not necessary to reduce random access performance overhead in value extraction of join
output. This is because most storage media are divided into contigu- ous blocks of storage, and random access within a block is significantly cheaper than random access across blocks. Therefore, the database does not need to completely sort the position list before using it to extract values from columns; rather, it just needs to be partitioned into the blocks on storage (or an approximation thereof) in which those positions can be found. Within each partition, the positions can remain unordered, since random access within a storage block is much cheaper (e.g., the difference between memory and disk I/O, or the difference between cache and memory I/O). The column from which we are extracting values is therefore accessed in block order, but not in exact position order. The Radix Join [17] is an example of a late materialized join along these lines, and provides a fast mechanism for both performing the partitioning of column positions into blocks before the column extraction, and reordering the intermediate data back to the original join order after the extraction has occurred, as long as all data involved are from fixed-width columns.
- 不需要完全排序整个列数据来减少 join 值提取中的随机访问性能开销输出
- 存储介质被分成连续的存储块,块内的随机访问比跨块的随机访问便宜得多。 因此,只需要在存储(或其近似值)上划分为可以找到这些位置的块。 在每个分区内,位置可以保持无序,因为存储块内的随机访问要便宜得多。
- 结论:保证块维度的顺序访问,块内的数据可以保持无序,来替代全局列按精确的位置顺序访问。 这个也就是一个新的概念: Radix Hash Join。
Radix Hash Join 提供了一种快速机制,用于在列提取之前将列位置划分为块,并在之后将中间数据重新排序回原始连接顺序 只要涉及的所有数据都来自固定宽度的列,提取就会发生。
3.7 Group-by, Aggregation and Arithmetic Operations
分组、聚合和算术运算
- Group-by 通常是现代列存储中基于哈希表的操作
- 聚合操作大量使用列式布局。
3.8 Inserts/updates/deletes
实现写入存储的一种自然方法是将差异(插入、删除和更新)存储在内存结构中。
-
Read Optimized
- Positional Delta Trees: PDT 位置增量树 , Positional Update Handling in Column Stores
-
Write Optimized
- Read store 和 delta data(增量数据) 分离
- Bitmap Index 标记被删除的行
- LSMTree: Log-Structured-Merge-Tree
3.9 Indexing, Adaptive Indexing and Database Cracking
- lightweight metadata per page (min/max/sum/null/count/distinct)
- partial sorted
- 渐进式索引
总结: 列式存储优点
数据压缩,确定一列数据的规律
- 查询时可以时读的数据量更少,在IO密集型计算中获得更多的性能优势
- 相同类型压缩效率更高
- 可以针对不同类型使用不同的压缩算法。LZ4,run-length encoding,delta encoding
- 高效的压缩可以减少磁盘IO数据量,但是高效的压缩都必须遵循某种特殊的规律,比如数据的长度,类型等一致;基于列式的查询数据库正好遵循这一点; 此外,某些特别的数据压缩格式,比如 RUN-Length编码,甚至可以在不做解压时便可以对数据过滤,减少无关的IO
数据选择优势大
- 可以选择特定的列做计算而不是读所有列
- 对聚合计算友好
更适合做延迟物化
- 物化: 将列数据转换为可以被计算或者输出的行数据或者内存数据结果的过程,物化后的数据通常可以用来做数据过滤,聚合计算,Join
- 延迟物化: 尽可能推迟物化操作的发生
- 缓存友好;节省CPU / 内存带宽;可以利用到执行计划和算子的优化,例如filter;可以直接在压缩列做计算
更容易向量化
- 向量化基于SIMD (single instruction multiple data) 。对于现代多核CPU,其都有能力用一条指令执行多条数据。
- 用SIMD指令完成的代码设计和执行的逻辑就叫做向量化
- 向量化对数据格式有要求:要处理的数据需要是连续内存;需要明确数据类型
- 执行模型要求:数据需要按批读取;函数的调用需要明确数据类型;
- 列存数据库适合设计出这样的执行模型从而使用向量化技术:因为本身按列读取,可以保证数据按批,在内存中连续;可以根据列的类型定义数据读写逻辑;函数按列类型处理
读这篇论文最大的收获是首先知道了到底哪些因素促使Column-Store对分析类查询性能可以大幅优于Row-Store: 延迟物化、压缩、Join 以及 块遍历 。
同时由于这篇论文详细的对各种场景进行了测试、论证,同时对 Row-Store 应用类似的性能优化的手段再进行测试、对比,掰开了揉碎了的分析。
最终告诉我们:
Column-Store的优点不止在于它的存储格式,查询引擎层的各种优化也同样关键,而由于Row-Store本身存储格式的限制,即使在Row-Store上使用这些优化,效果也不好。
实际上,列存数据库不只是存储格式的问题,底层存储的变化往往牵一发而动全身,如何适应性的修改计算引擎、存取方式等来达到更高更快的性能,并适应不同的 workload 或者硬件发展的趋势,都是列存数据库要关心的问题。
References
Paper
列式存储简介
Columnar Storage
http://db.csail.mit.edu/projects/cstore/abadi-sigmod08.pdf
列式存储更新 - PDT(位置增量树)