ClickHouse/GROUP BY 哈希聚合

把分组键映射为可批量更新的聚合状态

ClickHouse 先为输入行找到对应分组的聚合状态地址,再通过地址数组批量执行各个聚合函数。

23e9f161b检索与计算核验 2026-10-12
相关概念

工程问题#

同一批数据中的分组键可能反复出现,SUM、COUNT 等多个聚合又需要共享同一分组定位结果。若每个聚合函数各自查表,会重复计算键和探测哈希表;若保存每组全部原始行,又增加内存和后处理成本。

实际实现#

通用批处理分支通过 state.emplaceKey 查找或插入分组。新分组从 Arena 分配并初始化聚合状态,已有分组直接取状态指针,逐行写入 places 数组。随后按聚合函数遍历,addBatch 使用 places 定位每行应更新的状态。底层通用 HashTable 以开放寻址寻找相同键或空槽,扩容后重新取得槽位置。

处理流程#

  1. 按聚合方法编码分组键,查找或插入哈希表
  2. 为新分组分配并初始化状态,已有分组复用状态地址
  3. 把每行对应状态地址写入 places 数组
  4. 各聚合函数批量遍历输入,并更新 places 指向的分组状态

源码片段#

固定版本 · 原文摘录
src/Interpreters/Aggregator.cpp23e9f161b214820d67c274bf1faccaf7ce3470e9 · L1876–L1894
查看原文件
1876            auto emplace_result = state.emplaceKey(method.data, i, *aggregates_pool);18771878            if constexpr (top_k)1879            {1880                if (emplace_result.isInserted())1881                {1882                    if (state.isNullAt(i))1883                        method.top_k_heap.push(heap_key_cols, i);1884                    else1885                        method.top_k_heap.push(heap_key_cols, i, emplace_result.getKey());1886                }1887            }18881889            /// If a new key is inserted, initialize the states of the aggregate functions, and possibly something related to the key.1890            if (emplace_result.isInserted())1891            {1892                /// exception-safety - if you can not allocate memory or create states, then destructors will not be called.1893                emplace_result.setMapped(nullptr);1894
Aggregator::executeImplBatch:分组查找

emplaceKey 返回已有或新增分组;Top-K 条件分支在普通路径中不启用,新分组先把 mapped 清空以维护异常安全。

src/Interpreters/Aggregator.cpp23e9f161b214820d67c274bf1faccaf7ce3470e9 · L1905–L1925
查看原文件
1905                if (!aggregate_data)1906                    aggregate_data = aggregates_pool->alignedAlloc(total_size_of_aggregate_states, align_aggregate_states);19071908                createAggregateStates(aggregate_data, use_compiled_functions);1909                emplace_result.setMapped(aggregate_data);1910            }1911            else1912                aggregate_data = emplace_result.getMapped();19131914            if constexpr (top_k)1915            {1916                if (method.top_k_heap.needsTrim())1917                {1918                    trimHeapAndPruneHashTable(method, &destroyed_states, i);1919                    skip_bitmap = nullptr;1920                    state.resetCache();1921                }1922            }19231924            chassert(aggregate_data != nullptr);1925            places[i] = aggregate_data;
Aggregator::executeImplBatch:状态地址

新分组从 Arena 获取对齐空间并构造状态,已有分组复用原地址,最终把它记录到 places[i]。

src/Interpreters/Aggregator.cpp23e9f161b214820d67c274bf1faccaf7ce3470e9 · L2039–L2057
查看原文件
2039    /// Add values to the aggregate functions.2040    for (size_t i = 0; i < aggregate_functions.size(); ++i)2041    {2042#if USE_EMBEDDED_COMPILER2043        if (use_compiled_functions && is_aggregate_function_compiled[i])2044            continue;2045#endif20462047        const AggregateFunctionInstruction * inst = aggregate_instructions + i;20482049        if (all_keys_are_const || (inst->can_optimize_equal_keys_ranges && has_only_one_value_since_last_reset))2050        {2051            ProfileEvents::increment(ProfileEvents::AggregationOptimizedEqualRangesOfKeys);2052            addBatchSinglePlace(row_begin, row_end, inst, places[key_start] + inst->state_offset, aggregates_pool);2053        }2054        else2055        {2056            addBatch(row_begin, row_end, inst, places, aggregates_pool, all_places_are_non_null);2057        }
Aggregator::executeImplBatch:批量聚合

遍历聚合函数,普通分组调用 addBatch 并传入共享 places;所有键相同等情况有单状态快路径。

src/Common/HashTable/HashTable.h23e9f161b214820d67c274bf1faccaf7ce3470e9 · L446–L458
查看原文件
446    /// Find a cell with the same key or an empty cell, starting from the specified position and further along the collision resolution chain.447    size_t ALWAYS_INLINE findCell(const Key & x, size_t hash_value, size_t place_value) const448    {449        while (!buf[place_value].isZero(*this) && !buf[place_value].keyEquals(x, hash_value, *this))450        {451            place_value = grower.next(place_value);452#ifdef DBMS_HASH_MAP_COUNT_COLLISIONS453            ++collisions;454#endif455        }456457        return place_value;458    }
HashTable::findCell

从哈希定位的槽沿探测序列推进,直到找到相同键或空槽,碰撞不会创建额外链表节点。

