背景#

数字对不上时,行数可能完全对得上,报表上的金额、人数却和另一边差一截,而且没有任何报错。排查按这个顺序走:先确认两边比的是同一条时间轴,再看 JOIN 有没有放大行数,再看聚合口径;之后才轮到上游字段、FINAL 和报表这些和这条链路有关的原因。

前四种情形在 clickhouse-lab 的实验 09、10 里复现过(25.3.14.14,服务端时区 UTC),全部不报错;FINAL 和报表两项的依据是实验 14 和实验 24、25。行数少了、数据没进来的那一类,排查顺序在数据少了那篇。

两边比的是不是同一条时间轴#

第一件事是确认两边用的是同一个时间:事件时间还是写入时间,按哪个时区切天。

DateTime 列存的是和时区无关的 Unix 时间戳,时区记在列的类型里,决定显示、解析,以及 toDate、toYYYYMMDD 这类日历函数按哪个墙钟截断;列上没写时区,就用服务端启动时的时区(DateTime)。所以同一个时刻在两种时区声明下,可能落进不同的分区:按 UTC 和按上海(+8:00)声明,UTC 16 点到 24 点之间的时刻会落进相邻的两天。

实验 10 建了两张表,只差 ts 列的时区声明,分区键都是 toYYYYMMDD(ts),写进去的是同样两个绝对时刻:

表 列类型 2026-03-10 23:30:00 UTC 落在 2026-03-10 10:00:00 UTC 落在
tz_utc DateTime('UTC') 20260310 20260310
tz_sh DateTime('Asia/Shanghai') 20260311(显示为 3 月 11 日 07:30) 20260310

两张表行数一样、分区键表达式一样,按事件日 20260310 查,tz_utc 返回 2 行,tz_sh 只返回 1 行,另一行在 20260311 里。两边都自称查的是 3 月 10 日。

明细表的分区键是 toYYYYMMDD(toDateTime(settle_ms / 1000)),toDateTime 没带时区,用的是服务端时区。报表方案按 UTC 的桶切天(还没部署到现有集群),明细和报表要按同一个时区切天,修一天才只碰报表的一个分区(报表那篇)。补数评审时查过的那套部署 timezone() 是 UTC(补数评审那篇),其余几套值得各查一次。

现有集群上还有一处时间轴的错位是现成的:现在的聚合表按写入时间 create_time 的窗口累加,明细按 settle_ms 分区。create_time 比结算时间晚几秒到几分钟,人工补发的晚好几天,跨零点的行和补发的行在两边落在不同的天,按天直接比会对不上。

JOIN 有没有放大行数#

把明细和一张维度表 JOIN 之后金额变多,先把右表的键单独数一遍唯一性。普通的 INNER JOIN 对匹配的键做笛卡尔积,右表一个键有两行,左表这一行就变成两行(JOIN)。实验 10 里左表 3 个键各 1 行,右表同样 3 个键、每个 2 行:

JOIN 结果行数
INNER 6
LEFT 6
ANY INNER 3
ANY LEFT 3

ANY 不放大,留下右表哪一行由 join_any_take_last_row 决定。默认 0 时取右表先出现的那行,lab 里三个键取到的都是 v1;设成 1 之后全部变成后出现的 v2。25.3 源码里这个设置的说明写的是只对 Join 引擎的表生效(Settings.cpp),lab 上右表是普通子查询、走的是 hash join,照样被它翻转了。当前版本的设置文档已经改成对 Join 引擎表和 hash 类的 join 都生效,并提醒 join 并行构建时行序不确定,设成 1 可能取到不确定的行(join_any_take_last_row)。

所以不要依赖「ANY JOIN 取第一行」。要确定性,就在右表上自己用排序列加 LIMIT 1 BY 去掉重复的键,再 JOIN。

去重计数用的是不是 uniq#

uniq 用的是自适应采样,结果是近似值;文档推荐在大多数场景里用它(uniq)。对账要精确值,只能用 uniqExact。实验 10 在 25.3 上测到的误差:

基数 uniq 误差
10 万 100315 +0.315%
100 万 1001943 +0.194%
1000 万 9983940 −0.161%

误差两个方向都有,不是固定偏大或偏小,没法乘一个系数修正回去。一份报表里两种函数混用过,两边的去重人数就怎么也对不上。

上游改了字段名,整列变成默认值#

sink 写 schemaless JSON 时走的是 JSONEachRow,源码注释写明不校验 schema(ClickHouseWriter.java:1118-1125):上游少发一个字段,这一列用 ClickHouse 的默认值补;多出来的字段直接丢掉;不报错,也不进 DLQ。实验 09 C 里一条记录把字段名 val 写成了 vall,这一行照样写进来了,val 是默认值 0,DLQ 里什么都没有。

上游改了字段名、少发了一个字段,都是这种情况:行数对得上,可疑的那一列是一片 0 或空串。只数行数的对账发现不了,要抽几列看值的分布,比如按天数一下 countIf(列 = 0) 有没有突然变多。

读 ReplacingMergeTree 有没有带 FINAL#

明细表换成 ReplacingMergeTree 之后(方案,现有集群还没换),重复在后台合并时才折掉,合并的时机不由查询方决定。查询不带 FINAL(或者用户的 profile 没设 final = 1),读到的可能是同一笔交易的两份,这一笔的金额算了两次。FINAL 的代价和它跟手写去重的比较在查询路径那篇。

报表和明细逐桶对一遍#

报表如果由物化视图喂,就和明细 FINAL 按同样的桶逐桶对一遍。物化视图在插入时就把行累加进报表,之后的合并它看不到:块级去重窗口外的重复、靠重发新版本来改的数据,明细 FINAL 读出来是对的,报表却两份都算了(实验 24、25)。对账查询和重算步骤在报表的对账和重算;明细这边数重复的口径(键带上 settle_ms 和 rev、按小时切窗、只用 uniqExact)写在 REPLACE PARTITION runbook。

上面这些都排完仍然不对,才去查设置差异和版本差异。

小结#

  • 数字对不上先查时间轴:DateTime 的时区在列的类型里,UTC 16 点到 24 点之间的时刻,在 UTC 和上海两种声明下会落进相邻两天的分区;现有集群上的聚合按 create_time 切、明细按 settle_ms 分区,两边的天本来就对不齐。
  • JOIN 先查右表键是否唯一。INNER 和 LEFT 会放大,ANY 留下哪一行取决于 join_any_take_last_row,要确定性就自己 LIMIT 1 BY。
  • 对账只用 uniqExact,uniq 在 25.3 上的误差两个方向都有。
  • schemaless JSON 不校验字段,上游改了字段名,那一列会静默地变成默认值。
  • 明细换成 ReplacingMergeTree 后要带 FINAL 读;报表要和明细 FINAL 逐桶对账。

参考资料#

相关文章#