ClickHouse和他的朋友们(15)Group By 为什么这么快

Stella981
• 阅读 451

在揭秘 ClickHouse Group By 之前,先聊聊数据库的性能对比测试问题。在虎哥看来,一个“讲武德”的性能对比测试应该提供什么信息呢?

  • 首先要尊重客观事实,在什么场景下,x 比 y 快?

  • 其次是为什么 x 会比 y 快?

如果以上两条都做到了,还有一点也比较重要:x 的优势可以支撑多久?是架构等带来的长期优势,还是一袋烟的优化所得,是否能持续跟上自己的灵魂。如果只是贴几个妖艳的数字,算不上是 benchmark,而是 benchmarket。

好了,回到 Group By 正题。

相信很多同学已经体验到 ClickHouse Group By 的出色性能,本篇就来分析下快的原因。首先安慰一下,ClickHouse 的 Group By 并没有使用高大上的黑科技,只是摸索了一条相对较优的方案。

一条 SQL

SELECT sum(number) FROM numbers(10) GROUP BY number % 3

我们就以这条简单的 SQL 作为线索,看看 ClickHouse 怎么实现 Group By 聚合。

1. 生成 AST

EXPLAIN ASTSELECT sum(number)FROM numbers(10)GROUP BY number % 3┌─explain─────────────────────────────────────┐│ SelectWithUnionQuery (children 1)           ││  ExpressionList (children 1)                ││   SelectQuery (children 3)                  ││    ExpressionList (children 1)              ││     Function sum (children 1)               │  // sum 聚合│      ExpressionList (children 1)            ││       Identifier number                     ││    TablesInSelectQuery (children 1)         ││     TablesInSelectQueryElement (children 1) ││      TableExpression (children 1)           ││       Function numbers (children 1)         ││        ExpressionList (children 1)          ││         Literal UInt64_10                   ││    ExpressionList (children 1)              ││     Function modulo (children 1)            │  // number % 3 函数│      ExpressionList (children 2)            ││       Identifier number                     ││       Literal UInt64_3                      │└─────────────────────────────────────────────┘

2. 生成 Query Plan

EXPLAINSELECT sum(number)FROM numbers(10)GROUP BY number % 3┌─explain───────────────────────────────────────────────────────────────────────┐│ Expression ((Projection + Before ORDER BY))                                   │ │   Aggregating                                                                 │ // sum 聚合│     Expression (Before GROUP BY)                                              │ // number % 3│       SettingQuotaAndLimits (Set limits and quota after reading from storage) ││         ReadFromStorage (SystemNumbers)                                       │└───────────────────────────────────────────────────────────────────────────────┘

