缺一个汇率,对一笔销售做了什么
App Store 的收入以各结算币种到账。归一化只需要一次对着每日汇率表的 ASOF JOIN:取交易当日或之前最近的一条汇率,相乘,产出 revenue_usd。用 ASOF 是刻意的——在抓取失败的那天沿用上一次已知汇率,好过让一笔销售因为缺一行而消失。
这个设计里的缺口,是当表里对这个币种在当日或之前完全没有汇率时会发生什么。
ASOF LEFT JOIN dim_fx_rate_deduped AS fx
ON e.currency = fx.currency
AND e.timestamp >= fx.rate_date
匹配不到的 LEFT ASOF JOIN 产出 NULL。revenue_usd 变成 NULL。而这里有一点我们花了好几个月才真正接受:收入列上的 NULL 不是图上缺一根柱子——它是一笔任何聚合都不会再计入的销售。 sum() 跳过它。每一个 USD 汇总、每一块看板、每一条 LTV 曲线、每一个 ROAS 数字——它们全都正确地在一个已经不包含这笔交易的集合上计算。没有错误抛出,没有作业失败。管道是绿的,数字是错的。
一笔落在未覆盖币种里的 19.99 消费整个从 mart 里消失了。我们发现它的方式,是有人把 App Store 后台的一张订阅截图和我们的数字对了一遍,问它去哪了。这样发现一次是可以接受的方式,永远这样发现则完全不能接受——本文剩下的部分就在处理这件事。
那个其实早就知道的报表
我们并不是瞎的。自汇率表引入起,join 旁边就一直存在一个覆盖率模型,按天、按币种报出缺失和陈旧的汇率。它在结构上也确实太晚了。
报表回答的是「出了什么问题」,而那是在数据已经提交、聚合、并被人拿去做了决策之后。它的价值是诊断性的。我们需要的是预防性的,而拿到它最便宜的方式是把问题往前挪一级——在钱进入系统的那一刻,在那笔交易还在手上、还能归属的时候。
判据是花最久才弄对的那部分,也是最值得抄走的那部分:
覆盖与否,取决于这个币种在这笔钱被扣掉的那天或之前有没有汇率——永远不是取决于它今天有没有。
ASOF 只往回看。一个八月才在某商店出现的币种、其汇率同步十月才开始,它今天是有汇率的。任何天真的覆盖检查——「这个币种我们有没有」「同步作业还活着吗」「最新一行够不够新」——都会回答有。而八月的收入依然 join 不到任何东西,因为它左边本来就没有东西。补今天的汇率不会让时间倒流。那个看起来像修法的检查,恰恰是把窟窿认证成已填上的那个。
在入口处提问
fxrate.CoverageGuard 把这个问题搬到了上游。它启动时载入一次聚合——两百来个币种,量级可以忽略——并为每个币种持有它最早的汇率日期:
SELECT currency, min(rate_date)
FROM dim_fx_rate
GROUP BY currency
于是每一条收入行都会经过一个问题,在摄取时被问出来:按这笔钱被扣的那天的汇率,它换得出来吗? 如果答案是否定的,缺口就在当天被记录下来,指名造成它的那个币种和那个日期,而那笔交易还在、还能挂上去。同一个思路后来被推广到 LLM 额度定价上,问题一字不差,只是单位换了。
两侧的键在做比较前都经过 upper+trim 归一。这一点毫不炫目,却比看上去重要得多:如果守卫归一了而数仓的 join 键没有(或者反过来),守卫就会为一个数仓从未见过的币种字符串报覆盖情况、为它报警,而这个过程会教会所有人忽略它。一个会为大小写问题哭狼眼的质量守卫,比没有守卫更糟,因为下一条告警会被同样随意地关掉。
底下两个更安静的坑
写守卫的过程翻出了 join 本身里两个早就躺在那儿的 bug,属于同一个物种:它们是碰巧错的,而且长得和正确的一模一样。
零收入不等于未知收入。 免费试用之类的 $0 事件和其他行一样被拿去乘汇率。在 ClickHouse 里,0 * NULL 是 NULL 而不是 0——于是未覆盖币种里的一个试用事件从 join 里出来就是 NULL,被所有意为「我们知道这值多少钱」的 revenue_usd IS NOT NULL 过滤一并扫走。修法是显式短路,让金额是 0,同时让覆盖标记如实说明有没有找到过汇率:
if(e.revenue_micros = 0, 0, e.revenue_micros / 1e6 * fx.usd_per_unit) AS revenue_usd,
if(fx.usd_per_unit IS NULL, NULL, dateDiff('day', fx.rate_date, toDate(e.timestamp))) AS fx_rate_age_days
匹配不到必须真的是匹配不到。 ClickHouse 默认会用类型的零值去填未匹配的列,而不是 NULL。一个 0.0 的汇率意味着那笔交易的收入被静默算成了零——方向相反的另一种失败,同样毫无可见性。把汇率列改成 Nullable 是一次带着一行原因注释的 schema 迁移,而它是这个 join 的 NULL 语义之所以是我们想要的那种语义的唯一原因。
小结
在数仓里,NULL 不是一个不完整的答案——它是一次排除。任何对某列求和的聚合都会永远跳过它,而且不发一句牢骚。如果一个 NULL 可能意味着「暂时还不知道」,那么你需要另一列专门说这件事,因为下游分不清这两者,而只有其中一个该被过滤掉。
判覆盖要看你需要的最早那条事实,而不是你能观察到的最新状态。只往回看的查询(时间旅行查询、ASOF join、生效日期查表)只能被当时已经存在的数据满足,所以一个问「现在怎么样」的覆盖检查,会愉快地把历史窟窿认证成没问题。
把问题问在入口,而不是问在报表。报表告诉你管道产出了什么;只有摄取口的守卫能告诉你它本该拒绝什么。两者之间差的是一周的看板,和一条当天就留痕、并且还挂在内存里那笔交易上的痕迹。