TT Lab
开始
学习 学习路径 课程

ClickHouse — 从内部理解列式分析数据库

按列存储意味着什么 — 数据部分、列文件与压缩

在 TT Lab 中继续学习

一句话总结

ClickHouse 的 MergeTree 把行存储为按排序键顺序排好、并逐列单独压缩的一组文件(数据片段,part)。所以查询的成本不取决于选出多少行,而取决于碰了哪些列,以及借助排序键能跳过多少。

为什么需要它

向行式数据库抛出分析查询,很快就会遇到这样的疑问:“我只是求一个销售额合计,为什么要读整张表?”行式存储中,一行的所有列是连在一起的,所以即使只想累加 amount 这一列,磁盘上也要把整行都捞上来。对事务处理来说这是对的——订单一笔整行读、整行写。但分析正相反。它要在几亿行中只读两三列。

列式存储把这种不对称颠倒过来。把同一列的值聚在一起,查询只需读它用到的列;相似的值彼此相邻,压缩效果也好得多。代价是“修改一行”变得昂贵,因为必须触碰多个列文件。ClickHouse 的大部分设计决策——写过一次的数据片段不再修改,删除和更新留到以后合并时处理——都源于这个权衡。

工作原理

一次 INSERT 会创建一个数据片段目录。数据片段内的行按排序键顺序排列,每列各有 .bin 数据文件和标记文件,每 8192 行划分为一个颗粒,primary.idx 中只写颗粒第一行的键值。后台合并把两个小数据片段合并成一个大数据片段,旧数据片段变为非活动状态

官方文档(Table parts)写出的 INSERT 的四个步骤,就是它的存储结构。

  1. 按表的排序键(ORDER BY)顺序对进来的行排序,并创建稀疏主索引
  2. 把排好序的行拆成列
  3. 逐列压缩
  4. 把压缩后的列文件和索引写入一个新的数据片段目录

数据片段一旦写出就不会改变(immutable)。每来一次 INSERT,数据片段就多一个,后台合并再把小数据片段合并成大数据片段。被合并的旧数据片段变为非活动(inactive)状态,之后会被删除。数据片段名称 all_1_2_1 依次是分区(all)、所含块编号的起止(1–2)和合并级别(1)。级别 0 是一次也没有被合并过的数据片段。

列文件内部被划分为颗粒(granule)。默认 8192 行为一个颗粒,它是处理的最小单位。主索引记录的不是每一行,而是每个颗粒第一行的键值——所以叫“稀疏(sparse)”索引。WHERE site = 'docs.example' 到来时,会扫描索引,把不可能含有该值的颗粒整个跳过。如果用不在排序键里的列过滤,就没有可以跳过的依据,只能全部读取。system.parts 的 marks 是每个颗粒对应一个的位置标记的数量,再加上一个表示结尾的标记(200 万行时,245 个颗粒对应 246 个标记)。

各列的压缩率差别很大。 相同的值连续出现很长的列(排序键的第一列、只有几种取值的列)会缩小到几百分之一,而接近随机的整数几乎压缩不了。LowCardinality(String) 是把字符串替换成字典编号后存储的类型,所以只有五个站点名称的列几乎是免费的。反过来,像时间这样不断增大的整数,仅靠默认编解码器(LZ4)是压缩不下来的——压缩这种数据的编解码器会在后面的模块讲。

SELECT name, data_compressed_bytes, data_uncompressed_bytes
FROM system.columns WHERE database = 'col' AND table = 'events';   -- 열별 크기

SELECT sum(dur_ms) FROM col.events FORMAT JSON;   -- 맨 끝 statistics 에 rows_read · bytes_read

衡量成本的刻度有两个。rows_read 是没能跳过而读取的行数,bytes_read 是从这些行中触碰的列的(解压后)字节数。求一个 UInt32 列的和,每行 4 字节,200 万行恰好是 8,000,000 字节。同样是 200 万行,如果读取长字符串列的内容,就会是好几倍。有一个陷阱——length(url) 会被改为只读取仅含长度的子列(url.size),所以每行只读 8 字节。读了什么不要靠猜,要用数字确认。

每个查询的这些数字也会保留在 system.query_log 中。用 SETTINGS log_comment = '...' 给它贴上标签,以后就容易找到自己的查询。日志表大约每 1 秒清空一次,所以想立刻看到刚运行的查询,要先执行 SYSTEM FLUSH LOGS。

最后是数据片段的格式。数据片段较小时,会是把所有列放在一个文件里的 Compact,较大时,则是每列单独一个文件的 Wide。界限由表设置 min_bytes_for_wide_part、min_rows_for_wide_part 决定。这是为了在小规模 INSERT 频繁时,不让文件数量暴增。

在现场相遇的样子

收到分析仪表板很慢的反馈,首先要找的是 SELECT *。这个在行式数据库里几乎免费的习惯,在列式存储中就意味着要打开所有列文件。仅仅改成只选界面需要的列,读取的字节数往往就能减少到几分之一。

第二种常见情况,是把排序键定成“既然是主键那就用 id”的表。几乎所有查询都按站点和时间段过滤,而排序键是 id,那么索引虽然存在,却一次也没能跳过。如果 rows_read 始终等于总行数,就该怀疑排序键。

第三种是容量规划。如果按“原始日志每天 100GB,所以磁盘是 100GB × 保留天数”来定,就会大错特错。各列的压缩率相差几倍到几百倍,所以正确的做法是放入真实数据一天的量,用 system.columns 测出压缩后的大小,再以此为基础相乘。

下一项实验要做什么

以排序键 (site, ts) 创建 col.events 表,写入 200 万行,把数据片段合并成一个,并从 system.parts 读出名称、行数和标记数。找出各列压缩率中压缩得最好和最差的列,比较只读一列的查询与读取长字符串列的查询的 bytes_read。测量按排序键过滤与按键之外的列过滤时的 rows_read,在 query_log 中找到自己的查询,再创建一张小表,确认 Compact 数据片段。