暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

并行加速 ClickHouse 中固定哈希表的聚合合并

ClickHouseInc 2026-01-14
89


本文字数:5413;估计阅读时间:14 分钟

作者:Jianfei Hu


编者按 

在 ClickHouse 25.11 中,我们引入了针对小型 GROUP BY 的并行合并功能。这项优化通过并行化基于 FixedHashMap 的聚合操作中的合并阶段,大幅提升了对 8 位和 16 位键的聚合性能。该特性已在 25.11 的版本更新中简要介绍。

以下内容是实现这一优化的工程师 Jianfei Huhttps://github.com/incfly 撰写的技术原文。Jianfei 在解决问题的过程中,以个人笔记的形式记录下了这个过程,内容涉及多次尝试、ClickHouse 聚合机制的底层实现,以及一些微妙的并发与内存管理问题。

我们几乎未作修改地发布这篇文章,因为它展现了发布说明中无法体现的另一面:

构建这项优化的真实体验,以及过程中带来的启发。


背景 

我最近在处理这个 PR:https://github.com/ClickHouse/ClickHouse/pull/87366。这个点子本身很简单,但在实现过程中我深入了解了 ClickHouse 的聚合机制,因此希望将所学记录下来。

最初的问题陈述很明确https://github.com/ClickHouse/ClickHouse/issues/63666:在运行几乎相同的查询时,性能却出现了巨大差异!

    SELECT
        number % 10000 AS k,
        uniq(number) AS u
    FROM numbers_mt(1000000000.)
    GROUP BY k
    ORDER BY u DESC
    LIMIT 10
         ┌────k─┬──────u─┐
       1. │ 4759 │ 101196 │
       2. │ 4587 │ 101079 │
       3. │ 6178 │ 101034 │
       4. │ 6567 │ 101032 │
       5. │ 9463 │ 101013 │
       6. │  298 │ 101009 │
       7. │ 2049 │ 100993 │
       8. │ 8167 │ 100989 │
       9. │ 5530 │ 100973 │
      10. │ 1968 │ 100973 │
          └──────┴────────┘
      10 rows in set. Elapsed: 62.793 sec. Processed 1.00 billion rows, 8.00 GB (15.93 million rows/s., 127.40 MB/s.)
      Peak memory usage: 11.30 GiB.
        SELECT
            0 + (number % 10000) AS k,
            uniq(number) AS u
        FROM numbers_mt(1000000000.)
        GROUP BY k
        ORDER BY u DESC
        LIMIT 10
             ┌────k─┬──────u─┐
           1. │ 4759 │ 101196 │
           2. │ 4587 │ 101079 │
           3. │ 6178 │ 101034 │
           4. │ 6567 │ 101032 │
           5. │ 9463 │ 101013 │
           6. │  298 │ 101009 │
           7. │ 2049 │ 100993 │
           8. │ 8167 │ 100989 │
           9. │ 5530 │ 100973 │
          10. │ 1968 │ 100973 │
              └──────┴────────┘
          10 rows in set. Elapsed: 8.547 sec. Processed 1.00 billion rows, 8.00 GB (116.99 million rows/s., 935.95 MB/s.)
          Peak memory usage: 10.09 GiB.
          1. 唯一的区别在于,第二个查询使用了 0 + (number % 10000) 作为 GROUP BY 的键 k。

          2. ClickHouse 会将第一个查询中的 k 识别为 UInt16,而第二个则是 UInt64。

          但这为什么会有影响?我们不妨深入探讨一下 ClickHouse 聚合的技术细节。


          ClickHouse 的聚合原理 

          当按小于 UInt16 的数值进行分组时,可以直接使用数组来实现哈希表:

          否则就会使用标准哈希表,并在需要时转换为两级哈希表:

          那这对合并聚合状态意味着什么?

          现在就能理解为什么第一个查询会比较慢。

          • 当每个线程各自维护一个两级哈希表时,合并操作是可以并行进行的:例如线程 T1 处理第 0 到 7 个桶,T2 处理第 8 到 15 个,依此类推。

          • 但如果使用的是固定哈希表(FixedHashMap),所有的聚合状态都会被存储在一个一维数组中。这种基于桶划分的并行合并策略就无法适用了。


          优化思路的演进
          • 最初的思路是将一维数组结构转换为两级哈希表,但实践中发现,这种方式的性能并不理想,甚至更慢。

          • 后来 Nikita T. 提出了一种新的方案:让每个合并线程原地处理各自不重叠的 group by 键子集。这样既不存在线程间竞争,也无需做额外的结构转换。

          • 我据此实现了该方案,但过程中仍有不少新的知识需要学习。


          基于范围的分段策略并不奏效

          一开始,我直观地想到可以按 key 的数值范围进行划分,将不同段的 key 分配给不同线程处理:

          这种方式的确带来了些许性能提升,但效果非常有限。从火焰图(Flamegraph)上几乎看不出任何差异。原因在于:虽然因为并行处理 wall clock 时间缩短了,但 CPU 层面每条调用栈的总时间并没有减少,所以在堆栈追踪中并不能体现性能差异。这个问题是我通过记录日志并结合线程 ID 分析耗时才发现的。

          在确认这一点后,我决定采用另一种方式重新划分合并任务:


          遇到内存损坏的诡异问题

          在某次 CI 测试中,出现了因内存释放错误而导致的失败,一些内存大小校验的断言没有通过。

            2025.09.22 01:04:58.132587 [ 906517 ] {} <FatalBaseDaemon10. home/incfly/workspace/github.com/ClickHouse/ClickHouse/src/Common/Exception.h:58DB::Exception::Exception(PreformattedMessage&&, int) @ 0x000000000b549785
            2025.09.22 01:04:58.243209 [ 906517 ] {} <FatalBaseDaemon11. home/incfly/workspace/github.com/ClickHouse/ClickHouse/src/Common/Exception.h:141DB::Exception::Exception<unsigned long&>(int, FormatStringHelperImpl<std::type_identity<unsigned long&>::type>, unsigned long&) @ 0x000000000bce6cab
            2025.09.22 01:04:58.248715 [ 906517 ] {} <FatalBaseDaemon12.0. inlined from home/incfly/workspace/github.com/ClickHouse/ClickHouse/src/Common/Allocator.cpp:119: (anonymous namespace)::checkSize(unsigned long)
            2025.09.22 01:04:58.248738 [ 906517 ] {} <FatalBaseDaemon12. home/incfly/workspace/github.com/ClickHouse/ClickHouse/src/Common/Allocator.cpp:144Allocator<falsefalse>::free(void*, unsigned long) @ 0x000000001272c82e
            2025.09.22 01:04:58.265559 [ 906517 ] {} <FatalBaseDaemon13. home/incfly/workspace/github.com/ClickHouse/ClickHouse/src/Common/Arena.h:94DB::Arena::MemoryChunk::~MemoryChunk() @ 0x000000000c8858a2
            2025.09.22 01:04:58.281901 [ 906517 ] {} <FatalBaseDaemon14.0. inlined from home/incfly/workspace/github.com/ClickHouse/ClickHouse/contrib/llvm-project/libcxx/include/__memory/unique_ptr.h:80: std::default_delete<DB::Arena::MemoryChunk>::operator()[abi:se190107](DB::Arena::MemoryChunk*) const
            2025.09.22 01:04:58.281922 [ 906517 ] {} <FatalBaseDaemon14.1. inlined from home/incfly/workspace/github.com/ClickHouse/ClickHouse/contrib/llvm-project/libcxx/include/__memory/unique_ptr.h:292: std::unique_ptr<DB::Arena::MemoryChunk, std::default_delete<DB::Arena::MemoryChunk>>::reset[abi:se190107](DB::Arena::MemoryChunk*)

            我一开始完全无法理解为什么我的改动会引发内存管理问题。仔细阅读代码后,我发现 DB::Arena 是一种非常独特的内存管理机制。

            设想在一次查询执行过程中,我们需要临时创建大量小尺寸的字符串(例如聚合状态)。这些字符串生命周期短暂,可以被统一销毁。传统做法是为它们分配一块大内存,并用链表记录各个内存块的分配与释放情况:

            而 DB::Arena 的做法则更为简单高效:它只维护一个偏移量变量,指向下一个可用内存地址。每次分配 M 字节的内存时,Arena 直接返回当前偏移量作为地址,并将偏移量加上 M:

            这种方式非常高效:无需查找空位,也不支持单个内存块的释放。但这对本场景而言正合适,因为所有对象最终会一起释放,只需销毁整个 Arena 区域即可。

            回到我遇到的问题,我注意到一些相关的错误现象:

            • 在执行大量聚合时,有时会触发段错误,堆栈追踪显示问题来自 Arena 的相关代码;

            • 部分查询结果也出现了错误。

            这些问题的根源最终被定位为内存分配中的竞争条件。进一步检查后我意识到,Arena 本身并不是线程安全的,而现有的两级聚合机制在合并过程中是每个线程各自使用一个 Arena。我当时错误地让多个线程共享了同一个 Arena。修复这一用法错误后,问题也随之消失。


            简单聚合函数引发的性能下降

            我注意到,如果将前面示例查询中的聚合函数替换为一些非常简单的函数,例如 count、sum、min 或 max,不仅没有带来性能提升,反而出现了运行变慢的情况。我的初步判断是:由于这些聚合函数本身计算量极小,使用并行合并反而引入了额外的开销,得不偿失。因此,我决定在这类场景中默认禁用该优化。

            不过,评审者提醒我们需要进一步理解导致性能下降的具体原因:

            1. 在 FixedHashMap 中,通常会记录最小值和最大值的索引,以加速迭代和其他操作。但为了避免线程间的竞争条件,我们在并行合并场景中禁用了这一特性。

            2. 结果是,我们必须遍历整个哈希数组,而不是仅仅遍历那些实际被填充的数据项。举个例子,如果 group by 使用的是 k % 100,数据分布是稀疏的,迭代整个数组就变得非常低效。

            3. 观察发现,所有性能下降的查询,其执行时间几乎一致地增加了 3 毫秒左右。

            解决方案是:在进入并行合并之前,提前提取最小和最大索引,用于限制后续的迭代范围,从而减少无效扫描。


            其他技术细节

            ClickHouse 的 CI 性能测试工具会生成差异火焰图(differential flame graph),帮助我们识别某个改动是否引入了性能回退。如下例所示:

            在 FixedHashMap.h 的第 123 行,Aggregator::mergeDataImpl 被检测出性能略有上升,定位到了 isZero 函数。但令人疑惑的是,它仍然被标注为属于 Aggregator 函数。原因可能在于编译器在进行函数内联时,虽然函数逻辑已经融合到了另一个函数中,但仍然保留了其原始定义文件的信息。

            我仍未完全理解为何新实现的 mergeSingleLevelDataImplFixedMap 等函数,在火焰图中显示为“白色”区域。这可能是火焰图差异计算过程中使用的一些特殊处理策略所致。


            /END/


            试用阿里云 ClickHouse企业版


            轻松节省30%云资源成本?阿里云数据库ClickHouse 云原生架构全新升级,首次购买ClickHouse企业版计算和存储资源组合,首月消费不超过99.58元(包含最大16CCU+450G OSS用量)了解详情:https://t.aliyun.com/Kz5Z0q9G



            征稿启示

            面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:Tracy.Wang@clickhouse.com

            文章转载自ClickHouseInc,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

            评论