代码主要在 InterpreterSelectQuery::executeImpl@Interpreters/InterpreterSelectQuery.cpp (https://github.com/ClickHouse/ClickHouse/blob/27ddf78ba572b893cb5351541f566d1080d8a9c6/src/Interpreters/InterpreterSelectQuery.cpp#L1063)

3. 生成 Pipeline

EXPLAIN PIPELINESELECT sum(number)FROM numbers(10)GROUP BY number % 3┌─explain───────────────────────┐│ (Expression)                  ││ ExpressionTransform           ││   (Aggregating)               ││   AggregatingTransform        │  // sum 计算│     (Expression)              ││     ExpressionTransform       │  // number % 3 计算│       (SettingQuotaAndLimits) ││         (ReadFromStorage)     │└───────────────────────────────┘

4. 执行 Pipeline

Pipeline 是从底部往上逐一执行。

4.1 ReadFromStorage

首先从 ReadFromStorage 执行,生成一个 block1, 数据如下:

┌─number─┐│      0 ││      1 ││      2 ││      3 ││      4 ││      5 ││      6 ││      7 ││      8 ││      9 │└────────┘number类型为 UInt64

4.2 ExpressionTransform

ExpressionTransform 包含了 2 个 action:

  1. 名字为 number,type 为 INPUT

  2. 名字为 modulo(number, 3), type 为 FUNCTION

经过 ExpressionTransform 运行处理后生成一个新的 block2, 数据如下:

┌─number─┬─modulo(number, 3)─┐│      0 │                 0 ││      1 │                 1 ││      2 │                 2 ││      3 │                 0 ││      4 │                 1 ││      5 │                 2 ││      6 │                 0 ││      7 │                 1 ││      8 │                 2 ││      9 │                 0 │└────────┴───────────────────┘number 类型为 UInt64modulo(number, 3) 类型为 UInt8

代码主要在 ExpressionActions::execute@Interpreters/ExpressionActions.cpp (https://github.com/ClickHouse/ClickHouse/blob/27ddf78ba572b893cb5351541f566d1080d8a9c6/src/Interpreters/ExpressionActions.cpp#L416)

4.3 AggregatingTransform

AggregatingTransform 是 Group By 高性能的核心所在。

本示例中的 modulo(number, 3) 类型为 UInt8,在做优化上,ClickHouse 会选择使用数组代替 hashtable作为分组,区分逻辑见 Interpreters/Aggregator.cpp (https://github.com/ClickHouse/ClickHouse/blob/27ddf78ba572b893cb5351541f566d1080d8a9c6/src/Interpreters/Aggregator.cpp#L526)

在计算 sum 的时候,首先会生成一个数组 [1024],然后做了一个编译展开(代码 addBatchLookupTable8@AggregateFunctions/IAggregateFunction.h) (https://github.com/ClickHouse/ClickHouse/blob/27ddf78ba572b893cb5351541f566d1080d8a9c6/src/AggregateFunctions/IAggregateFunction.h#L412-L487):

        static constexpr size_t UNROLL_COUNT = 4;        std::unique_ptr<Data[]> places{new Data[256 * UNROLL_COUNT]};        bool has_data[256 * UNROLL_COUNT]{}; /// Separate flags array to avoid heavy initialization.        size_t i = 0;        /// Aggregate data into different lookup tables.        size_t batch_size_unrolled = batch_size / UNROLL_COUNT * UNROLL_COUNT;        for (; i < batch_size_unrolled; i += UNROLL_COUNT)        {            for (size_t j = 0; j < UNROLL_COUNT; ++j)            {                size_t idx = j * 256 + key[i + j];                if (unlikely(!has_data[idx]))                {                    new (&places[idx]) Data;                    has_data[idx] = true;                }                func.add(reinterpret_cast<char *>(&places[idx]), columns, i + j, nullptr);            }        }

sum(number) ... GROUP BY number % 3 计算方式:

array[0] = 0 + 3 + 6 + 9 = 18array[1] = 1 + 4 + 7 = 12array[2] = 2 + 5 + 8 = 15

这里只是针对 UInt8 做的一个优化分支,那么对于其他类型怎么优化处理呢? 

ClickHouse 针对不同的类型分别提供了不同的 hashtable,声势比较浩大(代码见 Aggregator.h) (https://github.com/ClickHouse/ClickHouse/blob/27ddf78ba572b893cb5351541f566d1080d8a9c6/src/Interpreters/Aggregator.h#L68-L103):

using AggregatedDataWithUInt8Key = FixedImplicitZeroHashMapWithCalculatedSize<UInt8, AggregateDataPtr>;using AggregatedDataWithUInt16Key = FixedImplicitZeroHashMap<UInt16, AggregateDataPtr>;using AggregatedDataWithUInt32Key = HashMap<UInt32, AggregateDataPtr, HashCRC32<UInt32>>;using AggregatedDataWithUInt64Key = HashMap<UInt64, AggregateDataPtr, HashCRC32<UInt64>>;using AggregatedDataWithShortStringKey = StringHashMap<AggregateDataPtr>;using AggregatedDataWithStringKey = HashMapWithSavedHash<StringRef, AggregateDataPtr>;using AggregatedDataWithKeys128 = HashMap<UInt128, AggregateDataPtr, UInt128HashCRC32>;using AggregatedDataWithKeys256 = HashMap<DummyUInt256, AggregateDataPtr, UInt256HashCRC32>;using AggregatedDataWithUInt32KeyTwoLevel = TwoLevelHashMap<UInt32, AggregateDataPtr, HashCRC32<UInt32>>;using AggregatedDataWithUInt64KeyTwoLevel = TwoLevelHashMap<UInt64, AggregateDataPtr, HashCRC32<UInt64>>;using AggregatedDataWithShortStringKeyTwoLevel = TwoLevelStringHashMap<AggregateDataPtr>;using AggregatedDataWithStringKeyTwoLevel = TwoLevelHashMapWithSavedHash<StringRef, AggregateDataPtr>;using AggregatedDataWithKeys128TwoLevel = TwoLevelHashMap<UInt128, AggregateDataPtr, UInt128HashCRC32>;using AggregatedDataWithKeys256TwoLevel = TwoLevelHashMap<DummyUInt256, AggregateDataPtr, UInt256HashCRC32>;using AggregatedDataWithUInt64KeyHash64 = HashMap<UInt64, AggregateDataPtr, DefaultHash<UInt64>>;using AggregatedDataWithStringKeyHash64 = HashMapWithSavedHash<StringRef, AggregateDataPtr, StringRefHash64>;using AggregatedDataWithKeys128Hash64 = HashMap<UInt128, AggregateDataPtr, UInt128Hash>;using AggregatedDataWithKeys256Hash64 = HashMap<DummyUInt256, AggregateDataPtr, UInt256Hash>;

如果我们改成 GROUP BY number*100000 后,它会选择 AggregatedDataWithUInt64Key 的 hashtable 作为分组。

而且 ClickHouse 提供了一种 Two Level 方式,用于应对有大量分组 key 的情况,Level1 先分大组,Level2 小组可以并行计算。

针对 String 类型,根据不同的长度,hashtable 也做了很多优化,代码见 HashTable/StringHashMap.h (https://github.com/ClickHouse/ClickHouse/blob/27ddf78ba572b893cb5351541f566d1080d8a9c6/src/Common/HashTable/StringHashMap.h#L78-L82)

总结

ClickHouse 会根据 Group By 的最终类型,选择一个最优的 hashtable 或数组,作为分组基础数据结构,使内存和计算尽量最优。

这个”最优解“是怎么找到的?从 test 代码可以看出,是不停的尝试、测试验证出来的,浓厚的 bottom-up 哲学范。

全文完。

Enjoy ClickHouse :)

本文分享自微信公众号 - 老叶茶馆(iMySQL_WX)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

点赞
收藏
评论区
推荐文章
Jacquelyn38 Jacquelyn38
3年前
Vue3.0系列——「vue3.0性能是如何变快的?」
前言1.先学习vue2.x,很多2.x内容依然保留;2.先学习TypeScript,vue3.0是用TS重写的,想知其然知其所以然必须学习TS。为什么学习vue3.0?性能比vue2.x快1.2~2倍按需编译,体积比vue2.x更小组合API(类似ReactHook)更好的TS支持
redis为什么速度这么快?
一、原因分析1.redis在处理请求的时候都是纯内存操作;2.redis处理命令使用了单线程,减少了线程切换的开销;3.redis内部实现使用了非阻塞的IO多路复用;4.redis主要基于c语言实现,底层针对不同的数据类型做了不少优化。
Stella981 Stella981
3年前
Python OpenCV实例:图像灰度拉伸
coding:utf8'''灰度拉伸定义:灰度拉伸,也称对比度拉伸,是一种简单的线性点运算。作用:扩展图像的直方图,使其充满整个灰度等级范围内公式:g(x,y)255/(BA)f(x,y)A,其中,Aminf(x,y),最小
Stella981 Stella981
3年前
Redis 为什么这么快?(9)
redisbenchmarktset,lpushn100000qSET:38550.50requestspersecond//每秒处理3.8万多次的set请求LPUSH:37821.48requestspersecond//每秒处理3.7万多次lpush请求
Stella981 Stella981
3年前
Redis为什么这么快
Redis简介Redis是一个开源的内存中的数据结构存储系统,它可以用作:数据库、缓存和消息中间件它支持多种类型的数据结构,如字符串(String),散列(Hash),列表(List),集合(Set),有序集合(SortedSet或者是ZSet)与范围查询,Bitmaps,Hyperloglogs和
Stella981 Stella981
3年前
Hprose 和 Yar 的性能比较
之前总有人问我Hprose快,还是Yar快。这个问题我之前的回答都是,我没有做过测试,但我觉得Yar应该更快一些,毕竟他是鸟哥完全用纯C实现的。但这个答案好像并不能让大多数人满意。所以在被多人多次询问之后,昨晚我终于没忍住测试了一下,但是结果所反映出的并不是Hprose快,还是Yar快的问题。测试结果所能确定的问题只有一个,那就是在
Stella981 Stella981
3年前
2021最新版阿里巴巴Java性能调优速成手册强烈推荐
为什么要做性能调优?一款线上产品如果没有经过性能测试,那它就好比是一颗定时炸弹,你不知道它什么时候会出现问题,你也不清楚它能承受的极限在哪儿。所以,要不要做性能调优,这个问题其实很好回答。所有的系统在开发完之后,多多少少都会有性能问题,我们首先要做的就是想办法把问题暴露出来,例如进行压力测试、模拟可能的操作场景等等,再通过性能调
可莉 可莉
3年前
2021最新版阿里巴巴Java性能调优速成手册强烈推荐
为什么要做性能调优?一款线上产品如果没有经过性能测试,那它就好比是一颗定时炸弹,你不知道它什么时候会出现问题,你也不清楚它能承受的极限在哪儿。所以,要不要做性能调优,这个问题其实很好回答。所有的系统在开发完之后,多多少少都会有性能问题,我们首先要做的就是想办法把问题暴露出来,例如进行压力测试、模拟可能的操作场景等等,再通过性能调
京东云开发者 京东云开发者
10个月前
spark为什么比mapreduce快?
spark为什么比mapreduce快?首先澄清几个误区:1:两者都是基于内存计算的,任何计算框架都肯定是基于内存的,所以网上说的spark是基于内存计算所以快,显然是错误的2;DAG计算模型减少的是磁盘I/O次数(相比于mapreduce计算模型而言),
京东云开发者 京东云开发者
3个月前
spark为什么比mapreduce快?
spark为什么比mapreduce快?首先澄清几个误区:1:两者都是基于内存计算的,任何计算框架都肯定是基于内存的,所以网上说的spark是基于内存计算所以快,显然是错误的2;DAG计算模型减少的是磁盘I/O次数(相比于mapreduce计算模型而言),