ClickHouse不同引擎大比拼

2022-03-28 08:03:56 浏览数 (3)

MergeTree

这个引擎是 ClickHouse 的重头戏,它支持一个日期和一组主键的两层式索引,还可以实时更新数据。同时,索引的粒度可以自定义,外加直接支持采样功能。

而且,以这个引擎为基础,后面几种引擎都是在其基础之上附加某种特定功能而实现的"变种"。

使用这个引擎的形式如下:

代码语言:javascript复制
MergeTree(EventDate, (CounterID, EventDate), 8192)
MergeTree(EventDate, intHash32(UserID), (CounterID, EventDate, intHash32(UserID)), 8192)
  • EventDate 一个日期的列名。
  • intHash32(UserID) 采样表达式。
  • (CounterID, EventDate) 主键组(里面除了列名,也支持表达式),也可以是一个表达式。
  • 8123 主键索引的粒度。

细节稍后再看,先建表看看文件系统那层大概是个什么样:

代码语言:javascript复制
create table t (gmt  Date, id UInt16, name String, point UInt16) ENGINE=MergeTree(gmt, (id, name), 10);

先不管采样机制,创建一个四列的表。然后插入几条数据:

代码语言:javascript复制
insert into t(gmt, id, name, point) values ('2017-04-01', 1, 'zys', 10);
insert into t(gmt, id, name, point) values ('2017-06-01', 4, 'abc', 10);
insert into t(gmt, id, name, point) values ('2017-04-03', 5, 'zys', 11);

这里注意一下,日期的格式,好像必须是 yyyy-mm-dd 。

在插入了三条数据之后,在 /var/lib/clickhouse/data/default/t 下可以看到这样的结构:

代码语言:javascript复制
├── 20170401_20170401_2_2_0
│   ├── checksums.txt
│   ├── columns.txt
│   ├── gmt.bin
│   ├── gmt.mrk
│   ├── id.bin
│   ├── id.mrk
│   ├── name.bin
│   ├── name.mrk
│   ├── point.bin
│   ├── point.mrk
│   └── primary.idx
├── 20170403_20170403_6_6_0
│   └── ...
├── 20170601_20170601_4_4_0
│   └── ...
└── detached

从上面看的话:

  • 最外层的目录,是根据日期列的范围,作了切分的。目前看来,三条数据,并没有使系统执行 merge 操作(还是有三个目录),后面使用更多的数据看看表现。
  • 最外层的目录,除了开头像是日期范围,后面的数字,可能与主键有关。
  • 最外层还有一个 detached ,不知道干什么的。
  • 目录内, primary.idx 应该就是主键组索引了。
  • 目录内其它的文件,看起来跟 Log 引擎的差不多,就是按列保存,额外的 mrk 文件保存一下块偏移量。

简单三条数据,有很多东西还是看不出来的。

(在等了不知道多少时间后,或者手动使用 optimize table t 触发 merge 行为,三个目录会被合成两个目录,变成 20170401_20170403_2_6_1 和 20170601_20170601_4_4_0 了)

ReplacingMergeTree

这个引擎是在 MergeTree 的基础上,添加了“处理重复数据”的功能,简直就是在多维数据加工流程中,为“最新值”,“实时数据”场景量身打造的一个引擎啊。这些场景下,如果重复数据不处理,你自己当然可以通过时间倒排,取最新的一条数据来达到目的,但是,至少这样会浪费很多的存储空间。

相比 MergeTree , ReplacingMergeTree 在最后加一个“版本列”,它跟时间列配合一起,用以区分哪条数据是“新的”,并把旧的丢掉(这个过程是在 merge 时处理,不是数据写入时就处理了的,平时重复的数据还是保存着的,并且查也是跟平常一样会查出来的,所以在 SQL 上排序过滤 Limit 什么的该写还是要写的)。同时,主键列组用于区分重复的行。

代码语言:javascript复制
create table t (gmt  Date, id UInt16, name String, point UInt16) ENGINE=ReplacingMergeTree(gmt, (name), 10, point);

像上面一样,“版本列”允许的类型是, UInt 一族的整数,或 Date 或 DateTime 。

