|
1 | 1 | # 存储
|
| 2 | +# 什么时候需要列存引擎?🤔 |
| 3 | + |
| 4 | +数据库用户通常会在分析型 (OLAP) 数据库系统上对大量数据进行统计分析。举个例子,假设我们有一张记录用户点击量的表,记录用户什么时候访问了什么网页,从哪里来,看了多久: |
| 5 | + |
| 6 | +| 点击时间 (time) | URL | 用户名 (username) | 停留时长 (min) (length) | 来源 (referrer) | |
| 7 | +|:--|:--|:--|:--|:--| |
| 8 | +| 10:00am | risinglightdb/risinglight | user1 | 1 | github.com | |
| 9 | +| 10:01am | risinglightdb/risinglight/issues | user2 | 2 | github.com/risinglightdb | |
| 10 | +| ... | ... | ... | ... | ... | |
| 11 | + |
| 12 | +对于数据库用户来说,它们关心的一项内容可能是:过去一小时内,有多少用户访问了 risinglight。 |
| 13 | + |
| 14 | +```sql |
| 15 | +SELECT count(*) FROM table WHERE time >= "10am" AND time <= "11am" |
| 16 | +``` |
| 17 | + |
| 18 | +行存引擎在盘上将同一行的内容存储在一起,列存引擎将一列的内容存储在一起。接下来我们看看这条 query 在行存和列存引擎上分别需要访问多少数据。 |
| 19 | + |
| 20 | + |
| 21 | + |
| 22 | +在这个 query 中,我们只需要访问 `time` 列的数据就能获得结果。在行存引擎里,由于同一行的数据是相邻的,取得 `time` 值的同时也需要把同一行的其他信息从盘上读取出来。在列存引擎里,由于一列的数据是相邻存储的,我们只需要把这一列的数据读出来就可以得到结果了。因此,OLAP 数据库系统的数据适合存储在列存引擎中。数据按列存放,可以大大提升分析型查询的速度。 |
| 23 | + |
| 24 | +# 列存引擎如何存储数据?💾 |
| 25 | + |
| 26 | +## 最简单的“列”存 |
| 27 | + |
| 28 | +我们先做一个最简单的假设:用户只有一张表,表只有一列定长类型数据 (i64)。用户写一次就不会再写了。之后用户在表上只跑只读的全表扫描 query。在这种情况下,用什么方式存储数据是最高效的呢?我们从这么几个维度来考虑: |
| 29 | +- 空间:数据写到盘上需要占用多少空间。 |
| 30 | + - 当我们使用列存引擎存储已经排好序的数据时,相邻的数据大概率会重复。此时若使用 Run-Length 编码 (RLE),则可以大大减少空间占用。 |
| 31 | +- 读取:把数据从盘上读到内存里需要多少开销。 |
| 32 | + - 如果我们使用一种压缩算法直接对整列做压缩,这就意味着每次要从盘上把一整列读上来,在内存里解压,才能读到原始数据。这就会导致一定的读放大。 |
| 33 | +考虑到这个场景里用户“扫描全表”、“一次写入”,我们可以选定一种编码方法,按顺序写入数据,一次全部压缩写到盘上。在扫描全表时,一次全部读入并解压。这样可以用最少的空间达到最高效的全表扫描性能。 |
| 34 | + |
| 35 | + |
| 36 | + |
| 37 | +## 列存索引 |
| 38 | + |
| 39 | +用户开始考虑做一些筛选,比如 SELECT count(*) FROM table WHERE x >= 10。其中 x 是这张表的主键。在这种情况下,用户不需要扫描全表了。如果我们依然一次压缩全部写入,在读取的时候就有可能读到大量不需要的数据。因此,我们需要: |
| 40 | + |
| 41 | +- **对列做划分,比如每 64K 做一次压缩,而不是全部写入再压缩。** |
| 42 | + - 计算机的内存是有限的,数据库通常来说都会维护自己的 buffer pool,只缓存一部分盘上的数据。在 RisingLight 的存储引擎中,我们使用 block cache 来缓存这些数据。将数据分块也可以减小缓存换入换出的开销,提升 block cache 的使用率。 |
| 43 | +- **存储引擎可以在写入时预先根据 x 对列做排序和索引**,记录每一小块的起始 key,从而快速定位 x >= 10 所在的块在什么位置。 |
| 44 | + |
| 45 | +这样一来,就可以大大减少扫描 x >= 10 的读取量。我们把存储引擎最小的管理单位称作“块 (Block)”。在 RisingLight 中,Block 的大小通常是 64K - 1MB。每次读取时,先从索引找到 x >= 10 对应的块的位置,然后开始读取并解压缩 block,获得原始数据。 |
| 46 | + |
| 47 | +在 RisingLight 中,列文件都使用 .col 后缀存储,索引文件使用 .idx 或者 .sort 后缀存储。 |
| 48 | + |
| 49 | + |
| 50 | + |
| 51 | +根据主键做筛选只是一个例子(而且在 RisingLight 里面也没有实现)。在 RisingLight 中,按 block 对列做划分的特性可以用来做 filter scan。比如: |
| 52 | + |
| 53 | +```sql |
| 54 | +SELECT sum(y) FROM table WHERE x = 10; |
| 55 | +``` |
| 56 | + |
| 57 | +即使 x 不是主键,也不一定在索引里,现在的设计中我们依然可以减少 I/O 量。我们只需要把 x = 10 的行对应的 y 扫描出来就行了。 |
| 58 | + |
| 59 | +## 存储多个列 |
| 60 | + |
| 61 | +现在用户的数据不止有一列了,而且每一列的数据类型都不同: |
| 62 | + |
| 63 | +```sql |
| 64 | +CREATE TABLE t(c1 int, c2 varchar, c3 float, primary key(v1)) |
| 65 | +``` |
| 66 | + |
| 67 | +数据类型不同,意味着写入时,block 不能根据行对齐。举个例子,在 c1 中,假设前 100 个 i64 数据可以正好塞进一个 block。现在的 c2 列类型是 varchar,每一个数据都是不定长的。它的前 100 行可能塞不满一个 block,也有可能超过一个 block。在这种情况下,我们应该继续保持 block 大小不变,让不同列的同一个位置的 block 包含不同个数的数据;还是保持每个 block 行数相等? |
| 68 | + |
| 69 | + |
| 70 | + |
| 71 | +**RisingLight 的选择是不对齐 Block**。这是出于两个考虑: |
| 72 | +- Block Cache 更好管理:Block Cache 里的每一项长度几乎相同,只需要限制 Block Cache 的项数就可以很好的估计内存占用。 |
| 73 | +- I/O 更可控:Block 是最小的管理单元、最小的读盘单元。如果每个 Block 大小几乎相同,每个 I/O 请求的大小就更可控,延迟也更可控。 |
| 74 | + |
| 75 | +多个相同行数的列会被放在同一个文件夹中,称为 RowSet。一个 RowSet 由 n 个列和 n 个索引组成,每一列包含相同的行数。 |
| 76 | + |
| 77 | + |
| 78 | + |
| 79 | +## 存储整张表 |
| 80 | + |
| 81 | +一个用户表的数据通常不可能一次全部导入,用户往往会往表里多次 insert 数据。在 RisingLight 的设计中,每次 insert 都会产生一个新的 RowSet,**所有文件一旦落盘就不会更改**。同一个用户表的所有 RowSet 组合起来,才是完整的表。这里就需要考虑两个问题: |
| 82 | + |
| 83 | +* 怎么在读取时把 RowSet 组合成一张表? |
| 84 | +* 单个 RowSet 可能很小 (几 KB),也有可能很大 (几 MB)。如何对它们进行整理? |
| 85 | + |
| 86 | +先考虑读取的问题。在做全表扫描时,我们要一个接着一个地读取 RowSet。这里就需要有一个 `ConcatIterator` 把 RowSet 拼起来。虽然存储层里面有多个 RowSet,在数据库上层算子看来,它依然是一整张表。对于要按照主键排序的情况,我们需要实现一个 `MergeIterator`,对多个 RowSet 做多路归并,从而合并多个 RowSet,对计算层暴露一个排好序的表。 |
| 87 | + |
| 88 | +而后考虑整理的问题。用户不断操作数据库系统,产生大量小的 RowSet,并且也有可能“删除” RowSet 中的数据。大量小的 RowSet 对多路归并的性能会产生影响。因此,我们需要定期对存储引擎中的数据做整理。在 Merge-Tree 引擎中,所有文件一旦落盘就不会更改了。因此,如果我们想要整理这些 RowSet,唯一的方法就是把需要整理的 RowSet 读出来,然后生成一个新的 RowSet。这个整理的过程被称为“Compaction”。 |
| 89 | + |
| 90 | +## 删除 |
0 commit comments