src/Common/HashTable/HashTable.h23e9f161b214820d67c274bf1faccaf7ce3470e9 · L1037–L1055
查看原文件
1037    void ALWAYS_INLINE emplaceNonZeroImpl(size_t place_value, KeyHolder && key_holder,1038                                          LookupResult & it, bool & inserted, size_t hash_value)1039    {1040        it = &buf[place_value];10411042        if (!buf[place_value].isZero(*this))1043        {1044            keyHolderDiscardKey(key_holder);1045            inserted = false;1046            return;1047        }10481049        keyHolderPersistKey(key_holder);1050        const auto & key = keyHolderGetKey(key_holder);10511052        new (&buf[place_value]) Cell(key, *this);1053        buf[place_value].setHash(hash_value);1054        inserted = true;1055        ++m_size;
HashTable::emplaceNonZeroImpl

已有键直接返回;空槽才持久化键并构造 Cell,随后增加元素计数。

src/Common/HashTable/HashTable.h23e9f161b214820d67c274bf1faccaf7ce3470e9 · L1057–L1078
查看原文件
1057        if (unlikely(grower.overflow(m_size)))1058        {1059            try1060            {1061                resize();1062            }1063            catch (...)1064            {1065                /** If we have not resized successfully, then there will be problems.1066                  * There remains a key, but uninitialized mapped-value,1067                  *  which, perhaps, can not even be called a destructor.1068                  */1069                --m_size;1070                buf[place_value].setZero();1071                inserted = false;1072                keyHolderDiscardKey(key_holder);1073                throw;1074            }10751076            // The hash table was rehashed, so we have to re-find the key.1077            size_t new_place = findCell(key, hash_value, grower.place(hash_value));1078            chassert(!buf[new_place].isZero(*this));
HashTable::emplaceNonZeroImpl:扩容

达到增长条件触发 resize;异常时撤回新增状态,成功后重新查找键,因为旧槽位置可能已改变。

片段保持固定提交的连续原文;完整源码与许可证保存在本地。每段说明交代所在分支,省略部分可通过出处查看。

收益与取舍#

工程分析

带来的收益

  • 一次分组定位结果可以供多个聚合函数复用
  • 状态按分组保存,适合无需保留全部明细行的聚合;Arena 减少分散的小对象分配

付出的代价

  • 状态空间随分组数及聚合函数状态大小增长;某些聚合状态本身会增长,不能一概写成每组常量内存
  • 开放寻址的探测成本受哈希分布和负载率影响;扩容需要重新定位,单次插入不是严格 O(1)
  • 批次 places 数组带来额外指针存储;外部聚合、两级哈希、Top-K 和专用 COUNT 分支会改变执行细节

可比较的选择

先排序再按连续分组聚合数据本来有序时可减少分组状态驻留,但否则需付出排序成本。

逐个聚合函数独立查表结构简单,但重复键处理和查找,不利于共享批次分组结果。

版本与适用范围#

对应固定提交 Aggregator::executeImplBatch 的一般分组状态分支,描述正常允许新键、未启用 Top-K 剪枝的路径。源码中按键类型有多种容器和专用优化;所附 HashTable 片段说明通用开放寻址实现,不声称所有 GROUP BY 都使用同一哈希表。

参考来源#

源码、官方文档与原始论文
01
ClickHouse · Aggregator::executeImplBatch:分组查找

src/Interpreters/Aggregator.cpp · Aggregator::executeImplBatch:分组查找

源码23e9f161b214820d67c274bf1faccaf7ce3470e9
02
ClickHouse · HashTable::findCell

src/Common/HashTable/HashTable.h · HashTable::findCell

源码23e9f161b214820d67c274bf1faccaf7ce3470e9
03
ClickHouse · 列键 emplaceKey 到容器 emplace 的转发

src/Common/ColumnsHashing.h · 列键 emplaceKey 到容器 emplace 的转发

源码23e9f161b214820d67c274bf1faccaf7ce3470e9
04
ClickHouse · HashMapCell 与键值映射

src/Common/HashTable/HashMap.h · HashMapCell 与键值映射

源码23e9f161b214820d67c274bf1faccaf7ce3470e9
05
ClickHouse · executeImplBatch 接口

src/Interpreters/Aggregator.h · executeImplBatch 接口

源码23e9f161b214820d67c274bf1faccaf7ce3470e9
06
ClickHouse · UInt64 等键的聚合 HashMap 类型

src/Interpreters/AggregatedData.h · UInt64 等键的聚合 HashMap 类型

源码23e9f161b214820d67c274bf1faccaf7ce3470e9
07
ClickHouse · 按键类型选择聚合数据变体

src/Interpreters/AggregatedDataVariants.h · 按键类型选择聚合数据变体

源码23e9f161b214820d67c274bf1faccaf7ce3470e9
08
ClickHouse · AggregationMethodOneNumber 状态

src/Interpreters/AggregationMethod.h · AggregationMethodOneNumber 状态

源码23e9f161b214820d67c274bf1faccaf7ce3470e9

相关实现与比较#

4 个相关案例
QEMU·TCG 翻译块v9.2.0

QEMU TCG 用 QHT 保存 TranslationBlock 索引,帮助复用与当前客体执行状态一致的翻译结果。

附源码片段 · 缓存与复用
19 个匹配案例