把分组键映射为可批量更新的聚合状态
ClickHouse 先为输入行找到对应分组的聚合状态地址,再通过地址数组批量执行各个聚合函数。
工程问题#
同一批数据中的分组键可能反复出现,SUM、COUNT 等多个聚合又需要共享同一分组定位结果。若每个聚合函数各自查表,会重复计算键和探测哈希表;若保存每组全部原始行,又增加内存和后处理成本。
实际实现#
通用批处理分支通过 state.emplaceKey 查找或插入分组。新分组从 Arena 分配并初始化聚合状态,已有分组直接取状态指针,逐行写入 places 数组。随后按聚合函数遍历,addBatch 使用 places 定位每行应更新的状态。底层通用 HashTable 以开放寻址寻找相同键或空槽,扩容后重新取得槽位置。
处理流程#
- 按聚合方法编码分组键,查找或插入哈希表
- 为新分组分配并初始化状态,已有分组复用状态地址
- 把每行对应状态地址写入 places 数组
- 各聚合函数批量遍历输入,并更新 places 指向的分组状态
源码片段#
固定版本 · 原文摘录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);1894emplaceKey 返回已有或新增分组;Top-K 条件分支在普通路径中不启用,新分组先把 mapped 清空以维护异常安全。
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;新分组从 Arena 获取对齐空间并构造状态,已有分组复用原地址,最终把它记录到 places[i]。
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 }遍历聚合函数,普通分组调用 addBatch 并传入共享 places;所有键相同等情况有单状态快路径。
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 }从哈希定位的槽沿探测序列推进,直到找到相同键或空槽,碰撞不会创建额外链表节点。
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;已有键直接返回;空槽才持久化键并构造 Cell,随后增加元素计数。
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));达到增长条件触发 resize;异常时撤回新增状态,成功后重新查找键,因为旧槽位置可能已改变。
片段保持固定提交的连续原文;完整源码与许可证保存在本地。每段说明交代所在分支,省略部分可通过出处查看。
收益与取舍#
工程分析带来的收益
- 一次分组定位结果可以供多个聚合函数复用
- 状态按分组保存,适合无需保留全部明细行的聚合;Arena 减少分散的小对象分配
付出的代价
- 状态空间随分组数及聚合函数状态大小增长;某些聚合状态本身会增长,不能一概写成每组常量内存
- 开放寻址的探测成本受哈希分布和负载率影响;扩容需要重新定位,单次插入不是严格 O(1)
- 批次 places 数组带来额外指针存储;外部聚合、两级哈希、Top-K 和专用 COUNT 分支会改变执行细节
可比较的选择
先排序再按连续分组聚合数据本来有序时可减少分组状态驻留,但否则需付出排序成本。
逐个聚合函数独立查表结构简单,但重复键处理和查找,不利于共享批次分组结果。
版本与适用范围#
对应固定提交 Aggregator::executeImplBatch 的一般分组状态分支,描述正常允许新键、未启用 Top-K 剪枝的路径。源码中按键类型有多种容器和专用优化;所附 HashTable 片段说明通用开放寻址实现,不声称所有 GROUP BY 都使用同一哈希表。
参考来源#
源码、官方文档与原始论文src/Interpreters/Aggregator.cpp · Aggregator::executeImplBatch:分组查找
src/Common/HashTable/HashTable.h · HashTable::findCell
src/Common/ColumnsHashing.h · 列键 emplaceKey 到容器 emplace 的转发
src/Common/HashTable/HashMap.h · HashMapCell 与键值映射
src/Interpreters/Aggregator.h · executeImplBatch 接口
src/Interpreters/AggregatedData.h · UInt64 等键的聚合 HashMap 类型
src/Interpreters/AggregatedDataVariants.h · 按键类型选择聚合数据变体
src/Interpreters/AggregationMethod.h · AggregationMethodOneNumber 状态
相关实现与比较#
4 个相关案例QEMU TCG 用 QHT 保存 TranslationBlock 索引,帮助复用与当前客体执行状态一致的翻译结果。
libvirt 为 Domain 对象维护 UUID 与名称两个哈希索引,两个入口指向同一个对象。
把上下文身份编码进块哈希,让重复请求共享已经计算的 KV cache。
Redis 有序集合既要按成员查分数,也要按分数和名次遍历;较大集合通过两种索引服务这些访问方式。