共计 2308 个字符,预计需要花费 6 分钟才能阅读完成。
背景痛点
最近在用 ClickHouse 的 Rollup 功能做多维分析时,发现个头疼问题:查询结果虽然包含了各个维度的聚合数据,但返回的扁平化结构里,父子层级关系完全要靠自己拼装。举个实际例子,当我们统计 ” 大区→省份→城市 ” 的销售数据时,Rollup 结果可能是这样的:

[{"region":"华东", "province":null, "city":null, "sales":5000},
{"region":"华东", "province":"江苏", "city":null, "sales":3000},
{"region":"华东", "province":"江苏", "city":"南京", "sales":1000}
]
直接映射成 POJO 列表后,发现两个典型问题:
- 内存爆炸:尝试用递归构建树形结构时,百万级数据直接 OOM
- 循环引用:某些脏数据会导致 JSON 序列化时 StackOverflow
技术方案对比
试过三种构建树形结构的方案后,总结出这些经验:
- 纯递归方案:代码简洁但性能最差,10 万数据就 GC 频繁
- 第三方库方案:像 JOOQ 的 Tree 工具类,对 CH 特殊字段支持不好
- HashMap 索引方案:最终采用 Guava 的 Multimap 预构建索引,查询效率 O(1)
核心代码实现
先定义处理 Rollup 结果的 DTO,注意处理 CH 的 null 值特性:
@Data
public class RollupNode {@JsonProperty("region")
private String region;
@JsonProperty("province")
private String province; // CH 用 null 表示层级终止
@JsonProperty(value = "city", required = false)
private String city;
private volatile List<RollupNode> children = new ArrayList<>(); // 线程安全优化}
关键聚合逻辑使用 Multimap 加速查找:
public class RollupTreeBuilder {public static List<RollupNode> buildTree(List<RollupNode> flatData) {
// 1. 构建父节点查找索引
Multimap<String, RollupNode> parentIndex = ArrayListMultimap.create();
flatData.forEach(node -> {String parentKey = buildParentKey(node); // 根据业务规则生成 key
parentIndex.put(parentKey, node);
});
// 2. 非递归方式构建树
List<RollupNode> roots = new ArrayList<>();
flatData.forEach(node -> {String currentKey = buildCurrentKey(node);
Collection<RollupNode> children = parentIndex.get(currentKey);
if (!children.isEmpty()) {node.setChildren(new ArrayList<>(children)); // 防御性拷贝
}
if (isRootNode(node)) { // 判断是否为根节点的业务逻辑
roots.add(node);
}
});
return roots;
}
}
性能优化要点
通过 JMH 测试(测试环境:4 核 8G 阿里云 ECS),不同方案处理 100 万数据的表现:
| 方案 | 耗时(ms) | GC 次数 |
|---|---|---|
| 递归方案 | 4523 | 28 |
| JOOQ 方案 | 1256 | 5 |
| 本文方案 | 687 | 2 |
内存优化技巧:
- 使用 ArrayList 而非 LinkedList,随机访问性能提升 40%
- 对稀疏层级采用
Collections.emptyList()初始化子节点 - 大对象池化复用 RollupNode 实例
避坑指南
- 字段类型陷阱 :CH 返回的数值可能是 Long/Double,建议用
Number类型接收 - 循环引用检测:在 buildTree 方法首行添加:
if (flatData.stream().anyMatch(Objects::isNull)) {throw new IllegalArgumentException("数据包含 null 元素"); } - 分布式环境:建议采用一致性哈希分配节点处理任务
延伸思考
后来发现这个聚合逻辑其实可以下沉到 ClickHouse 的物化视图,用 groupArray 函数实现:
CREATE MATERIALIZED VIEW sales_hierarchy
ENGINE = AggregatingMergeTree
AS SELECT
region,
province,
groupArray((city, sales)) AS children
FROM sales_data
GROUP BY region, province
对于需要动态层级的场景,可以结合 GraphQL 实现灵活查询。比如用 graphql-java 定义类型:
GraphQLObjectType nodeType = newObject()
.name("RollupNode")
.field(field -> field.name("children").type(GraphQLList.list(nodeType)))
.build();
经过这次优化,同样硬件环境下处理千万级数据,耗时从原来的 12 秒降到 1.8 秒。关键点是利用内存索引把 O(n²)的递归查询变成 O(n)的哈希查找。建议大家在处理层级数据时,一定要先建立好索引关系再组装树形结构。
正文完
发表至: 技术分享
近一天内