代码语言:javascript复制
insert into t (gmt, id, name, point) values ('2017-07-10', 1, 'a', 20);
insert into t (gmt, id, name, point) values ('2017-07-10', 1, 'a', 30);
insert into t (gmt, id, name, point) values ('2017-07-11', 1, 'a', 20);
insert into t (gmt, id, name, point) values ('2017-07-11', 1, 'a', 30);
insert into t (gmt, id, name, point) values ('2017-07-11', 1, 'a', 10);

插入这些数据,用 optimize table t 手动触发一下 merge 行为,然后查询:

代码语言:javascript复制
select * from t

结果就只有一条:

代码语言:javascript复制
┌────────gmt─┬─id─┬─name─┬─point─┐
│ 2017-07-11 │  1 │ a    │    30 │
└────────────┴────┴──────┴───────┘

SummingMergeTree

ReplacingMergeTree 是替换数据, SummingMergeTree 就是在 merge 阶段把数据加起来了,当然,哪些列要加(一般是针对可加的指标)可以配置,不可加的列,会取一个最先出现的值。

建表:

代码语言:javascript复制
create table t (gmt Date, name String, a UInt16, b UInt16) ENGINE=SummingMergeTree(gmt, (gmt, name), 8192, (a))

插入数据:

代码语言:javascript复制
insert into t (gmt, name, a, b) values ('2017-07-10', 'a', 1, 2);
insert into t (gmt, name, a, b) values ('2017-07-10', 'b', 2, 1);
insert into t (gmt, name, a, b) values ('2017-07-11', 'b', 3, 8);
insert into t (gmt, name, a, b) values ('2017-07-11', 'b', 3, 8);
insert into t (gmt, name, a, b) values ('2017-07-11', 'a', 3, 1);
insert into t (gmt, name, a, b) values ('2017-07-12', 'c', 1, 3);

OPTIMIZE TABLE 后查询的结果为:

代码语言:javascript复制
┌────────gmt─┬─name─┬─a─┬─b─┐
│ 2017-07-10 │ a    │ 1 │ 2 │
│ 2017-07-10 │ b    │ 2 │ 1 │
│ 2017-07-11 │ a    │ 3 │ 1 │
│ 2017-07-11 │ b    │ 6 │ 8 │
│ 2017-07-12 │ c    │ 1 │ 3 │
└────────────┴──────┴───┴───┘

11, b 的 a 列,相加了, b 列取了一个 8 。

这个引擎要注意的一个地方是,可加列不能是主键中的列,并且如果某行数据可加列都是 null ,则这行会被删除。

AggregatingMergeTree

AggregatingMergeTree 是在 MergeTree 基础之上,针对聚合函数结果,作增量计算优化的一个设计,( clickhouse 中说的是状态,我个人猜,应该就是为增量计算保存的一些中间数据)。它会在 merge 时,针对主键预处理聚合的数据。

在讲具体的用法之前,要先讲明白两个问题,一是聚合数据的预计算,二是进一步,聚合数据的增量计算的情况。

聚合数据的预计算,实现上,算是一种“空间换时间”的权衡,并且是以减少维度为代价的。

假设原始有三个维度,一个需要 count 的指标:

我们可以通过减少一个维度的方式,来以 count 函数聚合一次 M ,减少维度要达到目的,结果的行数应该要减少的。以上面数据来说,如果我们把 D1 去掉,按 D2 和 D3 聚合的话,结果就是:

count(M1) 的值有多少大于 1 的,就可以反映这一步聚合有多少效果,因为它减少了数据的行数了。

通过这一步,我们从原来的三个维度,减少到两个维度,数据从 8 行减少到 5 行。当然,剩下的两个维度,在实际使用中,还是可以自由控制的了。

在实际中,如果是记录网站访问之类的数据,原始数据中一般都有一个“用户ID”的维度,但是在输出数据时,是不会精确到人的,那么这就是一个可以去掉的维度,至于去掉这个维度之后,数据行数能减少多少,实际上跟减少的那个维度的数据是完全没有关系的,只跟剩下的维度有关,剩下的维度值越集中,数据行数就越少。不过,去掉“用户ID”之后,好像我们就没有办法计算像 UV 这样的数据了。

接着说去掉一个维度后的情况,前面说当去掉“用户ID”之后,剩下的数据其实我们是没有办法再计算 UV 这样的指标了的,那么在之前,我们可以就把相关的指标算好。比如我们在前面的数据上加一个 UV 指标:

