在 ClickHouse 上补一列历史数据:一次只读评审
目录
同事发来一份补数方案,让我在执行前帮忙看看。事情不复杂:一张 1300 亿行、19.54 TiB 的 ReplicatedMergeTree 表,有一列连续 18 天被写成了 0,要把这 109 万行改回正确值。
方案主体是对的:用 ALTER TABLE … UPDATE 逐个分区原地改,而不是把正确数据重新 INSERT 一遍。我最后提的意见几乎全落在回滚那一节:它的回滚谓词会连带清掉十万行本来就正确的数据,而且这件事一旦补数跑完就再也查不出来了。下面按我看方案的顺序记。
背景#
出问题的是一次上线漏了一行映射代码,placed_at(下单时间)没有被赋值,于是这一列在连续 18 天里都是 0。代码侧已经修好上线,窗口闭合了,剩下的只有历史数据。
表的结构(列名和表名我改写过,键的位置和列数是原样):
CREATE TABLE analytics.events
(
id String,
source_id UInt32,
placed_at UInt64, -- 出问题的列,毫秒时间戳
settled_at UInt64, -- partition key + primary key 首列
retry_num Int8,
ext_id String,
group_id String,
... -- 共 39 列
)
ENGINE = ReplicatedMergeTree
PARTITION BY toYYYYMMDD(toDateTime(settled_at / 1000))
ORDER BY (settled_at, id, retry_num)
SETTINGS storage_policy = 'tiered'
几个先交代清楚的前提,后面所有判断都建立在这上面:
- 全表 810 个分区、约 1300 亿行、19.54 TiB,3 副本。存储策略是
tiered,其中 764 个分区、17.41 TiB 已经落在 remote(对象存储)那一层,本地盘上最旧的分区停在两个月前; - 要补的 1,091,487 行分布在连续 18 个日分区里,只有一列是错的;
- 方案在我写这篇时(2026-08-28)还没有执行。 下面的数字全部来自评审当天(2026-08-27)用强制只读的连接(URL 参数
readonly=2)查system表和跑聚合查询量出来的,没有执行过任何写语句。所以「补完之后怎么样」这一段我没有,只有「补之前能看出什么」; - 服务端版本 25.3.14.1,
timezone()是 UTC。分区表达式里的toDateTime用的是服务器时区,方案里那张 epoch 边界表能对上,靠的就是这一条。
为什么不重新插一遍#
第一个想法通常是把这 109 万笔的正确值从上游日志捞出来重新 INSERT,手上也有一条现成的补数接口。
但这张表是普通的 ReplicatedMergeTree,主键不是唯一约束。MergeTree 文档写得很直接:
ClickHouse does not require a unique primary key. You can insert multiple rows with the same primary key.
插一行 id 已存在的数据,得到的就是两行。对一张流水表来说,营收类指标会直接翻倍,而且这种重复没有办法事后靠一条谓词摘干净。
换成 ReplacingMergeTree 也不解决我这个问题:合并是后台异步发生的,没有时间保证;查询要写 FINAL 或者自己按键聚合才能看到合并后的结果,等于要改所有下游查询;而且它按排序键替换整行,需要凑出完整的一整行——我这里只有一列是错的。
那条补数接口是为「缺失的行」准备的。一列值写错了是另一类问题,得换个工具。
mutation 重写的是哪些文件#
ALTER TABLE … UPDATE 在 ClickHouse 里不是 OLTP 意义上的 UPDATE。文档自己就在解释这个前缀:
The
ALTER TABLEprefix makes this syntax different from most other systems supporting SQL. It is intended to signify that unlike similar queries in OLTP databases this is a heavy operation not designed for frequent use.
它被实现成 mutation,是「asynchronous background processes similar to merges」。语句提交后立刻返回,实际工作在后台的 merge/mutation 线程池里做。
有一句我原来理解错了,是查文档时才改过来的:并没有查询级的原子性。
There is no atomicity — parts are substituted for mutated parts as soon as they are ready and a SELECT query that started executing during a mutation will see data from parts that have already been mutated along with data from parts that have not been mutated yet.
也就是说替换是按 part 进行的,一个正在跑的 SELECT 可能同时看到改过和没改过的 part。对报表列来说这个中间态可以接受,但得写进方案里,而不是含糊地说一句「原子」。
成本取决于被改的列在哪里。同一个 ALTER 文档里还有一句硬约束:
Updating columns that are used in the calculation of the primary or the partition key is not supported.
所以只有两种情况:列在 primary key 或 partition key 上,直接不支持;列不在这两个键上,ClickHouse 只重写这一列的文件,其余列以 hardlink 的方式挂到新 part 上,不重新排序。ALTER 文档里「rewriting whole data parts」的说法容易读重:它说的是替换以 part 为粒度进行,不是每一列的字节都要重写。列级的行为在官方博客 How we built fast UPDATEs for the ClickHouse column store 里写得很直接:
the updated columns … are fully rewritten, and the unchanged columns … are hard linked. No data is copied for those columns, the new part simply reuses the same underlying files via hard links on disk.
我要改的 placed_at 不在任何键上,属于能改的那一类;而 settled_at 既是 partition key 又是 primary key 首列,本来也不能碰。
656 MiB"] O2["其余 38 列的文件"] end subgraph NEW["新 part:ready 之后替换掉旧 part"] N1["placed_at:重新写一份
656 MiB"] N2["其余 38 列"] end O1 -->|"读旧值算出新值"| N1 N2 -->|"hardlink,不复制字节"| O2
于是这笔账是这样的:
| 量 | 值 |
|---|---|
| 一个目标分区 | 324,026,400 行 / 49.95 GiB |
其中 placed_at 一列 |
656.42 MiB |
| 18 个分区总体积 | 855.89 GiB |
| 18 个分区该列合计 | 10.94 GiB |
856 GiB 里实际重写 11 GiB。这个比例是方案能成立的原因,也是我认为它比重新 INSERT 更值得走的地方:原地改一列,行数不变,行也不会跨分区移动,因为 partition key 根本没被写。
一个前提要写明:按列 hardlink 只对 Wide 格式的 part 成立。Compact 格式把所有列放在同一个文件里,改一列也得整个重写;part 超过 min_bytes_for_wide_part 之后才按 Wide 存储。这次的目标 part 动辄几十 GiB,不太可能是 Compact,但这件事只读就能确认,预检查里值得加一条:
SELECT part_type, count() FROM system.parts
WHERE active AND database = ? AND table = ? GROUP BY part_type;
这笔账怎么从 system 表里算出来#
这几条查询是我这次真正用上的,只读账号就能跑:
-- 引擎、键、存储策略,以及容易漏掉的一项:有没有物化视图挂着
SELECT engine, partition_key, sorting_key, storage_policy, dependencies_table
FROM system.tables WHERE database = ? AND name = ?;
-- 每个分区:多大、多少 part、在哪块盘上、最近还在动吗
SELECT partition, count() AS parts, sum(rows) AS rows,
formatReadableSize(sum(bytes_on_disk)) AS size,
groupUniqArray(disk_name) AS disks,
max(modification_time) AS newest_part
FROM system.parts
WHERE active AND database = ? AND table = ?
GROUP BY partition ORDER BY partition;
-- 那一列自己占多少,也就是 mutation 要重写的量
SELECT partition, formatReadableSize(sum(column_bytes_on_disk)) AS col_size
FROM system.parts_columns
WHERE active AND database = ? AND table = ? AND column = 'placed_at'
GROUP BY partition ORDER BY partition;
-- 还有什么会被连带重建
SELECT name, type, expr FROM system.data_skipping_indices
WHERE database = ? AND table = ?;
SELECT count() FROM system.projection_parts
WHERE database = ? AND table = ? AND active;
-- 线程池多大、现在忙不忙、以前有没有人动过这张表
SELECT metric, value FROM system.metrics
WHERE metric LIKE 'BackgroundMergesAndMutations%';
SELECT count() FROM system.merges;
SELECT mutation_id, command, create_time, is_done, latest_fail_reason
FROM system.mutations WHERE database = ? AND table = ? ORDER BY create_time;
system.parts_columns.column_bytes_on_disk 是这里的关键一列:它精确到「某个分区里某一列占多少字节」,正好就是列级 mutation 的写入量。有它就不用估。
system.mutations 的历史也值得看一眼。这张表 2024 年 10 到 11 月有过 7 次 mutation,全部 is_done = 1、没有失败记录。不过要说准确:它们的形态都是 UPDATE _row_exists = 0 … WHERE … id IN ('…','…',…),也就是 lightweight delete,重写的只是 _row_exists 这一列掩码,不碰业务列的文件。所以它们算不上「列级重写在这张表上跑过」的先例,但确实说明这个集群上的 mutation 机制是通的,而且——后面会用到——这种把 id 列表写成字面量的写法有人用过。
另外有一组我没有从方案里照抄,而是自己量的:谓词里带不带 String 列,扫描代价差挺多。18 天的零值统计,不比较任何 String 列时一次跑完 93 秒;加上 ext_id = group_id 这个比较之后,每 6 天一段要跑 57 到 72 秒,折算下来每天 9.5 到 12 秒,对比前者的 5 秒出头,代价大约翻倍。这不是控制变量的 benchmark(max_threads 都是 4,但天数、命中行数都不一样),只能说明方向:昂贵的字符串守卫留在预检查的 SELECT 里,别放进 mutation 的 WHERE,因为 mutation 的谓词每个 part 都要付一次。
忘了 IN PARTITION 会发生什么#
placed_at = 0 这个条件和 partition key 没有任何关系,所以它不能帮 ClickHouse 剪分区。不写 IN PARTITION,这条 mutation 就会对全表 810 个分区排队。
写了 IN PARTITION |
忘写 | |
|---|---|---|
| 涉及 part | 1 个分区,5 到 12 个 part | 810 个分区,3,678 个 part |
| 涉及数据 | 49.95 GiB,其中实际重写 656 MiB | 19.54 TiB |
| 其中在对象存储上 | 0 | 17.41 TiB |
最后一行是我最在意的。tiered 策略会把旧分区搬到 remote 那一层,漏了 IN PARTITION,remote 上那 764 个分区、17.41 TiB 也全部进 mutation 的队列。按前面的列级重写模型,要重写的仍然只是 placed_at 一列加上读谓词的列,不是 17 TiB 全部搬一遍;但这些读写都发生在对象存储上,hardlink 在 remote 盘上怎么落地我没有实测。不管按哪种口径算,波及范围都从 18 个本地分区变成了全部 810 个。而且漏写不报错——语句语法完全合法,只是干的活多了三个数量级。英文里管这种设计叫 footgun。
顺带一个小发现:全表还有一个 19700101 分区,里面 2,139 行,明显是历史上某批 settled_at = 0 的脏数据。全表 mutation 会连它一起扫。
我给方案加的约束是:18 条语句提前生成好、复核一遍,执行时只粘贴不现场手敲;预检查里加一条 SELECT DISTINCT disk_name FROM system.parts WHERE partition = '…',确认这个分区还在本地盘上。第二条不是洁癖——本地盘上最旧的分区停在两个月前,这批数据早晚会被搬走,方案拖久了成本模型就变了。
正确的值从哪来#
方案最初假设要从上游 CSV 或日志回捞每一笔的时间戳。后来发现不用:这类调用里下单和结算是同一个动作,上游只给一个时间戳,所以 placed_at 本该等于 settled_at,值就在同一行里。
不过「本该」不算证据。真正让我接受这个结论的是一段意外的窗口:修复代码曾在这 18 天中间误上线过 13 小时又被回滚,那期间写入的 38,846 行,placed_at 和 settled_at 逐行相等,没有例外。于是补数不需要任何外部数据源:
ALTER TABLE analytics.events
UPDATE placed_at = settled_at
IN PARTITION 20260804
WHERE source_id = 76 AND placed_at = 0;
这个套路我觉得可以带走:补数之前先找一段「行为正确的时期」——一次部署窗口、一个没受影响的分区、另一个区域的集群。如果不变量在那段真实数据上严格成立,就能从行内推导正确值,省掉一整条外部回灌管道。找这段数据通常比建管道便宜得多。
也有代价,得写进方案里:这样补出来的值有 0.12 到 1.2 秒的系统性偏差(坏行里存的是接收时间,不是上游时间,这是从请求日志抽 2000 条样本比出来的)。这个偏差在那 13 小时的边界上本来就存在于数据里,补数没有创造也没有放大它。要不要接受,交给用这列数据的人判断,而不是我在方案里替他们决定。
谓词命中的到底是哪些行#
补数谓词是 source_id = 76 AND placed_at = 0。怎么确认它只命中想改的行?
我的做法是把它写成两个集合的差。设 T 是意图修改的集合(这里是所有单发调用产生的行),P 是谓词实际命中的集合:
- P \ T:谓词命中了意图之外的行。这是危险的一种,意味着会改错数据。
- T \ P:意图之内但谓词没命中的行,通常因为它们本来就是对的。这种无害,但要数清楚——它会影响事后的断言,也会影响回滚。
一条查询同时把两边和几个异常项数出来:
SELECT toYYYYMMDD(toDateTime(intDiv(settled_at, 1000))) AS d,
countIf(placed_at = 0 AND ext_id != group_id) AS p_minus_t,
countIf(placed_at != 0 AND ext_id = group_id) AS t_minus_p,
countIf(placed_at != 0 AND ext_id = group_id
AND placed_at = settled_at) AS t_minus_p_eq,
countIf(placed_at = 0 AND (status != 1 OR retry_num != 0)) AS odd_rows
FROM analytics.events
WHERE settled_at >= ? AND settled_at < ? AND source_id = 76
GROUP BY d ORDER BY d;
结果:p_minus_t 在 18 个分区上都是 0,odd_rows 也都是 0(没有未结算的行、没有重试行)。前者顺带说明那个字符串守卫是冗余的,可以从 mutation 里删掉,省下上面说的读放大。
t_minus_p 不为 0:105,724 行,集中在三个分区上。这一条本来看着无害,结果是这次评审里最贵的问题。
回滚谓词是这份方案里最贵的问题#
方案原来的回滚是这样写的:
ALTER TABLE analytics.events UPDATE placed_at = 0
IN PARTITION 20260814
WHERE source_id = 76 AND placed_at = settled_at;
思路看着没问题:补数把 placed_at 改成等于 settled_at,撤销就是把两者相等的行清回 0。
问题是「两者相等」不是补数行独有的特征。那 105,724 行本来就相等,来源是三类:老版本代码写的(11,777 行)、修复误上线那 13 小时写的(38,846 行)、修复正式上线之后写的(55,101 行)。方案作者发现了后两个分区并加了豁免,漏掉的是第一个。按原写法在那个分区上回滚,一万多行正确数据会被一起清零。
更要紧的是时序:
placed_at = 0"] -->|"被补数改写"| A1["placed_at = settled_at"] B2["本来就相等的 105,724 行
placed_at = settled_at"] -->|"谓词命中不到,不变"| A2["placed_at = settled_at"] IDS["补数前抓下的 id 名单"] -.->|"事后区分两者的唯一依据"| A1
补数之前,两类行可以用 placed_at = 0 干净地分开。补数之后,它们在这两列上完全一样,没有任何查询能事后把它们分开——区分它们的信息正是补数覆盖掉的那一列。
一次修复如果会覆盖掉「自己改过什么」的证据,那么这个证据只能在动手之前记下来。
具体做法是回滚按身份而不是按状态。补数之前先把名单抓到服务端的一张表里:
CREATE TABLE analytics.patch_ids (p UInt32, id String)
ENGINE = MergeTree ORDER BY (p, id);
INSERT INTO analytics.patch_ids
SELECT toYYYYMMDD(toDateTime(intDiv(settled_at, 1000))) AS p, id
FROM analytics.events
WHERE settled_at >= ? AND settled_at < ?
AND source_id = 76 AND placed_at = 0;
109 万行的名单,估算是几十 MB 的量级;这一步和方案里其他写操作一样没有执行过,实际耗时要到执行时才知道。名单要覆盖全部 18 个分区,不只是那三个混着好数据的——名单便宜,误杀不便宜。抓完之后冻结这张表,在全部验证签收之前不再写入。
为什么不能像方案原来那样,用客户端 SELECT id 导出成文件:一个分区 5 到 8 万个 UUID,大约 2 到 3 MB,塞不进一条语句。我在这个集群上量到 max_query_size 是 262144(256 KiB)。
回滚谓词于是变成四部分:
| 谓词 | 作用 |
|---|---|
id IN (名单) |
身份:只有补数碰过的行在册 |
placed_at = settled_at |
状态守卫:只撤当前仍是补数值的行,跑第二遍命中 0 行,天然幂等 |
retry_num = 0 |
钉住行身份,id 在重试行之间是复用的 |
source_id = 76 和 IN PARTITION |
冗余防护 |
评审到这里我的结论是:补数方案里最该被反复看的是回滚,不是补数本身。 补数谓词写错了,通常改不动数据,或者一跑断言就露出来;回滚谓词写错了,是在故障之上再造一次故障,而且第二次没有回滚可用。
复制表上的 mutation 是三次独立执行#
复制表的 mutation 不是主副本改完再同步过去,而是命令文本经 Keeper 分发,三台各自执行一遍。有两个后果。
验收要问遍三台。system.mutations 是副本本地的视图,而生产连接一般经过负载均衡器。我连着发四次 SELECT hostName(),四次都落在同一个节点上——那台报告 is_done,说明不了另外两台。clusterAllReplicas 的文档写的是「same as cluster, but all replicas are queried」,正好用来一次问遍:
SELECT hostName() AS replica, mutation_id, is_done, parts_to_do, latest_fail_reason
FROM clusterAllReplicas('default', system.mutations)
WHERE database = ? AND table = ? AND mutation_id = ?;
每条 ALTER 提交后先在 system.mutations 里按 command 对出它的 mutation_id 记下来,验收按 id 查:三个副本都返回 is_done = 1 才算这条 mutation 完成。只用 NOT is_done 数零行有个盲区——KILL MUTATION 的文档语义是「cancel and remove」,被杀掉的 mutation 也会从列表里消失,零行分不清「跑完了」和「被杀了」。这个表函数在我这个只读账号上是可用的,我用它顺手核过一次三副本的一致性。
谓词里引用别的表,要额外开开关。上一节的回滚要用名单,最自然的写法是 id IN (SELECT id FROM patch_ids WHERE p = ?)。ClickHouse 会拒绝,除非打开 allow_nondeterministic_mutations(我在这个集群上查到默认值是 0,可以按查询覆盖)。
我的理解是这个拒绝有道理:三台各自执行,如果谓词依赖另一张表的内容,而那张表在三台执行的时刻内容不同(有人插了数据、或者某台还没同步完),三台就会改出不同的结果集,副本之间的数据悄悄不一致。这种损坏不报错,可能很久以后才被发现。这一段是我自己的推理,官方文档里这个设置的说明页我没有找到(写这篇时那个 anchor 打不开),所以不当成引用。
DBA 的意见是不开这个开关,我同意。更省事的做法是让名单变成语句本身的一部分:
-- 用名单表生成回滚语句,id 以字面量写进去
SELECT concat(
'ALTER TABLE analytics.events UPDATE placed_at = 0 ',
'IN PARTITION ', toString(p), ' ',
'WHERE source_id = 76 AND retry_num = 0 AND placed_at = settled_at ',
'AND id IN (\'', arrayStringConcat(groupArray(id), '\',\''), '\');'
) AS stmt
FROM analytics.patch_ids
WHERE p = ?
GROUP BY p, cityHash64(id) % 20
FORMAT TSVRaw;
语句文本经 Keeper 复制,三台执行的必然是同一份名单——确定性由构造保证,不依赖谁记得先同步。切 20 批是为了同时躲开两个上限:一个是上面量到的 max_query_size 256 KiB,最多的一天 8 万个 UUID 切 20 批约 4000 个一条、160 KB 左右——不过 cityHash64(id) % 20 只是期望上均分,不保证每批都压在线下,语句生成完按字节数复核一遍再执行;另一个是 Keeper 单条目的大小限制,ZooKeeper 的 jute.maxbuffer 默认是 1048575 字节(just under 1M),ClickHouse Keeper 这边的对应上限我没有实测。
字面量拼接还有一个没写进 SQL 的前提:id 在表结构里是 String,实际内容得是 UUID 这类只含安全字符的文本,拼出来的语句才不用考虑转义——一个带单引号或反斜杠的 id 就能破坏整条语句。名单表冻结后值得加一条断言,返回 0 再继续生成:
SELECT count() FROM analytics.patch_ids
WHERE NOT match(id, '^[0-9a-fA-F-]{36}$');
这也正好是 2024 年那 7 次 lightweight delete 的形态。发现自己「想出来」的写法和前人留下的痕迹一致,通常是个好迹象。
断言写在哪一层#
两件事让我把方案里的断言改了。
第一,mutation 只覆盖排队时已经存在的 part。ALTER 文档里那句是:
Mutations are also partially ordered with INSERT INTO queries: data that was inserted into the table before the mutation was submitted will be mutated and data that was inserted after that will not be mutated.
补数窗口在过去,但迟到数据确实存在——我在评审当天就看到 08-26、08-27 各新增了一个只有 1 行的 part 落在窗口内的老分区上(值是正确的)。所以每个分区补完都要复查坏行是否为 0,有就重跑那个分区。这也让流程天然幂等:谓词只碰 placed_at = 0 的行,重跑不会误伤。
第二,行数会自己变动。system.part_log 显示 08-26 一天就有 4 次 merge 重排了约 4.09 亿行,都落在这 18 个分区里,属于正常的后台维护。要说清楚的是 part_log 只是我连上的那个节点的视角,绝对量有偏差,但足以说明这些老分区还在被动。
结果就是「总行数不变」这种断言会被合法地打破。我建议换成:
- 目标指标归零:
countIf(placed_at = 0) = 0 - 差值断言:
eq_settle(后) − eq_settle(前) == to_patch,其中eq_settle是方案里的记号,指countIf(placed_at = settled_at);「前」的值要在预检查里先记下来 - 总行数只作参考;差几行先去
system.parts看有没有新 part,不要直接当事故回滚
原方案的验证是「eq_settle 增量等于补数行数」加「总行数不变」,前者在混着好数据的分区上会误报,后者在活表上会误报。断言建立在「我改的那件事」上比建立在「表的全局状态」上可靠,因为活表的全局状态本来就在动。
克隆可以用来验证,但不要 REPLACE PARTITION#
方案里有一节想得挺好:用 ATTACH PARTITION … FROM 把一个分区搬到同结构的克隆表上,在克隆上跑一遍一模一样的 mutation,然后做行级 diff。分区操作文档里这条的关键性质是源表不动——「Data will be deleted neither from table1 nor from table2」,另外两表必须结构、partition key、order by、primary key、storage policy 全都一致,目标表还要包含源表全部的 indices 和 projections——这张表有 6 个 data skipping index,克隆表建表时漏了它们,这一步就过不去。
方案里说这一步「秒级、几乎不占额外磁盘」,理由是 hardlink。文档这一页的措辞是 copy,hardlink 是实现细节,而我没有执行权限、也没有实测,所以我把这句改成了「需要 DBA 在克隆上确认」。另外在托管服务上 MergeTree 可能被自动创建成 ReplicatedMergeTree,ATTACH 之前先看一眼引擎,不匹配会快速失败。
真正需要拦下来的是另一半:方案提到可以在克隆上验证完之后,用 REPLACE PARTITION 把克隆换回生产。这一招看着最安全,实际上最危险。同一页文档对它的定义是「copies the data partition from table1 to table2 and replaces the existing partition in table2」——目标分区被克隆里的数据整体换掉。克隆是 ATTACH 那一刻的快照,从 ATTACH 到 REPLACE 之间插入生产表的行不在克隆里,替换之后也就没有了。而上一节说了,迟到行是真实存在的。
克隆适合用来验证,实际修改还是用原地 mutation:它不会增行、不会减行、也不会让行跨分区移动。
快照也一样。方案想留一份补数前的分区快照,我加了一句「验证完就删」:后台合并会让两边的 part 逐渐分叉,快照持有的旧 part 越滚越多,「不占空间」只是刚做完那一刻的状态。
物化视图看不到 mutation#
ClickHouse 的增量物化视图本质是 INSERT 触发器,文档的原话是「just a trigger that runs a query on blocks of data as they’re inserted into a table」。mutation 不走 INSERT 通道,所以挂在表上的增量物化视图不会被触发,已经把这段数据聚合出去的下游表也不会自动更新(refreshable materialized view 是按计划整体重算的,不在这个讨论里)。补数之前值得查一次:
SELECT dependencies_database, dependencies_table
FROM system.tables WHERE database = ? AND name = ?;
这次的结果算运气好:整台服务器一个物化视图都没有,system.projection_parts 里 0 个 active projection,6 个 data skipping index 也没有一个包含 placed_at。所以补完只需要重跑报表,不用回填任何物化产物,也不会连带重建索引。
但这个检查还是得做。有 MV 而不处理,等于只修好一半:明细对了,聚合还是错的,而且这种不一致往往要过很久才被发现。
只读能评审到什么程度#
这次评审我全程只有 SELECT 权限,事后回头看,能验的东西比我预想的多:分区体积、列体积、两种泄漏、105,724 行的来源分解、副本状态、后台负载、历史 mutation、服务器时区、max_query_size、有没有物化视图和 projection——都在 system 表里。一份破坏性数据修复方案的大部分内容,可以在没有写权限的情况下被证伪。
真正没法只读验证的只有两件:执行账号到底有没有写权限,以及 ATTACH PARTITION … FROM 的引擎相容性。这两件都会快速失败,风险可控。
所以下次评审别人的数据修复方案,我不会先等写权限。先把 system 表读一遍,代价最大的那个问题往往就在里面。
小结#
- 先弄清引擎的语义再选路线:MergeTree 的 primary key 不做唯一约束,补插一行就是多一行,这条路在流水表上基本不能走;
ALTER … UPDATE是 mutation,只有列不在 primary key 和 partition key 上时才是「重写一列 + hardlink 其余」(Wide part 下);在这两个键上的列,文档写的是不支持;- 成本从
system.parts_columns里算,不要估。这次是 856 GiB 的分区里重写 11 GiB; - 谓词不会剪分区,
IN PARTITION是安全装置而不是优化,在 tiered 存储上尤其如此; - 我这次改动最大的一条:补数之前抓 id 名单,回滚按身份而不是按状态。 补数会覆盖掉区分「补出来的」和「本来就对的」那一列信息,事后再想分开就来不及了;
- 复制表的 mutation 三台各自执行,验收用
clusterAllReplicas;要按名单回滚就把 id 写成字面量,不必去开allow_nondeterministic_mutations; - 活表上的断言选「目标指标归零 + 差值断言」,总行数只作参考。
参考资料#
- ALTER TABLE … UPDATE —— heavy operation、
IN PARTITION、primary/partition key 列不支持更新 - ALTER:Mutations —— 异步后台执行、没有查询级原子性、与 INSERT 的偏序关系
- MergeTree —— primary key 不要求唯一、稀疏索引按 granule 工作
- Manipulating Partitions and Parts ——
ATTACH PARTITION FROM/REPLACE PARTITION的语义与结构约束 - cluster / clusterAllReplicas —— 查遍所有副本
- How we built fast UPDATEs for the ClickHouse column store – Part 2: SQL-style UPDATEs —— mutation 只重写被改的列、未改列 hardlink 的一手出处
- Incremental materialized view —— 物化视图是 INSERT 触发器,不响应 mutation
- ZooKeeper Administrator’s Guide ——
jute.maxbuffer,znode 单条数据的默认上限