变更(mutation)、轻量 DELETE 与 TTL —— 如何改动不可变的数据分片
一句话总结
MergeTree 的数据片段一旦写出就不会改变。所以 UPDATE、DELETE 会变成整体重写数据片段的异步任务(变形,mutation),轻量级 DELETE 则只写下遮盖待删除行的标记,把实际的清除推迟给合并,而 TTL 是在合并发生时,去掉或汇总已过期的行、值的规则。必须明白,这三者都不是“现在立刻只改这一行”。
为什么需要它
在前面的模块中,我们看到数据片段不可变的设计让写入和压缩变得很快。代价是修改。个人信息删除请求、更正错误进入的状态值、清理陈旧日志,任何分析型数据库都会遇到。无法像行式数据库那样原地改几个字节,所以 ClickHouse 把这类需求分给了三个工具——少见而沉重的更正用变形,频繁的删除用轻量级 DELETE,能用规则表达的寿命管理用 TTL。官方文档的“Avoid mutations”建议尽量不要使用第一个工具,本模块的目标就是用数字看清其中的原因。
工作原理
变形(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 掉。