现在又有新的问题了,现在虽然还剩下 2 个维度,但是像 UV 这类数据即不可加,也不是复合指标,对于这个指标而言,维度不能再改变了。(从上方的数据中,你无法再按 D2 这一个维度聚合 UV,因为 D1 已经没了),如果只看 D2 这一个维度的 UV 值,那么我们想要的结果是:

AggregatingMergeTree 也许能解决这个问题。

clickhouse 中,对于聚合函数的实现,实现上是有三套的,除了普通的 sum, uniq 这些,应用于 AggregatingMergeTree 上的,还有 sumState , uniqState ,及 sumMerge , uniqMerge 这两组,而一个 AggregatingMergeTree 的表,里面的聚合函数,只能使用 sumState 这一组,对应于,查询时,只能使用 sumMerge 这一组。( sumState 这一组的输出,是无法查看的二进制数据)

另外,对于 AggregatingMergeTree 引擎的表,不能使用普通的 INSERT 去添加数据,那怎么办?一方面可以用 INSERT SELECT 来插入数据,更常用的,是可以创建一个物化视图。

我们还是按上面的例子,先创建一个 t 表:

代码语言:javascript复制
create table t(gmt Date, D1 String, D2 String, D3 String, M1 UInt16) ENGINE=MergeTree(gmt, (gmt, D1, D2, D3), 8192)

原始数据放进去:

代码语言:javascript复制
insert into t (gmt, D1, D2, D3, M1) values ('2017-07-10', '甲', 'a', '1', 1);
insert into t (gmt, D1, D2, D3, M1) values ('2017-07-10', '甲', 'a', '1', 1);
insert into t (gmt, D1, D2, D3, M1) values ('2017-07-10', '甲', 'b', '2', 1);
insert into t (gmt, D1, D2, D3, M1) values ('2017-07-10', '乙', 'b', '3', 1);
insert into t (gmt, D1, D2, D3, M1) values ('2017-07-10', '丙', 'b', '2', 1);
insert into t (gmt, D1, D2, D3, M1) values ('2017-07-10', '丙', 'c', '1', 1);
insert into t (gmt, D1, D2, D3, M1) values ('2017-07-10', '丁', 'c', '2', 1);
insert into t (gmt, D1, D2, D3, M1) values ('2017-07-10', '丁', 'a', '1', 1);

按 D2 和 D3 聚合 count(M1) 就是:

代码语言:javascript复制
select D2, D3, count(M1) from t group by D2, D3;

只按 D2 聚合 UV 是:

代码语言:javascript复制
select D2, uniq(D1) from t group by D2;

这些都没有什么特殊的地方。

接下来,我们创建一个物化视图,使用 AggregatingMergeTree ,把 D1 去掉(把前面的 t 删了重建,在创建视图后,重新填充数据,因为视图数据要重置):

代码语言:javascript复制
create materialized view t_view
ENGINE = AggregatingMergeTree(gmt, (D2, D3), 8192)
as
select D2, D3, uniqState(D1) as uv
from t group by D2, D3;

在重新填充数据后,直接查 t_view 的话:

代码语言:javascript复制
select * from t_view

可以看到这样的输出:

代码语言:javascript复制
┌────────gmt─┬─D2─┬─D3─┬─uv──────┐
│ 2017-07-10 │ c  │ 2  │ ????  │
└────────────┴────┴────┴─────────┘
┌────────gmt─┬─D2─┬─D3─┬─uv─────────┐
│ 2017-07-10 │ a  │ 1  │ ???      │
│ 2017-07-10 │ b  │ 2  │ ???????  │
│ 2017-07-10 │ b  │ 3  │ ????     │
└────────────┴────┴────┴────────────┘
┌────────gmt─┬─D2─┬─D3─┬─uv──────┐
│ 2017-07-10 │ a  │ 1  │ ????  │
└────────────┴────┴────┴─────────┘
┌────────gmt─┬─D2─┬─D3─┬─uv──────┐
│ 2017-07-10 │ c  │ 1  │ ?????  │
└────────────┴────┴────┴─────────┘

OPTIMIZE TABLE t_view 一下,就只有一片了。

