列存(Column-Oriented)数据库学习笔记:内存数据压缩

大数据技术已经从几年前的热门概念落地为实用技术,为互联网贡献了重要力量。

随着移动应用发展,数据紧密包裹用户,
数据分析(OLAP)要求日益提高,数据量、查询延时都面临极大挑战。

这种情况下,列存、行列混存数据库技术,在冷门多年后又重新获得了极大关注。

我们一起来看个案例,Mark Litwintschik使用十亿条的士载客记录,
在不同的软硬件平台上测试了的分析查询性能:
http://tech.marksblogg.com/benchmarks.html

可以看到,除了MapD(GPU数据分析平台)遥遥领跑之外,
(跑在8路泰坦、半T内存这种怪兽硬件上)
列存数据库ClickHouse仅凭借单机普通硬件,
就达到了Spark这样专门的大数据平台的百倍性能,非常惊人。

基于此,近期考虑对OLAP架构,尤其是列存数据库做深入的学习探索,
相关笔记就在此记录。

一些资料

VLDB、SIGMOD、ICDE:

数据库技术最重要的期刊会议。

Vertica:

HP的商业列存数据库,以C-Store为原型,在AWS上有托管分析服务售卖。
在最开始的测试案例中,可以看到Vertica虽然没有ClickHouse快,
但也以单机跑出了接近10节点Spark集群的成绩。

Daniel.J.Abadi:

列存数据库C-Store的开发者,在各大期刊上发表过多篇关于列存数据库的Paper。
其09年的“Query Execution in Column-Oriented Database Systems”,
对列存数据库的各方面都做了详细叙述。

何为列存数据库?

我们熟悉的Mysql等数据库,是按行存放数据的:

