ClickHouse 不让删数据?版本折叠合并树是怎么更新删除的
ClickHouse 查询快,但删除和更新一直是它的软肋。
VersionedCollapsingMergeTree 是官方给出的一个折中方案:不真删数据,而是再插一条「抵消行」,让后台合并时把新旧记录成对消掉。
我之前在一个用户状态表上踩过它的坑,SELECT * 出来的数据对不上,折腾了挺久才搞明白它的脾气。
这篇就把机制讲清楚。
它到底解决什么问题?
一句话:把删除和更新,变成纯插入操作。
ClickHouse 底层是列式存储,按数据块批量写。
它擅长追加和查询,但原地改一行、删一行的代价很高。
VersionedCollapsingMergeTree 的思路是不碰旧数据。
要删一条记录,就再写一条「除符号外完全相同」的抵消行。
要改一条记录,就先抵消旧的,再追加一条新的。
真正的物理删除交给后台合并进程慢慢做。
sign 和 version 不是自动生成的
这是很多人第一个误区。
sign 和 version 是你自己在表里定义的两个普通列,引擎不会凭空给你加。
建表时通过引擎参数告诉 ClickHouse,哪一列当符号、哪一列当版本:
ENGINE = VersionedCollapsingMergeTree(sign, version)两列的含义和类型如下:
| 列 | 作用 | 取值 / 类型 |
|---|---|---|
sign |
标记行的类型 | 1 是状态行(state),-1 是取消行(cancel);类型 Int8 |
version |
标记对象的状态版本 | 整数 / 日期 / 时间类型,每个状态用不同数字 |
写入规则记三条就够:
- 写一个新状态,
sign = 1 - 取消旧状态,写一条
sign = -1的取消行,这行要复制旧状态除 sign 外的所有字段,包括相同的 version - 更新后的新状态,
sign = 1,version 递增
折叠规则:相同主键 + 相同 version + 符号相反
核心就一句话。
后台合并数据分片时,会把「主键相同、version 相同、sign 相反」的成对行删掉。
行的物理顺序无所谓,这点很关键。
看一组官方示例数据:
┌──────────────UserID─┬─PageViews─┬─Duration─┬─Sign─┬─Version─┐
│ 4324182021466249494 │ 5 │ 146 │ 1 │ 1 │ 旧状态
│ 4324182021466249494 │ 5 │ 146 │ -1 │ 1 │ 取消旧状态
│ 4324182021466249494 │ 6 │ 185 │ 1 │ 2 │ 新状态
└─────────────────────┴───────────┴──────────┴──────┴─────────┘version=1 那两行符号相反,合并时被抵消。
version=2 的状态行保留下来,成为当前值。
那个 order by 不只是排序
原来我以为 ORDER BY 就是排个序,其实它定义的是表的主键,也就是折叠时「谁和谁算同一个对象」的依据。
ORDER BY 写了哪些列,折叠就按这些列加上 version 来配对。
还有个隐藏行为容易忽略:如果 version 列没写进主键,ClickHouse 会偷偷把它追加到主键最后一位参与排序。
所以 ORDER BY 选错列,折叠逻辑就全乱了。
和 CollapsingMergeTree 差在哪?
差别就在 version 这一列。
CollapsingMergeTree 折叠依赖插入顺序,状态行必须严格排在取消行前面,多线程或乱序插入就会折叠失败。
VersionedCollapsingMergeTree 靠 version 配对,插入顺序怎么乱都能正确折叠。
| 对比项 | VersionedCollapsingMergeTree | CollapsingMergeTree |
|---|---|---|
| 折叠依据 | version 列配对 | 严格的插入顺序 |
| 插入要求 | 多线程、任意顺序都行 | 必须按顺序写 |
| 乱序容错 | 仍能正确折叠 | 折叠失败 |
简单说,你的写入链路是异步的、并发的,就用带 version 的版本。
最大的坑:SELECT * 查出来的数据是错的
这是实战里最容易翻车的地方。
合并是后台异步做的,时机完全由 ClickHouse 决定,你不知道它什么时候合。
在合并发生前,抵消行和旧状态行都还在表里。
而且 ClickHouse 不保证相同主键的行落在同一个 part、甚至同一台机器上。
所以直接 SELECT *,大概率会看到没折叠的脏数据。
正确的查法有两种。
第一种,用 GROUP BY 配合 sign 做聚合,这是推荐做法:
SELECT
UserID,
sum(PageViews * Sign) AS PageViews,
sum(Duration * Sign) AS Duration,
Version
FROM UAct
GROUP BY UserID, Version
HAVING sum(Sign) > 0计数用 sum(Sign) 代替 count(),求和用 sum(x * Sign) 代替 sum(x),再用 HAVING sum(Sign) > 0 把已经被抵消掉的对象过滤掉。
要注意 min、max 这类函数它不支持,因为引擎根本不保存历史状态的值。
第二种,加 FINAL:
SELECT * FROM UAct FINAL结果是对的,但 FINAL 会强制在查询时做合并,大表上极慢。
线上大表别用,老老实实 GROUP BY。
完整示例走一遍
建一张用户表:
CREATE TABLE UAct
(
UserID UInt64,
PageViews UInt8,
Duration UInt8,
Sign Int8,
Version UInt8
)
ENGINE = VersionedCollapsingMergeTree(Sign, Version)
ORDER BY UserID;写入一个初始状态:
INSERT INTO UAct VALUES (4324182021466249494, 5, 146, 1, 1);后来这个用户数据要从 (5, 146) 改成 (6, 185),那就一次写两条:
INSERT INTO UAct VALUES
(4324182021466249494, 5, 146, -1, 1), -- 抵消旧状态,version 仍是 1
(4324182021466249494, 6, 185, 1, 2); -- 新状态,version 升到 2合并之后,version=1 的两行消失,只剩 version=2 的当前值。
合并之前,就靠上面那条 GROUP BY 查询拿到正确结果。
常见问题
VersionedCollapsingMergeTree 适合频繁更新的场景吗?
适合「状态会变、但变更有明确版本」的数据,比如用户画像、订单状态。
它把更新转成插入,写入很快。
但代价是存储会膨胀,而且需要应用层记住旧状态才能构造抵消行。
如果是高频随机单点更新且不能记住旧值,它并不合适。
一次 INSERT 后数据会立刻折叠吗?
不会。
单次 INSERT 只生成一个 part,永远不触发合并。
至少要有两个 part 才可能合并,且合并时机由 ClickHouse 自行决定,不可预测。
所以千万别假设「插完就折叠好了」。
sign 取错值会怎样?
折叠会出错。
state 行写成 -1、cancel 行没复制对 version,都会导致该抵消的没抵消,查询结果出现负数之类的诡异值。
写入链路一定要保证符号和 version 的一致性。
它和 ReplacingMergeTree 怎么选?
ReplacingMergeTree 按主键去重保留最新行,适合「只要最新值、不关心抵消」的场景,用法更简单。
VersionedCollapsingMergeTree 适合需要精确加减、做增量聚合统计的场景。
只想去重就用前者,要算 sum、count 这类指标就用后者。
写在最后
VersionedCollapsingMergeTree 的本质,是用「插入 + 后台抵消」绕开 ClickHouse 不擅长的删改。
理解它只要抓两点:sign 和 version 是自己定义的、折叠按主键加 version 配对;查询永远别裸 SELECT *,用 GROUP BY 或 FINAL。
以上结论我只在自己的测试环境验证过,具体行为还是以你用的 ClickHouse 版本为准。
如果你在用它的过程中遇到折叠不生效、查询数据对不上的问题,欢迎在评论区一起聊聊~~~
参考资料
版权声明
未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!
本文原文链接: https://fiveyoboy.com/articles/clickhouse-versioned-collapsing-mergetree/
备用原文链接: https://blog.fiveyoboy.com/articles/clickhouse-versioned-collapsing-mergetree/