代码语言:javascript复制
┌────────gmt─┬─D2─┬─D3─┬─uv─────────┐
│ 2017-07-10 │ a  │ 1  │ ???????  │
│ 2017-07-10 │ b  │ 2  │ ???????  │
│ 2017-07-10 │ b  │ 3  │ ???????  │
│ 2017-07-10 │ c  │ 1  │ ???????  │
│ 2017-07-10 │ c  │ 2  │ ???????  │
└────────────┴────┴────┴────────────┘

我们要查 D2 的 uv ,可以这样:

代码语言:javascript复制
select D2, uniqMerge(uv) from t_view group by D2 order by D2;

输出:

代码语言:javascript复制
┌─D2─┬─uniqMerge(uv)─┐
│ a  │             2 │
│ b  │             3 │
│ c  │             2 │
└────┴───────────────┘

酷吧。t_view 中的 uv 列保存的是源表中 D1 列的聚合状态,对于 uniq 的实现,简单地,状态中可以记录已经找到的 row_id ,已经有的参数值的集合,这里参数是 D1,还有当前结果值,这样,下次查的时候,就可以从 row_id 开始去扫源表,并把结果拿到集合验证,并决定是否更新结果。效率上比全表再扫一次高得多了。

说得更细一点,原始数据:

t_view 的数据大概会像这个样子:

这样,源表中后面有新的数据进去,更新 t_view 的效率是很高的了。

再考虑从 t_view 中只取子维度的情况,比如前面的只取 D2 维度的结果,对于 uniq 来说就更简单了, D2 的值对应的 uv 状态中,集合做并集就可以得到正确结果了。比如取 b 的 uniq ,就是 {甲,丙} 并 {乙} 结果为 3 。

可以看出,这种方式,对于不同的聚合函数处理上是会有不同,但是即使是对 uv 这类算是最麻烦的聚合计算, uniqState 也处理得很好。

CollapsingMergeTree

这个引擎,是专门为 OLAP 场景下,一种“变通”存数做法而设计的,要搞明白它,以及什么场景下用它,为什么用它,需要先行了解一些背景。

首先,在 clickhouse 中,数据是不能改,更不能删的,其实在好多数仓的基础设施中都是这样。前面为了数据的“删除”,还专门有一个 ReplacingMergeTree 引擎嘛。在这个条件之下,想要处理“终态”类的数据,比如大部分的状态数据都是这类,就有些麻烦了。

试想,假设每隔 10 秒时间,你都能获取到一个当前在线人数的数据,把这些数据一条一条存下,大概就是这样:

现在问你,“当前有多少人在线?”,这么简单的问题,怎么回答?

在这种存数机制下,“当前在线人数”显然是不能把 在线人数 这一列聚合起来取数的嘛。也许,能想到的是,“取最大的时间”的那一行,即先 order by 再 limit 1 ,这个办法,在这种简单场景下,好像可行。那我们再把维度加一点:

这时,如果想看每个频道的当前在线人数,查询就不像之前那么好写了,硬上的话,你可能需要套子查询。好了,我们目的不是讨论 SQL 语句怎么写。

回到开始的数据:

如果我们的数据,是在关心一个最终的状态,或者说最新的状态的话,考虑在业务型数据库中的作法,我们会不断地更新确定的一条数据, OLAP 环境我们不能改数据,但是,我们可以通过“运算”的方式,去抹掉旧数据的影响,把旧数据“减”去即可,比如:

当我们在添加 20 时间点的数据前,首先把之前一条数据“减”去,以这种“以加代删”的增量方式,达到保存最新状态的目的。

当然,起初的数据存储,我们可以以 1 和 -1 表示符号,以前面两个维度的数据的情况来看(我们把 “时间,频道” 作为主键):

如果想看每个频道的当前在线人数:

代码语言:javascript复制
select name, sum(point * sign) from t group by name;

就可以得到正确结果了:

代码语言:javascript复制
┌─name─┬─sum(multiply(point, sign))─┐
│ b    │                        181 │
│ c    │                         31 │
│ a    │                        101 │
└──────┴────────────────────────────┘

神奇。考虑数据可能有错误的情况(-1 和 1 不匹配),我们可以添加一个 having 来把错误的数据过滤掉,比如再多一条类似这样的数据:

代码语言:javascript复制
insert into t (sign, gmt, name, point) values (-1, '2017-07-11', 'd', 10),

再按原来的 SQL 查,结果是:

代码语言:javascript复制
┌─name─┬─sum(multiply(point, sign))─┐
│ b    │                        181 │
│ c    │                         31 │
│ d    │                        -10 │
│ a    │                        101 │
└──────┴────────────────────────────┘

