如果你做数据分析,大概率对 ClickHouse 的 MergeTree 家族不陌生。我第一次意识到 SummingMergeTree 的价值,是在被一张几亿行的订单明细表折磨之后——报表按天、按用户、按商品维度出汇总,每次查询都是全量扫描 + GROUP BY sum,机器 IO 拉满,查询延迟随着数据量一路飙升。后来把核心报表表换成 SummingMergeTree,同样的查询从几十秒压到几百毫秒,业务代码几乎没怎么改,只是换了建表引擎。这篇是 MergeTree 家族实战系列的第三篇,前面聊过基础 MergeTree 的存储结构和 ReplacingMergeTree 的去重机制,这次集中拆解 SummingMergeTree 的合并规则、columns 参数、嵌套结构处理、实战建表姿势,以及在真实业务里最容易踩的那些坑。
1. 先弄清楚 SummingMergeTree 到底解决什么问题
1.1 报表场景里的重复聚合劳动
大部分 ClickHouse 报表场景是同一个套路:底层一张明细表,每次查询都做 GROUP BY 维度字段, sum(指标字段)。问题在于,明细表的行数会随时间无限增长,而报表关心的维度组合其实是有上限的——比如几百万用户、几万个商品、几百个城市,组合起来可能也就千万级别。也就是说,你每次都在用几亿行明细去算一个几千万行的结果,中间有大量重复扫描和重复聚合。
SummingMergeTree 的思路很直接:在后台合并数据块的时候,顺手把排序键相同的行合并成一行,数值字段自动求和。这样表里的行数会显著减少,查询时扫描的数据量自然下降。它不是把聚合逻辑变没了,而是把聚合工作提前到写入后的合并阶段,让查询阶段更轻量。
1.2 它在 MergeTree 家族里的定位
ClickHouse 的 MergeTree 家族每个引擎都有自己的"合并语义":
- 普通 MergeTree:只负责存储和分区,不做任何行级合并,保留全部明细。
- ReplacingMergeTree:按排序键去重,同名键只保留一条,适合状态快照类数据。
- SummingMergeTree:按排序键求和,同键多行合并成一行,数值列自动累加。
- AggregatingMergeTree:存储聚合函数的中间状态,比 SummingMergeTree 更通用但更复杂。
打个比方:ReplacingMergeTree 是"重复的不要了,留一条最新记录",SummingMergeTree 是"重复的合并起来,算一笔总账"。如果你要的是"每个用户累计消费金额",那 SummingMergeTree 就是为你准备的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心合并机制:写入后数据不会立刻变少
2.1 写入路径与合并路径是两回事
很多新手最大的误解就是:认为数据一写入 SummingMergeTree,相同的键就会自动合并。实际上,写入路径和普通 MergeTree 完全一样,新数据直接落盘成一个新的 data part,不做任何处理。合并只发生在后台的 merge 任务里,或者你用 OPTIMIZE TABLE 手动触发。
这与 ClickHouse 的 LSM 架构有关。数据先顺序写入磁盘,形成不可变的小 part,后台再根据分区大小、part 数量和 merge 策略,把多个小 part 合并成大 part。SummingMergeTree 就是在 merge 这个环节做文章的——它不是实时聚合,而是周期性聚合。
我见过有人写完数据立刻查,发现相同键的行还在,以为引擎没生效,其实只是还没触发 merge。用 OPTIMIZE TABLE ... FINAL 可以强制合并,但生产环境大表别乱用,后面会细说。
2.2 合并时到底按什么规则求和
合并规则可以用一句话概括:ORDER BY 表达式完全相同的行合并成一行,数值列自动求和,非数值列取第一行的值。
具体展开:
- 排序键由建表语句中的
ORDER BY决定,而不是PRIMARY KEY。如果你只写了ORDER BY,它同时兼做主键;如果写了PRIMARY KEY,排序键必须包含主键。 - 所有数值类型的列默认都会求和处理,除非在引擎参数里用 columns 指定了只对哪些列求和。
- 非数值列(字符串、日期等)不会求和,合并时取这一组行里第一行的值。如果该列就在 ORDER BY 里,那它本来就是键的一部分,组内值都相同;如果不在 ORDER BY 里,取值就有一定随机性,语义类似 GROUP BY 里对非聚合字段取任意值。
- 如果一组行求和后所有非键列都变成 0,某些版本下整行会被清除。这个行为在不同版本里并不完全一致,所以业务上不要依赖"某列恰好为 0 的行会消失"这个特性。
这里有一个容易被忽略的点:PARTITION BY 不参与合并匹配。也就是说,相同排序键但不同分区的行不会合并。分区是物理隔离的,合并只发生在单个分区内部的 data part 之间。所以如果你的排序键设计里没有包含分区字段,那分区字段不同的话,行永远不会合并到一块。
2.3 为什么查询时还是要写 GROUP BY + sum()
这是 SummingMergeTree 最重要、也最反直觉的一点:即使用了它,查询时依然要写 GROUP BY 和 sum(),不能直接 SELECT *。
原因很简单:合并是异步的。你在查询的那一刻,表的 data parts 可能处于各种状态——有的是刚写入的原始明细,有的已经合并过。如果直接查明细,同一组排序键可能出现多行,也可能只出现一行,结果完全不可控。
正确的使用姿势是:查询时照常写 GROUP BY 和 sum()。SummingMergeTree 保证的是,即使某些 part 已经合并过,sum(sum(x)) 的结果仍然等于原来所有明细的 sum(x)——因为合并阶段做的也是求和操作,求和是幂等的,不会破坏聚合结果。换句话说,它减少的是扫描行数和中间结果大小,而不是让你省掉 SQL 的聚合逻辑。
我见过不少同学把 SummingMergeTree 当成"不用写 GROUP BY 的省事引擎",结果线上出过数据翻倍的故障。记住:它只是预聚合,不是替代品。
3. 实战建表:一张订单汇总表的完整配置
3.1 建表语句与排序键设计思路
从一个最常见的电商订单场景出发。假设每天有大量订单流水,需要按日期、用户、商品维度出销售额和订单数汇总。
sql复制CREATE TABLE order_sum
(
order_date Date,
user_id UInt64,
product_id UInt64,
category_id UInt64,
order_amount Decimal(18, 2),
order_cnt UInt32
)
ENGINE = SummingMergeTree
PARTITION BY toYYYYMM(order_date)
ORDER BY (order_date, user_id, product_id);
这里的关键是排序键 (order_date, user_id, product_id)。它的含义是:同一天、同一个用户、同一个商品的订单行,在合并时会合并成一行,order_amount 累加,order_cnt 累加,category_id 属于非排序键非目标列,合并时取第一行的值。
注意 category_id 其实有数据冗余风险——如果一个用户当天买了同一商品但分类不同(不太可能,但业务上如果有),合并时只会保留其中一个分类。所以不要把这种"组内可能不一致"的维度字段排除在排序键外,如果它必须保留原始语义,就把它加进 ORDER BY 里。
写入几条测试数据:
sql复制INSERT INTO order_sum VALUES
('2025-01-05', 1001, 2001, 3001, 99.90, 1),
('2025-01-05', 1001, 2001, 3001, 50.00, 1),
('2025-01-05', 1001, 2002, 3001, 29.90, 1),
('2025-01-05', 1002, 2001, 3001, 199.00, 2),
('2025-01-06', 1001, 2001, 3001, 88.00, 1);
前两行的排序键都是 (2025-01-05, 1001, 2001),合并后金额变成 149.90,订单数变成 2。其他行排序键不同,保持不变。
强制触发合并:
sql复制OPTIMIZE TABLE order_sum FINAL;
合并后查询:
sql复制SELECT
order_date,
user_id,
product_id,
category_id,
sum(order_amount) AS total_amount,
sum(order_cnt) AS total_cnt
FROM order_sum
GROUP BY order_date, user_id, product_id, category_id;
返回结果里第一组就只剩一行:2025-01-05, 1001, 2001, 3001, 149.90, 2。查询速度提升的本质就是行数变少了。
3.2 columns 参数:精确控制哪些列求和
不是所有数值列都适合自动求和。比如订单表里有个 discount_amount 字段,它只想在特定场景用,不想被合并求和;或者有一列是"是否有效"的 0/1 标记,求和没有业务意义。这时候可以用引擎参数 columns 指定求和白名单。
sql复制CREATE TABLE order_sum_2
(
order_date Date,
user_id UInt64,
product_id UInt64,
category_id UInt64,
order_amount Decimal(18, 2),
discount_amount Decimal(18, 2),
order_cnt UInt32
)
ENGINE = SummingMergeTree(order_amount)
PARTITION BY toYYYYMM(order_date)
ORDER BY (order_date, user_id, product_id);
这种情况下,只有 order_amount 列会参与合并求和。discount_amount 和 order_cnt 即使都是数值类型,也不会求和——合并时取这一组里第一行的值。这是一个很隐蔽的坑:你以为所有数值列都会求和,实际上引擎参数里没列到的列根本不处理。
如果 order_cnt 也要求,就写 SummingMergeTree(order_amount, order_cnt)。columns 参数用起来很简单,但我建议在建表前就把"哪些列要预聚合"想清楚,因为表一旦建好,要改引擎参数就得 ALTER 或重建表,线上成本不小。
3.3 集群环境下建表的注意点
如果你的 ClickHouse 是集群部署,建表通常要带 ON CLUSTER 关键字:
sql复制CREATE TABLE order_sum ON CLUSTER cluster_name
(
...
)
ENGINE = SummingMergeTree
...
这里最常见的报错是 user: default: authentication failed: code: 193,很多人以为是建表语句写错了。其实这个报错和 SummingMergeTree 没有任何关系,是客户端到某个节点的认证失败了——可能是你配置的连接用户没有分布式权限,或者集群里其他节点没同步这个用户。排查方向是检查所有节点的 users.xml 和分布式权限配置,而不是盯着表结构看。
4. 嵌套数据结构的求和:一个容易被忽略的能力
4.1 Nested 类型下的求和规则
SummingMergeTree 对 Nested(嵌套数组)类型有特殊处理,这个能力在日常业务里很实用,但大多数人没注意到。
在 19.16 版本之前,ClickHouse 要求 Nested 结构里所有求和字段名必须以 Summing 前缀开头,比如 SummingVisitCnt,引擎才会按数组下标对应求和。如果字段名不以这个前缀开头,整个嵌套结构会被当成一个普通列,合并时直接取某一行的整个数组,不逐元素求和。
19.16 之后,这个限制放宽了:嵌套结构里的数值字段会自动按数组对应位置求和,不再要求前缀名。举个例子,如果两行的排序键相同,每行数组长度都是 2,对应位置相加,数组变短或长度不一致时按较短处理。非数值字段仍然取第一行。
处理这个行为要注意版本差异。我建议团队内部统一 ClickHouse 版本,别在一个集群里混用 19.16 前后的版本,否则同一套建表语句在不同节点上的合并表现可能不一样。
4.2 一个多级汇总的实例
假设要统计每个用户每天访问了哪些页面、每个页面的访问次数和停留时长,页面列表是变长的,适合用 Nested 存。
sql复制CREATE TABLE user_visit_stat
(
stat_date Date,
user_id UInt64,
visit_stat Nested(
page_id UInt64,
visit_cnt UInt32,
stay_time UInt32
)
)
ENGINE = SummingMergeTree
ORDER BY (stat_date, user_id);
写入两条同一用户同一天的记录:
sql复制INSERT INTO user_visit_stat VALUES
('2025-01-05', 1001, [10001, 10002], [3, 2], [120, 80]),
('2025-01-05', 1001, [10001, 10003], [5, 1], [300, 30]);
排序键 (stat_date, user_id) 相同,合并时 visit_cnt 和 stay_time 都会按数组下标相加:page_id 字段因为是 UInt64 数值类型,在 19.16+ 也可能被求和——但页面对应位置不同,求和后 10001+10001=20002,毫无意义。所以真正的生产环境,page_id 这种 ID 字段要在设计上避免被累加。常见做法是把它移出 Nested,作为独立的维度字段存,或者把页面访问数据单独拆成子表。这也反映了嵌套结构的一个局限:它适合"变长数组 + 全部指标都是计量值"的场景,不适合同时维护维度 ID。
嵌套结构用对了,能省掉一张关联表;用错了,你会拿到一堆莫名奇妙的"求和后的 ID"。我建议在正式使用前先用小数据量做一次 OPTIMIZE,把合并后的结果打出来看一眼。
5. 这些坑我建议你提前知道
5.1 类型太小导致求和溢出
这是我最想强调的一个问题。SummingMergeTree 求和时使用的是列本身的类型,不会自动升级类型。
假设你建表时把 order_cnt 设成 UInt8,那它能表示的最大值是 255。如果同一个排序键下有 300 条订单记录,合并时 300 直接溢出,变成 300 mod 256 = 44。数据不仅错了,而且错得毫无征兆。
所以建表时,对于要参与求和的字段,一定要预估好未来可能的上限:
- 订单量、访问量这类高频累加字段,建议直接用
UInt64或Int64。 - 金额字段用
Decimal(P, S),别用Float32/Float64。 - 如果字段的值可能为负(比如退款金额),选有符号类型
Int64/Decimal。
这类问题在测试环境永远发现不了——因为测试数据量小,溢出阈值远达不到。一旦上了生产,日积月累的累加值突然越过类型上限,报表数据就崩了。
5.2 别把预聚合当成实时聚合
SummingMergeTree 的合并时机由后台任务控制,分区数据量、part 数量、系统负载都会影响 merge 什么时候发生。如果业务要求"刚写入的数据立刻就能看到聚合结果",那不能只依赖 SummingMergeTree。
有两种选择:
- 查询时依赖
GROUP BY + sum(),这样不管合并没合并结果都是对的。这也是官方推荐姿势。 - 如果希望查询时尽可能少扫描,写任务可以定时执行
OPTIMIZE TABLE ... FINAL。但注意,这个操作会重写整个分区的 data parts,在大表上会造成严重的 IO 放大,最好在业务低峰期执行,并且只针对必要分区,比如OPTIMIZE TABLE order_sum PARTITION '2025-01' FINAL。
另外,合并触发也有 SETTINGS 可以调,比如 merge_with_ttl_timeout 之类的参数,但大多数场景不需要动。保持默认即可。
5.3 浮点数求和有精度问题
如果列类型是 Float32 或 Float64,累加过程中会出现浮点误差。比如 0.1 + 0.2 在浮点表示里不等于 0.3,多行累加后误差会逐渐累积。金额、库存、计数这类对精度敏感的数据,绝对不要用 Float 族。
推荐做法:
- 金额:
Decimal(18, 2)或更高精度。 - 比率、单价:
Decimal(18, 4)之类的定点数。 - 纯计数:
UInt64。
Decimal 是定点数,没有浮点误差,累加是精确的。只是要注意 Decimal(P, S) 的 P 是总有效位数,S 是小数位数,整数部分长度是 P - S,要留足余量。
5.4 这些场景其实不适合用 SummingMergeTree
不是所有报表场景都能用 SummingMergeTree 优化。
- 精确去重指标,比如独立访客数 UV。
sum不能替代COUNT(DISTINCT),同一用户多次访问在排序键相同的情况下会被合并成一行,但如果你真的把明细行合并了,统计去重数就无从下手。这类场景应该用明细表 +uniq,或者用 AggregatingMergeTree +uniqState。 - 需要保留明细行的场景。如果你后续还要看每一笔原始记录,那就不能用预聚合引擎。可以考虑保留一张明细表,另建一张 SummingMergeTree 汇总表,通过异步或 TTL 机制同步。
- 正负抵消造成记录消失。如果同一排序键下存在一笔 +100 和一笔 -100,合并后金额为 0,某些版本下这组行会被清除。如果业务上需要保留这种冲销记录,同键设计就不合适,可以加一个流水号作为排序键的一部分。
- 实时性要求很高。合并是异步的,如果你的业务是"写入后秒级就能查最新聚合",预聚合引擎的模型可能会让你失望。
我自己在选型时的判断顺序是:先问有没有实时要求,再说能不能接受后台合并延迟;然后问数据语义是求和、去重还是保留明细;最后才选引擎。顺序错了,后面改表的成本会很大。
6. 和家族其他引擎怎么分工:一张表决定选型
6.1 各引擎核心差异对比
| 引擎 | 合并语义 | 适用场景 | 查询写法 |
|---|---|---|---|
| MergeTree | 不合并,保留明细 | 原始明细存储 | 直接查询 |
| ReplacingMergeTree | 按排序键去重,保留版本最新/最先 | 用户状态、商品快照 | 通常需配合版本字段 |
| SummingMergeTree | 按排序键求和,数值列累加 | 订单汇总、访问统计 | 仍需 GROUP BY + sum() |
| AggregatingMergeTree | 存储聚合中间状态 | 复杂聚合、去重计数 | 使用 -State / -Merge 系列函数 |
6.2 从实际场景反推引擎选择
以电商数仓为例。订单流表明细保留用普通 MergeTree,每个分区按天切;用户维度最新信息用 ReplacingMergeTree,按用户 ID 去重,保留最新的一条;销售汇总报表用 SummingMergeTree,按天、按商品、按店铺维度预聚合;需要精确出独立访客数的留存报表,用 AggregatingMergeTree 存 uniqState 中间状态。
真实业务里,几张不同引擎的表往往是配合使用的。比如明细表用 TTL 归档到 SummingMergeTree 汇总表,既控制了存储成本,又保证报表查询性能。这个组合在我经历的项目里是最常见的。
6.3 选型时一个容易忽略的成本
很多人只关注引擎的能力,忽略了运维成本。SummingMergeTree 合并后行数减少,但如果分区数量爆炸,后台 merge 任务会频繁触发,CPU 和磁盘 IO 都会受影响。建议控制分区粒度,比如按天或按月分区即可,不要按小时,否则 part 太多 merge 压力大。另一个点是排序键选择直接影响合并效率,如果排序键的基数太高,比如掺入唯一 ID,那"相同排序键"的行几乎永远只有一行,SummingMergeTree 就退化成普通 MergeTree,预聚合效果归零。设计排序键时,要确保它和真实业务维度匹配,而不是随手拼一个。
我在生产环境看到过一张建得随意的 SummingMergeTree 表,排序键里带了一个毫秒级时间戳,结果每个排序键都是唯一的,表和普通 MergeTree 没区别,性能问题依旧。把时间戳去掉、用天维度做排序键后,合并才真正生效,行数降了两个数量级。
从那次之后我养成了一个习惯:建表前先问自己一句"这张表哪个维度组合是业务上真正关心的",然后用它做排序键。SummingMergeTree 本身不复杂,复杂的是排序键和列设计是否符合业务语义。最后分享一个小技巧:刚用 SummingMergeTree 时,可以用一张几十行的小表,插入相同排序键的数据,手动 OPTIMIZE TABLE ... FINAL,再用 SELECT * 看合并结果,快速验证自己对列行为的理解是否正确。这个习惯帮我避过不少坑。
