多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

ClickHouse固定哈希表并行聚合合并优化实战

ClickHouse固定哈希表并行聚合合并优化实战 1. 这个优化点在 ClickHouse 里到底解决什么问题如果你调过 ClickHouse 的聚合查询应该见过这类场景一个大宽表几亿行数据按某个低基数字段做 GROUP BY同时算 SUM、COUNT、AVG 之类的一堆聚合。查询计划里最耗时的部分往往不是扫数据而是这件事——把海量行“塞”进一个个哈希表里一边塞一边累加聚合状态。当你打开 ClickHouse 的源码看到 Aggregator 那层逻辑时会发现一个有意思的设定哈希表是分“固定”和“动态”两种走的。所谓固定哈希表FixedHashTable不是指某个具体容器而是指一类在“聚合键空间已知且连续”的前提下被特殊优化的哈希表。典型代表就是FixedHashTable配合整型键使用比如按 UInt64 分组时可以直接用键值当作数组下标把哈希函数压缩到“几乎不存在”。这种表对 CPU 缓存极度友好因为你不需要探测链表、不需要处理冲突链一个连续内存块里塞满了聚合状态。但问题也随之而来。ClickHouse 默认会按线程数把输入数据拆成多份并行聚合每个线程各自维护一个小哈希表最后再把所有线程的哈希表“合并”成一个结果集。你猜合并这一步会发生什么固定哈希表在合并时最直观的做法是遍历每个线程的哈希表把里面的键值对一条条搬到最终表里。听起来没问题但如果每个线程的表都有几十万到上百万个槽位合并的总开销就会被放大到和“重新聚合一遍”差不多的程度。并行加速的收益在这里全被打回去了。我这篇文章想拆的就是这件事固定哈希表在并行聚合场景里合并阶段到底慢在哪以及我实测下来有效的几条并行加速思路。适合对 ClickHouse 执行层有一定了解、想深入看聚合性能瓶颈的读者也适合做数据库内核优化的人当作参考。2. “固定”二字背后的设计与取舍2.1 固定哈希表为什么这么快固定哈希表的底层思想其实特别朴素既然键的取值范围已知那我干脆不开辟动态节点直接用一段连续内存把每个可能的键值映射到一个固定下标上。拿最常见的FixedHashTableUInt64, AggregatePtr举例。它的存储结构基本等价于一个双层数组第一层按 UInt64 的高位分成若干“桶”bucket第二层在每个桶内按低位直接定址。查询一个键的时候理论上只需要做一次除余或者位运算就能定位到目标槽位。和传统HashMap相比避免了哈希计算、链式探测、节点分配和指针跳转内存访问模式是线性的预取器很容易把后续槽位提前加载到 L2/L3 Cache 里。我在自己的测试里遇到过特别典型的场景一张订单表按照用户 IDUInt64分组用户量大约 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::vectorUInt64 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的对比数据维护者非常看重可复现的基准。如果你只是在自用分支里做实验那更要注意回归测试尤其是各种聚合函数组合下的结果一致性别让合并优化变成数据正确性的隐患。
返回列表