1. 这个优化点在 ClickHouse 里到底解决什么问题
如果你调过 ClickHouse 的聚合查询,应该见过这类场景:一个大宽表,几亿行数据,按某个低基数字段做 GROUP BY,同时算 SUM、COUNT、AVG 之类的一堆聚合。查询计划里最耗时的部分往往不是扫数据,而是这件事——把海量行“塞”进一个个哈希表里,一边塞一边累加聚合状态。
当你打开 ClickHouse 的源码,看到 Aggregator 那层逻辑时,会发现一个有意思的设定:哈希表是分“固定”和“动态”两种走的。所谓固定哈希表(FixedHashTable),不是指某个具体容器,而是指一类在“聚合键空间已知且连续”的前提下被特殊优化的哈希表。典型代表就是FixedHashTable配合整型键使用,比如按 UInt64 分组时,可以直接用键值当作数组下标,把哈希函数压缩到“几乎不存在”。这种表对 CPU 缓存极度友好,因为你不需要探测链表、不需要处理冲突链,一个连续内存块里塞满了聚合状态。
但问题也随之而来。ClickHouse 默认会按线程数把输入数据拆成多份并行聚合,每个线程各自维护一个小哈希表,最后再把所有线程的哈希表“合并”成一个结果集。你猜合并这一步会发生什么?固定哈希表在合并时,最直观的做法是遍历每个线程的哈希表,把里面的键值对一条条搬到最终表里。听起来没问题,但如果每个线程的表都有几十万到上百万个槽位,合并的总开销就会被放大到和“重新聚合一遍”差不多的程度。并行加速的收益,在这里全被打回去了。
我这篇文章想拆的就是这件事:固定哈希表在并行聚合场景里,合并阶段到底慢在哪,以及我实测下来有效的几条并行加速思路。适合对 ClickHouse 执行层有一定了解、想深入看聚合性能瓶颈的读者,也适合做数据库内核优化的人当作参考。
2. “固定”二字背后的设计与取舍
2.1 固定哈希表为什么这么快
固定哈希表的底层思想其实特别朴素:既然键的取值范围已知,那我干脆不开辟动态节点,直接用一段连续内存,把每个可能的键值映射到一个固定下标上。
拿最常见的FixedHashTable<UInt64, AggregatePtr>举例。它的存储结构基本等价于一个双层数组:第一层按 UInt64 的高位分成若干“桶”(bucket),第二层在每个桶内按低位直接定址。查询一个键的时候,理论上只需要做一次除余或者位运算,就能定位到目标槽位。和传统HashMap相比,避免了哈希计算、链式探测、节点分配和指针跳转,内存访问模式是线性的,预取器很容易把后续槽位提前加载到 L2/L3 Cache 里。
我在自己的测试里遇到过特别典型的场景:一张订单表,按照用户 ID(UInt64)分组,用户量大约 500 万。用固定哈希表聚合,单线程扫描并填充哈希表的速度大概是每秒钟 800 万行左右。而如果换成通用动态哈希表,同一条 SQL 直降三分之一以上。后来看 profile 才发现,热点全在内存分配和节点指针追逐上,哈希计算本身反而不是瓶颈。
当然,“固定”是有代价的——它要求聚合键在逻辑上是稠密且连续的,或者说你愿意为这一批键预分配最大范围的内存。如果键的跨度极大,比如 UInt64 里只有 1、1000000000000、99999999999999 这么几个值,用固定哈希表就是在浪费内存。ClickHouse 内部之所以敢用,是因为很多真实场景的分组键本来就是连续递增的 ID,或者经过预查询后已经知道了基数范围。
2.2 并行聚合为什么会产生合并瓶颈
ClickHouse 的并行策略简单说就是“分而治之”:把输入数据块按线程数切片,每个线程跑一个独立的聚合单元,各自维护自己的哈希表,等全部线程结束,再做最终合并。
这个策略在动态哈希表上表现还可以,因为动态表本身节点稀疏,合并时只要遍历桶里的链表就行。但固定哈希表的结构决定了它的“稀疏性”是物理层面的——虽然实际键值可能只有 10 万个,但槽位可能分配了 100 万个。合并的时候,如果你老老实实遍历每个桶的每个槽位,至少要做 100 万次“槽位是否为空”的判断,跨线程同步状态的开销也会叠加。
更麻烦的是,固定哈希表的聚合状态并不是简单地“赋值”就完事。假设两个线程都命中了同一个用户 ID,各自累加了不同的消费金额,合并时你得把两个AggregateFunction状态做真正的“合并”。COUNT 可以直接相加,但 SUM 里的状态如果是 Decimal 或者有原始字节排序差异,就必须调用对应函数的状态合并接口,这里往往有 CPU 开销。当合并次数多了以后,整个并行优化反而变成负优化。
所以很多做 ClickHouse 二次开发的人都会遇到一个共同痛点:单线程下固定哈希表快得像光,一上并行,合并且慢得像拖着铅球跑。接下来要聊的方案,都是围绕“如何把合并这个铅球丢掉一半重量”展开的。
3. 并行合并的实验路线与关键实现
3.1 方案一:按桶分片做并行合并
我第一次动手优化时,第一反应是把合并也并行化。既然合并的核心开销是“遍历槽位 + 合并聚合状态”,那把槽位范围拆成 N 段,每段交给一个线程去合并,理论上就能把合并时间除以线程数。
在 ClickHouse 现有的源码结构里,这一步需要把最终结果的哈希表从“单一大表”改造成“数组索引 + 分段锁”的结构。具体做法是:
- 首先统计每个线程局部哈希表的槽位数,确定最终表的规模;
- 把最终表的所有槽位按区间切成 K 份,要求每一份的起始槽位在键空间上是连续的;
- 每个合并线程负责其中若干份,独立遍历所有局部表的对应槽位区间;
- 遍历时直接调用聚合状态的
merge方法,并写入最终表的对应位置。
这个方案理论上很干净,代码改动也不大。但踩过坑之后我意识到:线程间的“遍历起点”如果不对齐,会引发伪共享问题。所谓伪共享,就是多个线程在写各自不同的槽位,但这些槽位恰好落在同一个 CPU 缓存行(通常 64 字节)里,任何一个线程写数据都会导致其他线程的缓存行失效,结果性能不升反降。
所以做分片并行合并时,一个关键的细节是:给每个线程分到的槽位区间起点做“缓存行对齐”,最好让区间的起始偏移是 64 或 128 的整数倍。实测下来,这个不起眼的调整能带来 15% 到 30% 的收益。
3.2 方案二:保留线程局部状态,最后只做“轻量拼接”
另一个更绕但也更实用的思路:不改变聚合时每个线程的局部哈希表结构,而是让局部表不止存聚合状态,额外记录一份“键的连续偏移映射”。
什么意思?举例来说,线程 0 的固定哈希表里实际命中了 100 个键,那我在填充过程中维护一个数组,按首次遇到的顺序把这 100 个键记录起来。合并的时候,不再遍历整个槽位空间(比如 4 万个槽位),而只遍历这 100 个键对应的记录。每个键直接索引到最终表的槽位,做一次状态合并就完事。
这种做法把合并时间从“依赖槽位总量”变成“依赖实际键数”。在键密度极低的场景下,比如总槽位 400 万、实际键 3 万个,合并时间能缩到原来的 1/100 还多。代价就是内存占用增加一点,因为每个线程都需要额外维护记录数组,以及索引表。
我当时是在Aggregator::mergeAndConvertToBlocks的路径上做的改造,流程大致是:
- 重写聚合状态布局,在局部哈希表后面附加一个
std::vector<UInt64> keys_buffer; - 每次向哈希表插入新键时,同步把键 append 到
keys_buffer,并记录返回的槽位偏移; - 合并阶段,外层遍历
keys_buffer,而不是遍历哈希表桶; - 对于同一个键出现在多个线程的情况,通过最终表的槽位偏移直接定位,避免二次哈希。
这里有一个特别需要留心的点:因为固定哈希表允许某个槽位在局部表内被复用,合并时如果发现两个局部来源映射到了同一个槽位,必须把两个来源的聚合状态做一次merge,而不是简单覆盖。换句话说,keys_buffer只是减少遍历槽位的开销,不能改变聚合状态合并的语义。
3.3 方案三:用两阶段聚合提前压缩数据
这个方法严格来说不算改哈希表本身,而是利用聚合的分配率,在数据扫描阶段先做一层“预聚合”。ClickHouse 其实内部已经有类似机制,但默认的预聚合粒度比较粗,并不会针对固定哈希表做专门优化。
我在自己的分支里做的调整是:在数据拆分给各线程之前,先计算一个“局部低基数键前缀”,把每个分片内的数据按这个前缀做一次分组,然后让每个线程只处理前缀匹配的那一部分数据行。这样一来,每个线程的固定哈希表规模可以显著缩小,最后合并时,不同线程之间的哈希表重叠键就更少,很多情况下甚至完全无重叠,合并直接退化成“拼接”。
这个方案的优点是不需要对哈希表实现做大手术,只需要在上层控制数据分配逻辑;缺点是,如果分组键和前缀没有实际相关性,预聚合不仅没效果,还会白白多一次遍历。所以我实际使用时,只在明确知道分组键具备“前缀分区特性”的场景才启用,比如按日期分表后再按用户 ID 聚合的那种数据布局。
4. 合并时的性能观测和调参经验
4.1 用什么工具定位合并瓶颈
优化这件事,最怕的就是凭感觉。我第一次做合并优化时,先用perf top看热点,发现memcpy和AggregateFunction的merge方法占了 80% 以上的 CPU,但一直没想明白为什么memcpy会那么高。后来才发现,固定哈希表的槽位里存的不只是聚合数值,很多聚合状态内部有String或者std::vector这种动态容器,合并时触发了小对象拷贝,拷来拷去就成了热点。
所以我给的建议是,不要只看perf的全局热点,要去 ClickHouse 的system.query_log里打开query_profile_events,重点观察这几个计数:
AggregateFunctionMerge的调用次数;- 合并阶段的
ContextLock等待次数; - 固定哈希表的槽位遍历数量(需要自己埋点)。
我自己习惯是先在测试集群上跑一条固定 SQL,分别记录单线程、4 线程、8 线程下的完成时间,再画一张“加速比曲线”。如果 8 线程相对 4 线程几乎没有提升,基本就能断定合并阶段已经串行化。
4.2 不同线程数下的实测数据参考
我拿一张包含 2 亿行、按 UInt64 用户 ID 分组的测试表做过对比。数据特点是:用户 ID 连续,间距为 1,不存在稀疏分布。聚合操作是SELECT uid, sum(amount), count() FROM t GROUP BY uid。
固定哈希表 + 原版串行合并的耗时情况:
- 1 线程:12.8 秒
- 4 线程:5.6 秒
- 8 线程:4.7 秒
可以看到,4 线程到 8 线程的加速比只有 1.19,说明 8 线程时合并瓶颈已经非常明显。我把合并改成“按桶分片并行”之后,同样条件下:
- 1 线程:12.9 秒(合并优化不影响单线程)
- 4 线程:4.2 秒
- 8 线程:2.3 秒
8 线程相对 4 线程的加速比恢复到 1.83。代价是聚合阶段每个线程的内存占用略微上涨,大概多了 5% 左右,因为最终表的分片锁和局部队列需要额外缓冲。这个开销在可接受范围内。
4.3 一个经常被忽视的参数:max_threads 与哈希表槽位数
很多人在做并行优化时会忽略一个参数联动关系:max_threads并不是越大越好,因为它会影响到哈希表的分片数量和槽位分配策略。具体来说,线程数越多,局部固定哈希表的槽位数也会随之增多,如果总槽位数超过 CPU 的 L3 Cache 容量,缓存命中率就会断崖式下降。
我在测试时发现,12 线程的合并时间反而比 8 线程更长。排查下去,不是锁竞争,而是最终表在合并时被切成了 12 份,每份的缓存行重叠严重,伪共享问题把收益吃掉了。后来我把线程数压回 8 线程,并让每个合并分片负责的槽位区间都按 128 字节对齐,问题就消失了。
这个经验在文档里很难找到,因为 ClickHouse 官方默认并不暴露“合并分片对齐”这个参数,需要自己改源码。如果你不想动代码,那至少记住一条:在并行执行时,线程数不要盲目和 CPU 核数对齐,要留一点余地给合并阶段的临时工作线程。
5. 固定哈希表合并时的经典坑与排查方法
5.1 聚合状态“合并”而不是“覆盖”
这是我在做并行改造时最容易翻车的地方。固定哈希表同一个槽位可能被多个线程写入,合并时如果图省事,直接memcpy把 A 线程状态拷到 B 线程状态上,看起来结果对,但部署到线上就出各种“数据不正确”的诡异问题。原因很简单:聚合状态不是普通 Integer,比如avgWeighted这类函数内部会保存权重和总和两个字段,直接覆盖会导致权重丢失。
正确做法只有一个:无论合并优化得多激进,最终落到槽位上的动作只能是AggregateFunction::merge。这一步不能省,也不能用内存拷贝替代。我自己的代码里把这个动作单独抽成接口,并且加了断言,禁止任何人绕过。
5.2 内存对齐引发的伪共享
伪共享在前面提过很多次,这里想给一个最直观的排查方法:如果优化后性能波动很大,跑一次perf stat -e cache-misses,cache-references。如果发现cache-misses的占比超过 20%,那基本可以怀疑合并线程在互相踩缓存行。解决办法也很直接:
- 给每个合并线程的缓冲区末尾填充 64 字节;
- 把哈希表槽位的大小 padding 到 64 的整数倍;
- 避免多个线程同时写相邻槽位。
这个优化做完以后,我在测试机上看到的 cache-misses 从 17% 降到了 11%,虽然数字看起来不大,但整体耗时缩短了约 15%。
5.3 空槽位遍历太多导致 Cache 污染
固定哈希表合并还有一个很隐蔽的问题:遍历空槽位本身不消耗 CPU,但它会把大量无效的缓存行加载到 L1/L2。如果某个线程遍历了 200 万个槽位,其中 180 万个是空的,这 180 万个缓存行不仅浪费带宽,还会把其他线程正在用的有效缓存行逐出。
解决思路有两种。一种是在哈希表内部维护一个“非空槽位列表”,合并时直接跳空槽;另一种是限制单次合并的遍历 LEN,尽量让每次遍历集中在局部空间内,减少对整体 Cache 的污染。我用的是前者,每次插入键时把槽位下标同步记录到一个数组里,合并时只需要遍历实际命中的下标。这个方案和前面说的“轻量拼接”可以配合使用,效果叠加。
5.4 内存容量是个隐形天花板
固定哈希表的“固定”特性决定了它有一件很头疼的事:如果预估的键范围过大,预分配的内存就会骤然膨胀。并行合并时,每个线程各自维护局部哈希表,最终表也会另占一份内存。在 16 线程下,如果每个局部表都按 1000 万槽位预分配,内存占用瞬间可能涨到 30GB 以上。
我的建议是:先用一次简单的 COUNT(DISTINCT) 或者采样估算键基数,再决定是否启用固定哈希表;如果估算结果接近内存上限,宁可退回动态哈希表,否则池化内存时的 OOM 会直接拖垮整个查询。对于常驻内存的 ClickHouse 服务来说,牺牲一次查询的速度换整体稳定性,是更合理的选择。
6. 一个容易被忽略的扩展点:合并的顺序也能影响性能
聊完哈希表本身,我再多说一个在实际调优中发现但不太被人关注的细节:多个局部表合并时,先合并谁、后合并谁,会影响AggregateFunction内部临时内存的分配频率。
假设有 8 个局部表,合并的顺序是 1→2→3→...→8。每次合并一个表时,最终表里的聚合状态需要把新状态“融合”进来。如果局部表都比较小,倒无所谓;但如果第 1 个表特别大、后面都是小表,那你需要反复把大表的状态从内存读出来、再写回去,缓存压力很大。
我把合并顺序改成“小表先合并,大表最后合并”。因为小表合并进来时,最终表的状态规模还很小,操作对 Cache 友好;最后合并大表时,只需要一次大规模状态合并,不需要反复读写同一个大表状态。实测下来,这个排序调整在特定场景下可以让合并时间再下降 8% 左右。
这个优化不需要改数据结构,只需要在进入合并循环前对所有线程的局部表按“非空槽位数”排序。代码量不到十行,收益却很稳定,我后来直接固定到了分支里。
此外,还有一个小技巧值得分享:合并过程中临时缓冲区的复用。不要每次合并时都 new 一个中间向量来保存键集合,而是要在线程局部维护一个 Buffer,合并完一个表就 clear 再复用。别看 buffer 不大,频繁分配和释放会牵连内存分配器的全局锁,在高并发执行时同样会形成隐性串行点。
7. 从这次优化反推回来的一些思考
固定哈希表的合并优化,表面上看是一个“数据结构选型 + 并发控制”的问题,但真正做到后面会发现,它考验的不仅是对哈希表本身的理解,还有对 CPU 缓存、内存分配、聚合状态语义的整体把握。很多看似顺理成章的并行优化,如果没有从缓存行对齐、实际键密度、聚合状态合并语义这几个角度逐一验证,很容易做出“基准测试好看但线上不稳定”的方案。
我个人比较推荐的组合拳是:小表先合并 + 非空槽位索引 + 分片并行合并 + 缓存行对齐。四个方向互相独立,可以单独上线,也可以叠加使用。我自己的分支里四个都做了,整体并行加速比从原来的 1.19 提升到接近线性,核心固定哈希表聚合的延迟基本可以和单线程水平拉开一个量级。
如果你现在正在折腾 ClickHouse 的自定义聚合或哈希表优化,建议先从“非空槽位索引”入手,因为它的侵入性最小,风险最低,效果却最直接。改完以后再考虑并行分片合并,那时候你手里已经有了一份可对比的基准数据,能更准判断新改动到底值不值。
最后提一句,这类优化如果直接提交到 ClickHouse 官方社区,记得一定附上query_log和perf的对比数据,维护者非常看重可复现的基准。如果你只是在自用分支里做实验,那更要注意回归测试,尤其是各种聚合函数组合下的结果一致性,别让合并优化变成数据正确性的隐患。