存储

什么时候需要列存引擎?🤔

数据库用户通常会在分析型 (OLAP) 数据库系统上对大量数据进行统计分析。举个例子,假设我们有一张记录用户点击量的表,记录用户什么时候访问了什么网页,从哪里来,看了多久:

点击时间 (time)URL用户名 (username)停留时长 (min) (length)来源 (referrer)
10:00amrisinglightdb/risinglightuser11github.com
10:01amrisinglightdb/risinglight/issuesuser22github.com/risinglightdb
...............

对于数据库用户来说,它们关心的一项内容可能是:过去一小时内,有多少用户访问了 risinglight。

SELECT count(*) FROM table WHERE time >= "10am" AND time <= "11am"

行存引擎在盘上将同一行的内容存储在一起,列存引擎将一列的内容存储在一起。接下来我们看看这条 query 在行存和列存引擎上分别需要访问多少数据。

在这个 query 中,我们只需要访问 time 列的数据就能获得结果。在行存引擎里,由于同一行的数据是相邻的,取得 time 值的同时也需要把同一行的其他信息从盘上读取出来。在列存引擎里,由于一列的数据是相邻存储的,我们只需要把这一列的数据读出来就可以得到结果了。因此,OLAP 数据库系统的数据适合存储在列存引擎中。数据按列存放,可以大大提升分析型查询的速度。

列存引擎如何存储数据?💾

最简单的“列”存

我们先做一个最简单的假设:用户只有一张表,表只有一列定长类型数据 (i64)。用户写一次就不会再写了。之后用户在表上只跑只读的全表扫描 query。在这种情况下,用什么方式存储数据是最高效的呢?我们从这么几个维度来考虑:

  • 空间:数据写到盘上需要占用多少空间。
    • 当我们使用列存引擎存储已经排好序的数据时,相邻的数据大概率会重复。此时若使用 Run-Length 编码 (RLE),则可以大大减少空间占用。
  • 读取:把数据从盘上读到内存里需要多少开销。
    • 如果我们使用一种压缩算法直接对整列做压缩,这就意味着每次要从盘上把一整列读上来,在内存里解压,才能读到原始数据。这就会导致一定的读放大。 考虑到这个场景里用户“扫描全表”、“一次写入”,我们可以选定一种编码方法,按顺序写入数据,一次全部压缩写到盘上。在扫描全表时,一次全部读入并解压。这样可以用最少的空间达到最高效的全表扫描性能。

列存索引

用户开始考虑做一些筛选,比如 SELECT count(*) FROM table WHERE x >= 10。其中 x 是这张表的主键。在这种情况下,用户不需要扫描全表了。如果我们依然一次压缩全部写入,在读取的时候就有可能读到大量不需要的数据。因此,我们需要:

  • 对列做划分,比如每 64K 做一次压缩,而不是全部写入再压缩。
    • 计算机的内存是有限的,数据库通常来说都会维护自己的 buffer pool,只缓存一部分盘上的数据。在 RisingLight 的存储引擎中,我们使用 block cache 来缓存这些数据。将数据分块也可以减小缓存换入换出的开销,提升 block cache 的使用率。
  • 存储引擎可以在写入时预先根据 x 对列做排序和索引,记录每一小块的起始 key,从而快速定位 x >= 10 所在的块在什么位置。

这样一来,就可以大大减少扫描 x >= 10 的读取量。我们把存储引擎最小的管理单位称作“块 (Block)”。在 RisingLight 中,Block 的大小通常是 64K - 1MB。每次读取时,先从索引找到 x >= 10 对应的块的位置,然后开始读取并解压缩 block,获得原始数据。

在 RisingLight 中,列文件都使用 .col 后缀存储,索引文件使用 .idx 或者 .sort 后缀存储。

根据主键做筛选只是一个例子(而且在 RisingLight 里面也没有实现)。在 RisingLight 中,按 block 对列做划分的特性可以用来做 filter scan。比如:

SELECT sum(y) FROM table WHERE x = 10;

即使 x 不是主键,也不一定在索引里,现在的设计中我们依然可以减少 I/O 量。我们只需要把 x = 10 的行对应的 y 扫描出来就行了。

存储多个列

现在用户的数据不止有一列了,而且每一列的数据类型都不同:

CREATE TABLE t(c1 int, c2 varchar, c3 float, primary key(v1))

数据类型不同,意味着写入时,block 不能根据行对齐。举个例子,在 c1 中,假设前 100 个 i64 数据可以正好塞进一个 block。现在的 c2 列类型是 varchar,每一个数据都是不定长的。它的前 100 行可能塞不满一个 block,也有可能超过一个 block。在这种情况下,我们应该继续保持 block 大小不变,让不同列的同一个位置的 block 包含不同个数的数据;还是保持每个 block 行数相等?

RisingLight 的选择是不对齐 Block。这是出于两个考虑:

  • Block Cache 更好管理:Block Cache 里的每一项长度几乎相同,只需要限制 Block Cache 的项数就可以很好的估计内存占用。
  • I/O 更可控:Block 是最小的管理单元、最小的读盘单元。如果每个 Block 大小几乎相同,每个 I/O 请求的大小就更可控,延迟也更可控。

多个相同行数的列会被放在同一个文件夹中,称为 RowSet。一个 RowSet 由 n 个列和 n 个索引组成,每一列包含相同的行数。

存储整张表

一个用户表的数据通常不可能一次全部导入,用户往往会往表里多次 insert 数据。在 RisingLight 的设计中,每次 insert 都会产生一个新的 RowSet,所有文件一旦落盘就不会更改。同一个用户表的所有 RowSet 组合起来,才是完整的表。这里就需要考虑两个问题:

  • 怎么在读取时把 RowSet 组合成一张表?
  • 单个 RowSet 可能很小 (几 KB),也有可能很大 (几 MB)。如何对它们进行整理?

先考虑读取的问题。在做全表扫描时,我们要一个接着一个地读取 RowSet。这里就需要有一个 ConcatIterator 把 RowSet 拼起来。虽然存储层里面有多个 RowSet,在数据库上层算子看来,它依然是一整张表。对于要按照主键排序的情况,我们需要实现一个 MergeIterator,对多个 RowSet 做多路归并,从而合并多个 RowSet,对计算层暴露一个排好序的表。

而后考虑整理的问题。用户不断操作数据库系统,产生大量小的 RowSet,并且也有可能“删除” RowSet 中的数据。大量小的 RowSet 对多路归并的性能会产生影响。因此,我们需要定期对存储引擎中的数据做整理。在 Merge-Tree 引擎中,所有文件一旦落盘就不会更改了。因此,如果我们想要整理这些 RowSet,唯一的方法就是把需要整理的 RowSet 读出来,然后生成一个新的 RowSet。这个整理的过程被称为“Compaction”。

删除