TT Lab
开始
学习 学习路径 课程

ClickHouse — 从内部理解列式分析数据库

变更(mutation)、轻量 DELETE 与 TTL —— 如何改动不可变的数据分片

在 TT Lab 中继续学习

一句话总结

MergeTree 的数据片段一旦写出就不会改变。所以 UPDATE、DELETE 会变成整体重写数据片段的异步任务(变形,mutation),轻量级 DELETE 则只写下遮盖待删除行的标记,把实际的清除推迟给合并,而 TTL 是在合并发生时,去掉或汇总已过期的行、值的规则。必须明白,这三者都不是“现在立刻只改这一行”。

为什么需要它

在前面的模块中,我们看到数据片段不可变的设计让写入和压缩变得很快。代价是修改。个人信息删除请求、更正错误进入的状态值、清理陈旧日志,任何分析型数据库都会遇到。无法像行式数据库那样原地改几个字节,所以 ClickHouse 把这类需求分给了三个工具——少见而沉重的更正用变形,频繁的删除用轻量级 DELETE,能用规则表达的寿命管理用 TTL。官方文档的“Avoid mutations”建议尽量不要使用第一个工具,本模块的目标就是用数字看清其中的原因。

工作原理

左边有一个活动数据片段 all_1_1_1。ALTER UPDATE 被登记为变形(mutation),即使要改的行只有 2%,也要读取整个数据片段,写出新的数据片段 all_1_1_1_2,旧数据片段变为非活动状态。名称末尾的 2 是变形编号。中间是轻量级 DELETE。它不动数据片段的其他列,只是在隐藏列 _row_exists 中把待删除的行标记为 0,所以 SELECT 会遮盖这些行,但数据片段的 rows 并不会减少。右边是合并。OPTIMIZE FINAL 或后台合并会真正去掉被遮盖的行,同时 TTL 规则会删除已过期的行、把列的值改成默认值,或用 GROUP BY 汇总成一行

变形(mutation)。 ALTER TABLE t UPDATE ... WHERE ... 和 ALTER TABLE t DELETE WHERE ... 是文档有意让它们以 ALTER 开头的沉重命令。提交之后,system.mutations 中会出现一行,并在后台把含有相关行的数据片段重写,再以原子方式替换进去。默认是异步的,给出 mutations_sync = 2 则等到结束。在这个 Pod 上,把 40 万行数据片段中 2%(八千多行)的 status 修改后,数据片段名称从 all_1_1_1 变成了 all_1_1_1_2,而 system.part_log 中 MutatePart 的记录是:读取了 40 万行,又写出了 40 万行。名称末尾的 2 就是这次变形的编号。文档写明的几个性质——变形按提交顺序应用,只作用于提交之前进入的数据,无法撤销,重启服务器后也会接着运行。排序键、分区键的列不能 UPDATE。

失败的变形更可怕。提交把字符串邮箱用 toUInt32 转换的变形后,system.mutations 中留下了 is_done = 0 和 latest_fail_error_code_name = CANNOT_PARSE_TEXT,并且不会自行停止。之后用 mutations_sync = 2 提交的完好的 UPDATE,也被前一个挡住,以 UNFINISHED 错误返回。结束它的办法只有 KILL MUTATION。

轻量级 DELETE。DELETE FROM t WHERE ... 在内部会变成 UPDATE _row_exists = 0 WHERE ... 变形(在 system.mutations 的 command 中能原样看到)。如果是 Wide 数据片段,只会写隐藏列 _row_exists 这一个文件,其余列文件通过硬链接复用。此后 SELECT 会用 PREWHERE 应用这个掩码。在这个 Pod 上,删除用户 888 的 22 行之后,普通 SELECT 立刻是 0 行,SETTINGS apply_deleted_mask = 0 是 22 行,system.parts 的 rows 保持不变。行真正被去掉,是在用 OPTIMIZE ... FINAL 合并之后。文档也写道:“下一次合并时会被物理删除。”在有投影的表上默认会被拒绝,这一点在前面的模块中已经见过。

TTL。TTL 是附加在表或列上的规则,在合并时应用。如果没有合并,会按文档中的 merge_with_ttl_timeout(默认 4 小时)尝试 TTL 合并。有三种用法。

形态 示例 到期之后
列 TTL email String TTL event_date + INTERVAL 90 DAY 把该列的值变成类型默认值('')
行 TTL + WHERE TTL event_date + INTERVAL 180 DAY DELETE WHERE status = 'test' 删除符合条件的行
GROUP BY 汇总 TTL ts + INTERVAL 30 DAY GROUP BY user_id, toStartOfDay(ts) SET ... 把多行汇总成一行

MODIFY TTL 或带有 TTL 的 MODIFY COLUMN,默认(materialize_ttl_after_modify = 1)会同时生成对现有数据片段应用的 MATERIALIZE TTL 变形——也就是说,修改 TTL 也是重写数据片段的工作。GROUP BY 汇总的分组列必须是主键的前缀。在这个 Pod 上,2024 年 3 月的访问记录 20 万行,刚 INSERT 之后仍是 20 万行,OPTIMIZE FINAL 之后缩减为(用户, 日期)15,500 行。

还要知道,TTL 表达式以 now() 为基准,所以结果会随时间变化。本实验把数据全部放在 2024 年,对法定保留的行用 if(legal_hold = 1, toDate('2100-01-01'), ...) 给出遥远的未来,这样无论评分是什么时候,同样的行都会得到同样的判定。

在现场相遇的样子

“GDPR 删除请求一天有几百条,每一条都执行一次 ALTER DELETE”这样的设计,很快就会让变形队列堆积。表的所有变形会依次运行,只要有一个因失败而停住,后面的全部被挡住。如果删除很频繁,应先考虑轻量级 DELETE、ReplacingMergeTree 的删除标记,以及按分区 DROP。

第二种是“做了轻量级 DELETE,磁盘应该空出来了”这样的误解。只写了掩码,所以空间要到合并之后才会回来。如果在法律上必须承诺“在多久之内物理删除”,就像文档建议的那样,使用 min_age_to_force_merge_seconds 或 ALTER DELETE。

第三种是“TTL 没生效”的反馈。TTL 只在合并时应用,所以在安静的表上可能会晚几个小时。确认时用 MATERIALIZE TTL 立刻应用,平时则如文档建议,按与 TTL 相同的时间单位划分分区,让它整个脱落。

下一项实验要做什么

把 mut.events 的 40 万行做成一个数据片段之后,用 part_log 确认 ALTER UPDATE 会整体重写数据片段。用户 777 用 ALTER DELETE 删除,用户 888 用轻量级 DELETE 删除,数一数掩码与物理删除的差别。应用 email 列 TTL 和 test 行 TTL,用 GROUP BY TTL 汇总访问记录,最后记录一个失败而停住的变形,并把它 KILL 掉。