加一个 having :

代码语言:javascript复制
select name, sum(point * sign) from t group by name having sum(sign) > 0;

就可以得到正确的数据了:

代码语言:javascript复制
┌─name─┬─sum(multiply(point, sign))─┐
│ b    │                        181 │
│ c    │                         31 │
│ a    │                        101 │
└──────┴────────────────────────────┘

这种增量方式更大的好处,是它与指标本身的性质无关的,不管是否是可加指标,或者是像 UV 这种的去重指标,都可以处理。

相较于其它一些变通的处理方式,比如对于可加指标,我们可以通过“差值”存储,来使最后的 sum 聚合正确工作,但是对于不可加指标就无能为力了。

上面的东西如果都明白了,我们也就很容易理解 CollapsingMergeTree 引擎的作用了。

“以加代删”的增量存储方式,带来了聚合计算方便的好处,代价却是存储空间的翻倍,并且,对于只关心最新状态的场景,中间数据都是无用的。CollapsingMergeTree 引擎的作用,就是针对主键,来帮你维护这些数据,它会在 merge 期,把中间数据删除掉。

前面的数据,如果我们存在 MergeTree 引擎的表中,那么通过 select * from t 查出来是:

代码语言:javascript复制
┌─sign─┬────────gmt─┬─name─┬─point─┐
│    1 │ 2017-07-10 │ a    │   123 │
│   -1 │ 2017-07-10 │ a    │   123 │
│    1 │ 2017-07-10 │ b    │    29 │
│   -1 │ 2017-07-10 │ b    │    29 │
│    1 │ 2017-07-10 │ c    │   290 │
│   -1 │ 2017-07-10 │ c    │   290 │
│    1 │ 2017-07-11 │ a    │   101 │
│    1 │ 2017-07-11 │ b    │   181 │
│    1 │ 2017-07-11 │ c    │    31 │
│   -1 │ 2017-07-11 │ d    │    10 │
└──────┴────────────┴──────┴───────┘

如果换作 CollapsingMergeTree ,那么直接就是:

代码语言:javascript复制
┌─sign─┬────────gmt─┬─name─┬─point─┐
│    1 │ 2017-07-11 │ a    │   101 │
│    1 │ 2017-07-11 │ b    │   181 │
│    1 │ 2017-07-11 │ c    │    31 │
│   -1 │ 2017-07-11 │ d    │    10 │
└──────┴────────────┴──────┴───────┘

CollapsingMergeTree 在创建时与 MergeTree 基本一样,除了最后多了一个参数,需要指定 Sign 位(必须是 Int8 类型):

代码语言:javascript复制
create table t(sign Int8, gmt Date, name String, point UInt16) ENGINE=CollapsingMergeTree(gmt, (gmt, name), 8192, sign);

讲明白了 CollapsingMergeTree 可能有人会问,如果只是要“最新状态”,用 ReplacingMergeTree 不就好了么?

这里,即使不论对“日期维度”的特殊处理( ReplacingMergeTree 不会对日期维度做特殊处理,但是 CollapsingMergeTree 看起来是最会保留最新的),更重要的,是要搞明白, 我们面对的数据的形态,不一定是 merge 操作后的“完美”形态,也可能是没有 merge 的中间形态,所以,即使你知道最后的结果对于每个主键只有一条数据,那也只是 merge 操作后的结果,你查数据时,聚合函数还是得用的,当你查询那一刻,可能还有很多数据没有做 merge 呢。

明白了一点,不难了解,对于 ReplacingMergeTree 来说,在这个场景下跟 MergeTree 其实没有太多区别的,如果不要 sign ,那么结果就是日期维度在那里,你仍然不能以通用方式聚合到最新状态数据。如果要 sign ,当它是主键的一部分时,结果就跟 MergeTree 一样了,多存很多数据。而当它不是主键的一部分,那旧的 sign 会丢失,就跟没有 sign 的 MergeTree 一样,不能以通用方式聚合到最新状态数据。结论就是, ReplacingMergeTree 的应用场景本来就跟 CollapsingMergeTree 是两回事。

ReplacingMergeTree 的应用,大概都是一些 order by limit 1 这种。而 CollapsingMergeTree 则真的是 group by 了。

0 人点赞