+--------+-----+
| name   | age |
+--------+-----+
| 孙丽华  |  21 |
| 王永恒  |  23 |
| 张伟朋  |  21 |
+--------+-----+```

这样的数据存储方式,每次增加数据可以在末尾增加一行就行。
(实际比此复杂)

列存数据库则把行列倒转,并把不同列分开存放:

+--------+--------+--------+--------+
| name | 孙丽华 | 王永恒 | 张伟朋 |
+--------+--------+--------+--------+
+--------+--------+--------+--------+
| age | 21 | 23 | 21 |
+--------+--------+--------+--------+


列存数据库在增加数据时IO不连续,需要在每个列后面追加数据,写性能不佳,
因此一直没有成为主流的业务系统(OLTP)的首选数据库。

如前所述,大数据量下的OLAP是个挑战,它的用况特征我们可以列举一下:
    - 数据是批量导入的,导入后不修改
    - 绝大多数是读操作
    - 每个表有很多列,分析操作经常只涉及小部分列

可以看出,OLAP用况很吻合列存数据库,
既避开了列存的细粒度写性能的劣势,
又发挥了读少数列时节省IO、不需要读无用列的优点。

列存将相似数据紧密排列,对内存、CPU和磁盘都很友好,
这篇笔记的主要关注在(单机下的)列存数据在内存和CPU的处理,
磁盘上的存储布局也是一个较大的话题,会放到另一篇笔记。


# 列存数据的SIMD优化

数据的运算在CPU上进行,
CPU的数据吞吐量(Throughput)越大,计算就越快,数据分析也就越快。

CPU性能 = 每周期执行指令数(IPC,Instructions Per Cycle)× 频率
硬件本身决定了IPC上限和频率。

90年代,Intel等CPU厂商设计了MMX、SSE等单指令多数据(SIMD)的指令集,
顾名思义,就是一条指令处理一批数据,提高计算能力。
时至如今,SIMD仍是最重要的优化手段之一。

对于列式存放的数据,在内存里紧密排列、大小相同,
可以很容易地进行SIMD处理,批量地加载进CPU,由一次CPU指令处理完。
这就在不变的硬件条件下,成倍地提高了数据吞吐量。

紧密数组的遍历处理,对CPU流水线很友好,
即使在代码中没有特殊作SIMD处理,
编译器也可以使用loop-pipelining等手段进行优化,达到更高效率。


# 列存数据压缩

在CPU数据吞吐量固定的情况下,数据压缩是提升处理速度有效途径。

常见的压缩算法如lz77、lz78家族,对数据都做了变形处理,
然后在一个较大(k级)的数据窗口中寻找相同的数据达成压缩。

这类压缩的结果与原始数据相去甚远,进行计算之前需要解压,
我们称为通用压缩或者重型压缩(Generic Compression/Heavy Compression)
这类压缩的特点是压缩率高,压缩慢,解压较快,消耗CPU资源较多。

列存数据由于相邻数据相似度非常高,
可以进行针对数据特征的轻量压缩(Lightweight Compression)
并可以直接对压缩处理进行计算。

轻量压缩消耗CPU资源极少,压缩比普遍在1/3-1/4,
理想情况下,可以提升对应倍数的吞吐量。


# 轻量压缩的算法框架

###### 轻量压缩算法有很多种,常用的有:
    - 前缀压缩(Prefix Suppression):
    - 位向量编码(Bit-Vector Encoding)
    - 字典编码(Dictionary Ecoding)
    - 参照系编码(FOR,Frame of Reference Encoding)
    - 差异编码(Defferential Encoding/PFOR-DELTA)

###### 下面简单列一下各个算法的要点。

- 前缀压缩(Prefix Suppression):
  消除相同前缀,常用状况:列中大部分都是小值,但最大值较大

- 位向量编码(Bit-Vector Encoding):
  列中大多数值都相同,比如性别,以1个bit来表达。
  编码后数据还可以再次压缩。

- 字典编码(Dictionary Ecoding):
  适用于唯一值(Distinct Values)数量较少的情况,例如季度、国家
  预提取预存字典的KV对,数据里存K。
  压缩后数据长度为log 2(|D|) bits,D是唯一值数量

- 参照系编码(FOR,Frame of Reference Encoding):
  选择参照值,每个数据只存与参照值的差值,
  压缩后每值占log 2(max − min + 1) bits
  变种FOR: 选择可以小于0的参照值,使的每个值都为正数。

- 差异编码(Defferential Encoding/PFOR-DELTA):
  和参照系编码类似,使用前一个值作为参照值
  对于递增递减的数据性能良好。
  例如时间戳、递增ID、有序数组

- 行程编码(Run-Length Encoding,RLE):
  将一串连续的相同数据转化为特定的格式
  例如:aaabbc,表示为:3个a,2个b,1个c,压缩数据3a2b1c
  RLE是很基础的压缩方式,通用压缩算法中也很多它的影子,
  但通用压缩中经常结合多种算法,例如使用BWT可逆变换提高重复率。
  作为原始的、没有经过数据变形的RLE,更利于在压缩数据上进行计算。

###### 这些算法都可以在同一个算法框架下工作:
    - 找到数据特征,大部分数据都应该符合该特征
    - 对数据逐个进行编码,达成压缩,不同的压缩算法采用不同的编码方式
    - 如果碰到不符合特征的数据,按异常值(Exception Values)处理
    - 异常值的处理方式:编码为“跳出码(Escape Code)+ 原始数据”
    - 跳出码与正常值长度相等,其值为正常值不可能的值。所对应的原始数据可以另外存放

用伪码来表示编码过程:
```c
for i = j = 0; i < n; i++:
  if in[i] < MAXCODE:
    out[i] = CODE(in[i])
  else:
    out[i] = MAXCODE
    exception[j++]) = out[i]```

解码为逆过程:
```c
for i = j = 0; i < n; i++:
  if in[i] < MAXCODE:
    out[i] = DECODE(in[i])
  else:
    out[i] = exception[j++])```

有两个事情值得注意:
1,exception的数据比例对性能的影响。
2,if-else分支引发的指令预测失败,IPC(Instructions Per Cycle)下降的问题。

Marcin Zukowski在引文中测试了不同压缩算法、不同exception比例下的性能,
并且尝试使用两轮循环避免if-else分支,有兴趣可以看看。

在实践中,不同的数据列可以根据特征和需求来决定压缩策略,
工程师们提出过不少策略,以下是其中一种决策树:

该列有排序查询的需求吗
Y => 平均重复行程(Average Run-Length)> 2吗
Y => RLE压缩
N => 差异编码
N => 唯一值(Distinct Values)数量 < 50000吗
Y => 该列经常出现在选择谓词(即SQL where子语句)中吗
Y => 位向量编码
N => 字典编码
N => 是有局部性(某范围值较多)的数字类型吗
Y => 参照系编码
N => 不压缩或者重型压缩

在实际开发中,列数据一般被分成多个等大Block,
每个Block可以有自己的元数据(例如参照值),也可以引用别的Block的,
甚至可以再次对特征采样,选择不同的压缩策略。
可见落地过程中的优化空间还是很大的,需要结合实际数据测量效率。


# 对于压缩数据的计算

轻量压缩的结果与原始数据成一一映射的关系,
几乎所有的算术运算都可以直接在压缩结果上计算。

举例,原始数据[102, 101, 104],使用FOR编码,以100为参考值编码为[2, 1, 4],
那么(if > 101)的运算就被改写为(if > 1),可以直接在编码后数据上计算。

即使我们不使用改写计算的方法来处理数据,
而是使用解压后计算的常规方式,上述的轻量压缩仍旧可以带来巨大好处:

我们知道,RAM对比CPU来说是很慢的。
下表是数据在各个设备上存取数据的大概耗时比例,单位是时钟周期Cycle:

L1-CACHE 4
L2-CACHE 11
L3-CACHE 18
RAM 160```

计算过程:
- 压缩数据从RAM进入CACHE
- 送入CPU解压
- 进行业务运算
- 再压缩,结果留在CACHE
- 写回RAM

可见最耗时的两次IO:RAM-CACHE/CACHE-RAM,传输的都是压缩数据,
该技术被称为RAM-CPU CACHE Compression,
有效减少了RAM-CPU交换,增加了吞吐量。

以上涉及的都是列数据的通用的计算优化,
对于其在关系代数运算(查询)中的优化,
也是一个较大的话题,需要另外的篇幅来叙述。

主要参考资料

以下论文可以在各大学术库下载

- “Query Execution in Column-Oriented Database Systems” Daniel Abadi SIGMOD’09
- “Super-Scalar RAM-CPU Cache Compression” Zukowski, Heman, Nes, Boncz, ICDE’06
- “Weaving relations for cache performance” Ailamaki, DeWitt, Hill, and Skounakis. VLDB’01
- “Integrating Compression and Execution in Column-Oriented Database Systems” Abadi, SIGMOD’06
- “MonetDB/X100: Hyper-Pipelining Query Execution” Peter Boncz, CIDR’05

你可能感兴趣的:(列存(Column-Oriented)数据库学习笔记:内存数据压缩)