全文共 10,138 字 预计阅读 29 分钟
bg

bg17.广告

补水啦~

还是先来看看广告是怎么赚钱的:

广告不是两方交易,是三方——而这个结构直接决定了数据链路的形状。

角色 出什么 要什么
广告主(advertiser) 转化:下载、注册、下单、复购
媒体(media / publisher,短视频 APP 就是媒体) 用户注意力(广告位) 收入最大化
用户 注意力 不被打扰

中间还有四个技术角色,它们的名字在广告数据链路的字段里到处都是,先定义掉:

DSP(Demand-Side Platform,需求方平台):代表广告主出价的系统。广告主在这里设置预算、定向条件、出价策略;DSP 在每次竞价里替它决定"这次要不要出价、出多少"。

SSP(Supply-Side Platform,供给方平台):代表媒体卖流量的系统。它把媒体的广告位打包出去,目标是让每个曝光机会卖出最高价。

ADX(Ad Exchange,广告交易平台):撮合双方的市场。SSP 把一次曝光机会送进 ADX,ADX 向多个 DSP 发起询价,收集出价,判定赢家。

广告平台 / 联盟:像短视频平台这样既是媒体又自建了完整投放系统的,通常三个角色都在自己手里(自建闭环)。这时数据全在内部,链路最完整——本篇按这种"闭环平台"来讲,因为它的数据形状最全;如果你在纯 DSP 侧,会缺少媒体侧的曝光明细,后文 会讲这个缺口造成的口径问题。

计费模式:它本质上是在分配风险

这是理解广告数据要求的唯一入口

计费模式 全称 按什么收钱 风险在谁那边
CPM Cost Per Mille 每千次曝光 广告主:曝光了但没人点,钱照付
CPC Cost Per Click 每次点击 一半一半:媒体承担"没人点"的风险,广告主承担"点了不买"的风险
CPA Cost Per Action 每次转化(下单、注册) 媒体:转化不了一分钱收不到
oCPM optimized CPM 按曝光结算,但系统承诺优化转化成本 混合:结算基于曝光,但平台用模型对转化负"软责任"

自问:这四种模式对数据链路的要求,差在哪?

差在结算依据这条数据链有多长

CPM 结算:  曝光                                          ← 1 个事件,媒体侧自己就有
CPC 结算:  曝光 → 点击                                    ← 2 个事件,要关联,间隔秒级
CPA 结算:  曝光 → 点击 → ……(用户去了广告主的 APP)→ 转化   ← 3 个事件,
                                                            跨【两家公司的系统】,
                                                            间隔【可达数天】

链条每长一节,结算的技术难度就上一个台阶。 而 CPA 那一节里出现的"跨两家公司的系统 + 间隔数天",就是本篇全部难题的来源。

oCPM 这一行值得单独看,因为它是现在的主流:结算依据是曝光(数据链短、可靠),但平台承诺帮广告主优化转化成本(需要完整的转化数据回流)。 这意味着——

转化数据不参与结算,但参与模型训练和效果承诺。 所以它对精确性的要求略低于结算数据(不会有人拿它对账到分),但对完整性和及时性的要求很高(模型要靠它学习)。这个微妙的差别在后文建模时会用到。

用户滑到一个广告位,接下来大约 100 毫秒内:

sequenceDiagram
    participant U as 用户 APP
    participant M as 媒体广告服务
    participant X as ADX
    participant D1 as DSP-A
    participant D2 as DSP-B

    U->>M: ① 请求广告位内容
    M->>X: ② 发起竞价请求<br/>(带用户特征、广告位规格)
    par 并行询价(有严格超时)
        X->>D1: ③ 询价
        D1-->>X: ④ 出价 8.0 元 CPM
    and
        X->>D2: ③ 询价
        D2-->>X: ④ 出价 6.0 元 CPM
    end
    X->>X: ⑤ 判定赢家 + 定价
    X-->>M: ⑥ 返回中标广告素材
    M-->>U: ⑦ 下发广告
    U->>M: ⑧ 广告真正被看到 → 上报【曝光】
    U->>M: ⑨ 用户点了 → 上报【点击】

这张图里有两个细节,直接决定了数据链路的设计:

细节一:⑦ 和 ⑧ 之间有缝。 广告下发了,不等于被看到。用户可能在广告加载出来之前就滑走了。所以"中标"和"曝光"是两个不同的事件,中间的差额是真实存在的漏损——而 CPM 结算的依据是 ⑧ 不是 ⑦。这就是为什么曝光必须由客户端在"广告真正可见"时上报,而不能由服务端在下发时记账。

可见性标准(viewability):什么叫"真正被看到"通常有明确定义(例如"广告面积的 50% 以上在屏幕内持续 1 秒以上")。具体阈值随媒体、广告形式和行业标准不同,我没有核实到统一口径,请以你所在平台的定义为准。 但要记住这一条:它是一个口径,会被改,改了之后曝光数会跳变。

细节二:⑤ 的"定价"和"出价"不是一回事。

GSP / 二价拍卖(Generalized Second-Price):赢家是出价最高的,但实际支付的是第二高价(加一个最小增量)。上图里 DSP-A 出 8.0 赢了,但可能只付 6.01。 为什么这么设计:一价拍卖里,所有人都要猜"别人会出多少"然后压价,出价不反映真实价值、且会持续波动;二价拍卖让"按真实价值出价"成为更稳的策略,市场更容易稳定。这是拍卖理论里的经典结论。

可信度边界:GSP 是公开的经典机制(学术文献与早期搜索广告实践都有大量记载),但近年展示广告市场存在向一价拍卖(first-price)迁移的趋势,不同平台、不同时期的实际机制各不相同。我没有核实任何具体平台当前用的是哪一种。 对数据工程的影响与机制无关,只有一条:bid_price(出价)和 settle_price(成交价)是两个必须分开存的字段,绝不能混用。 用出价算收入会系统性高估。

把三篇并排:

数据错了会怎样
16A 短视频时长 大盘数字不准 → 决策偏差。没有人直接因此少收/多付钱
15 电商 GMV 财务对账不上 → 公司内部的账要重对
17 广告 广告主账户里的余额被多扣或少扣。这是【对外的、有法律含义的】一笔钱。

因果链:结算依据是数据 → 数据多算 = 多扣广告主的钱(可能构成欺诈指控)→ 数据少算 = 平台自己承担损失 → 两个方向都不可接受

所以广告链路有三条其他业务域没有的硬约束:

  1. 必须能对每一分钱追溯到原始事件。"这个广告主今天为什么被扣了 12,345.67 元"要能拆到具体的曝光/点击明细。
  2. 必须有独立的对账体系,而且不止一套(平台内部对账 + 与广告主的账单核对)。
  3. 口径变更必须能重算历史,而且重算结果要能解释差异。

三条流与它们的时间跨度

时间轴 ─────────────────────────────────────────────────────────────────►

  T0        T0+2s                              T0+3天                T0+7天
   │          │                                   │                     │
   ▼          ▼                                   ▼                     ▼
 【曝光】   【点击】                            【转化】              【转化】
 impression  click                             conversion            conversion
                                              (下载并注册)          (首次下单)

 200 亿/天    4 亿/天                                800 万/天(合计)
   │          │                                        │
   └─ 媒体侧产生 ──┘                                    └─ 【广告主侧】产生,
                                                          通过回传接口送回来

三条流的差异比"三张表"这个说法暗示的大得多,逐维度看:

维度 曝光流 点击流 转化流
量级(假设值) 200 亿/天 4 亿/天 800 万/天
相对曝光 1 1/50 1/2500
谁产生的 媒体客户端 媒体客户端 广告主的系统
数据质量 可控(自己的 SDK) 可控 不可控:格式、延迟、口径、完整性全看对方
延迟 秒级 秒级 秒级到数天都有
可信度 中(有作弊点击) :可能重复回传、漏传、甚至回传假数据
用途 CPM 结算、库存统计 CPC 结算、CTR 不参与结算(oCPM 下),但决定模型效果与广告主续费

回传(conversion callback / postback):广告主在自己的 APP 或服务器里,把"用户完成了某个动作"这件事主动发回给广告平台的接口调用。这是整条链路里唯一一段数据来自公司外部的部分,所以也是最脆弱的一段。

"转化流由广告主产生"这一条是本篇最重要的结构性事实。 它推出三个后果,全篇都在处理:

  1. 延迟不可控。广告主的服务器挂了、批处理任务延迟了、回传队列积压了——转化事件可能几小时甚至几天后才到。这不是网络抖动级的迟到,是天级的迟到。
  2. 质量不可控。同一次转化被重复回传(对方重试逻辑没做幂等)、转化时间戳用的是对方的时钟、字段含义与我们的理解不一致。
  3. 必须用一个"我们发出去、对方带回来"的标识把两端串起来——这就是下一节的 click_id

三条流各自长什么样

// ① 曝光事件(媒体客户端上报)
{
  "event": "ad_impression",
  "request_id": "rq-8a3f...",     // 一次竞价请求的唯一 id,贯穿竞价全过程
  "impression_id": "im-71c2...",  // ★ 本次曝光的唯一 id,点击事件会带回它
  "ad_id": 55001, "campaign_id": 9001, "advertiser_id": 301,
  "did": "device-xxxx", "uid": 10086,
  "bid_price_cpm": 8.00,          // 出价(§1.3 细节二:和成交价【不是】一回事)
  "settle_price_cpm": 6.01,       // ★ 成交价 —— 结算用这个
  "billing_type": "oCPM",
  "is_viewable": true,            // 是否满足可见性标准(§1.3 细节一)
  "event_ts": 1753900800000,      // 事件时间(客户端),会乱序
  "server_ts": 1753900800150      // 网关落地时间
}

// ② 点击事件(媒体客户端上报)
{
  "event": "ad_click",
  "click_id": "ck-4b91...",       // ★★ 最关键的字段:它会被拼进落地页 URL 发给广告主
  "impression_id": "im-71c2...",  // 指回是哪次曝光被点的
  "ad_id": 55001, "campaign_id": 9001,
  "did": "device-xxxx", "uid": 10086,
  "event_ts": 1753900802000,
  "server_ts": 1753900802080
}

// ③ 转化回传(广告主服务器 → 我们的回传接口)
{
  "event": "ad_conversion",
  "click_id": "ck-4b91...",       // ★★ 广告主把我们发出去的 click_id 原样带回来
  "conv_type": "register",        // 转化类型:download / register / purchase / ...
  "conv_value": 0.0,              // 转化价值(下单类会带金额)
  "advertiser_conv_ts": 1754160000000,  // ★ 广告主侧的转化时间 —— 【对方的时钟】
  "received_ts": 1754160500000,   // ★ 我们收到回传的时间 —— 【我们的时钟】
  "callback_seq": 1               // 对方的重试序号(如果对方提供的话)
}

click_id 是整条链路的脊椎,值得单独讲清楚它怎么工作:

① 用户点击广告
     ↓
② 媒体客户端生成 click_id = "ck-4b91...",上报点击事件,
   同时把它【拼进落地页 URL】:https://advertiser.com/landing?click_id=ck-4b91...
     ↓
③ 用户跳转到广告主的页面/APP,广告主的代码从 URL 里读出 click_id 并【存下来】
     ↓
④ 几天后用户在广告主 APP 里下了单
     ↓
⑤ 广告主调用我们的回传接口,把当初存的 click_id 原样送回来
     ↓
⑥ 我们拿 click_id 去找当初那次点击 → 找到 → 归因成功

这条链上有四个可能断的地方,每一个都对应真实的归因缺口:

断点 后果
② URL 被截断 / 跳转丢参数 广告主收不到 click_id
③ 广告主没存 / 存错 回传时给不出
⑤ 回传接口调用失败且不重试 转化永久丢失
⑥ 我们这边点击记录已过期 click_id 也找不到对应的点击

其中断点 ⑥ 完全在我们自己手里

量级与不对称性

以下全是为了算式可算而设的假设值

假设:
  DAU                = 2 亿
  人均广告曝光数      = 100 次/天
  CTR(点击率)       = 2%
  CVR(点击到转化)    = 2%

曝光: 2 亿 × 100        = 200 亿/天  → 平均 23 万 QPS,晚高峰按 3 倍 ≈ 70 万 QPS
点击: 200 亿 × 2%       = 4 亿/天    → 平均 4600 QPS
转化: 4 亿 × 2%         = 800 万/天  → 平均 93 QPS

这个 2500:1 的不对称,是本篇技术方案分叉的根本原因。

对比 16A:那里所有数据都是同一个量级的心跳,方案是统一的(加大并行度 + 两阶段聚合)。这里三条流差了三个数量级,必须用三套不同的方案

曝光流(200 亿): 量大但【每条的生命周期短】——只有需要做曝光归因时才要留
点击流(4 亿):   量中但【每条要活 7 天】     ← 这是状态最重的一条
转化流(800 万): 量小但【要去关联 7 天前的点击】← 这是关联最难的一条

归因是什么问题

自问:用户点了广告,几天后下单了。这单功劳算谁的?

听起来是个记账问题,实际上是一个没有客观答案的问题。看一个真实会发生的序列:

7月20日 10:00  用户在【抖音】看到广告 A 的曝光(没点)
7月21日 15:00  用户在【微信】点了广告 B 的链接(点了没买)
7月22日 09:00  用户在【抖音】点了广告 C(点了没买)
7月23日 20:00  用户【自己搜索】了这个品牌,下单

这一单,功劳算谁的?

没有任何数据能告诉你真相,因为"用户为什么下单"发生在用户的脑子里。所以——

归因(attribution):把一次转化,按某种预先约定的规则,分配给此前发生的一个或多个广告触点(touchpoint)的过程。

归因结果不是"事实",是"一种口径下的计算结果"。 这句话是本节的中心,它有三个直接的工程后果:

  1. 归因结果表必须带"用哪套规则算的",和 16B 特征表必须带 model_version 是同一个道理。
  2. 换归因模型 = 全部历史数据要重算,而且重算前后的数字都对,只是口径不同。这件事必须能向业务方解释清楚。
  3. 不同参与方会用不同的归因口径,因此媒体报的转化数和广告主自己统计的转化数几乎必然对不上。这不是 bug。

五种归因模型

模型 规则 上例的结果 什么时候用
末次点击(last-click) 全部功劳给最后一次点击 广告 C 拿 100% 最常用:简单、可审计、双方都容易验证
首次点击(first-click) 全部给第一次点击 广告 B 拿 100% 评估拉新渠道
线性(linear) 所有触点均分 B、C 各 50% 想看到全链路贡献
时间衰减(time-decay) 越靠近转化功劳越大 C 拿大头,B 拿小头 认为临门一脚更重要
位置(position-based,如 40/20/40) 首尾重、中间轻 B 40%、C 40%、中间 20% 兼顾拉新和转化

自问:既然末次点击明显不合理(它把首次触达的功劳全抹掉了),为什么它还是最常用的?

三个理由,都不是"因为它更准":

  1. 可审计。它只依赖一条记录(最后那次点击),广告主能自己核对。多触点模型要求双方对"完整的触点序列"达成一致——而双方看到的序列本来就不一样
  2. 不需要跨媒体数据。线性、时间衰减这类模型要求你知道用户在别的媒体上的触点,而这个数据你拿不到。
  3. 结算友好。功劳是整数份(100% 归一个),不会出现"这次转化 0.4 次归你"这种在账单上很难解释的东西。

代价必须说清:末次点击系统性低估了曝光型广告和拉新渠道的价值。业内的常见做法是"结算用末次点击,效果评估另外跑多触点模型",两套数字并存并且各自标注口径。

归因窗口:为什么是"点击 7 天、曝光 1 天"

归因窗口(attribution window / lookback window):一次转化,往前追溯多久之内的触点才算数。超出窗口的触点不参与归因。

行业里常见的默认组合是 点击 7 天(click-through 7d)+ 曝光 1 天(view-through 1d)

⚠️ 这个组合是行业通行的常见默认值,不同平台、不同行业、不同转化类型(下载 vs 复购)的实际设置差异很大。我没有核实任何具体平台的当前默认值,请以你的平台配置为准。 本篇用它做算式的输入。

自问:为什么点击 7 天而曝光只有 1 天?

因为两者的因果强度不同。 点击是主动行为,用户明确表达了兴趣,因此"7 天后下单还与那次点击有关"是合理假设。曝光是被动接触,用户可能根本没注意到——把 7 天前一次被动曝光和今天的下单挂钩,归因结果会大量包含"本来也会买"的自然转化

窗口长度是一个直接的成本-准确性旋钮,两个方向的代价都很实在:

窗口 好处 代价
拉长 抓到长决策周期的转化(买车、买房、B 端软件) 状态成倍增长(§4.2 会算);② 混入更多自然转化 → 高估广告效果;③ 归因结果要更久才能稳定
缩短 状态小、结果稳定快、归因更"干净" 系统性漏掉长周期转化 → 低估广告效果,广告主会抱怨

注意"归因结果要更久才能稳定"这一条,它是广告数据最反直觉的性质:

7 月 23 日的转化数,什么时候才算"最终值"?

→ 如果归因窗口是点击 7 天,那么 7 月 23 日发生的转化,
  可能归因到 7 月 16 日 ~ 23 日之间的任何一次点击。

→ 反过来看:7 月 16 日那一天的【广告效果】,
  要等到 7 月 23 日才算数完,因为在那之前还可能有新的转化归因过来。

结论: 某一天的广告效果数据,要【7 天后】才收敛。
      T+1 报表里的当日 ROI,天然是【不完整的】。

这一条必须写进产品和报表的口径说明里,否则运营会拿 T+1 的 ROI 做决策,然后发现一周后数字自己变了。

三个口径分叉(每一个都会造成"两边对不上")

分叉一:view-through 算不算?

只看到曝光、没点击,之后转化了——这叫 view-through 转化。算不算广告的功劳?媒体倾向于算(数字好看),广告主倾向于不算(怀疑是自然转化)。双方口径不同 → 数字对不上,而且是系统性的。

分叉二:用什么身份把人串起来?

点击时:  我们有 did(设备号)、uid(我们的登录 id)、click_id
转化时:  广告主有【他们自己的】用户 id,可能还有他们采集的设备号

串联方式:
  A. 靠 click_id                 → 【最可靠】,因为它是我们发出去、原样带回的
  B. 靠设备号匹配                 → 隐私合规限制越来越强,且跨 APP 取号能力在收紧
  C. 靠"同一时间同一地区"的概率匹配 → 【指纹匹配】,准确率不可控,合规风险高

所以 click_id 链路的完整性,直接决定了归因的覆盖率。 这也是为什么之前那四个断点值得逐个建监控。

分叉三:跨媒体的重复归因。

同一次转化,被抖音归因给自己、被微信也归因给自己——因为两家各自只看得到自己的触点,各自的末次点击都是自己。 于是所有媒体报给广告主的转化数加起来会超过广告主的实际转化数

这不是谁在造假,是结构性的。 广告主侧的解法是自己做统一归因(用 MMP,Mobile Measurement Partner,第三方归因服务商),把所有媒体的触点收在一起后统一裁决。作为媒体侧的数据工程师,你要知道:你的数字和广告主的数字对不上是常态,重要的是能解释差异从哪来。

建模:四张表,以及为什么归因结果必须单独一张

-- ① 曝光事实表:量最大,粒度 = 一次曝光
CREATE TABLE dwd_ad_impression (
  impression_id   STRING, request_id STRING,
  ad_id BIGINT, campaign_id BIGINT, advertiser_id BIGINT,
  did STRING, uid BIGINT,
  bid_price_cpm   DECIMAL(10,4),   -- 出价
  settle_price_cpm DECIMAL(10,4),  -- 成交价(§1.3:两者绝不可混用)
  billing_type    STRING,
  is_viewable     BOOLEAN,
  event_ts        TIMESTAMP
) PARTITIONED BY (dt STRING, hh STRING);   -- 200 亿/天,必须小时分区

-- ② 点击事实表:粒度 = 一次点击
CREATE TABLE dwd_ad_click (
  click_id STRING, impression_id STRING,
  ad_id BIGINT, campaign_id BIGINT, advertiser_id BIGINT,
  did STRING, uid BIGINT,
  event_ts TIMESTAMP
) PARTITIONED BY (dt STRING, hh STRING);

-- ③ 转化回传表:【原样落库,不做任何加工】
CREATE TABLE dwd_ad_conversion_raw (
  click_id STRING,
  conv_type STRING, conv_value DECIMAL(18,4),
  advertiser_conv_ts TIMESTAMP,   -- 对方的时钟
  received_ts        TIMESTAMP,   -- 我们的时钟
  callback_seq INT,
  raw_payload  STRING             -- ★ 原始报文全文,一个字节都不改
) PARTITIONED BY (dt STRING);      -- 按【received_ts】分区,不是转化时间

-- ④ 归因结果表:★ 这不是事实,是【一次计算的产物】
CREATE TABLE dwd_ad_attribution (
  conversion_key   STRING,        -- 转化的唯一标识(去重后)
  click_id         STRING,        -- 归因到的触点
  impression_id    STRING,
  ad_id BIGINT, campaign_id BIGINT, advertiser_id BIGINT,
  attribution_model   STRING,     -- ★ 'last_click' / 'linear' / ...
  attribution_window  STRING,     -- ★ 'click_7d_view_1d'
  attribution_weight  DECIMAL(6,4),-- ★ 该触点分到的功劳份额(末次点击恒为 1.0)
  conv_ts     TIMESTAMP,          -- 归一化后的转化时间
  touch_ts    TIMESTAMP,          -- 触点时间
  calc_version STRING,            -- ★ 这一行是哪次归因计算产出的
  calc_ts      TIMESTAMP          -- ★ 什么时候算的
) PARTITIONED BY (conv_dt STRING);

三个建模决策,每一个都值得解释:

决策一:③ 原样落库、按 received_ts 分区、保留 raw_payload

因果链:转化数据来自外部、不可信、可能重复、可能字段含义有分歧 → 一旦在入库时做了加工(去重、时间归一化、类型映射),原始信息就永久丢失了 → 而广告主投诉"我明明回传了 1000 条你只算了 800 条"时,你需要能拿出原始报文逐条核对 → 所以入库层必须是原样的,所有清洗放在下一层。

received_ts 而不是 advertiser_conv_ts 分区,是因为分区键必须是我们能控制的、单调的量。用对方的时钟做分区键,一次对方时钟错乱就会往历史分区里写数据——而历史分区可能已经被下游消费过、已经对过账了。

决策二:④ 归因结果表与事实表严格分开。

如果把归因结果写回点击表(加一列 is_converted),会同时踩三个坑:

  • 点击表从"只追加"变成"要更新"(退化成 15 篇的 CDC 问题,而且是在 4 亿行/天的表上);
  • 换归因模型时要原地重写事实表,事实就被改掉了;
  • 无法让多套归因口径并存——而之前说了结算和效果评估要用不同模型。

决策三:④ 的四个 ★ 字段(attribution_model / attribution_window / calc_version / calc_ts)。

这四个字段让归因结果从"一个数字"变成"一个带完整出处的数字"。有了它们:

  • 多套口径可以同时存在于一张表里,下游读的时候显式指定;
  • 换模型是追加一批新 calc_version 的数据,不是覆盖,因此可回滚、可对比
  • 广告主质疑某条归因时,能回答"它是 v3 归因逻辑在 7 月 24 日算出来的,用的是 last_click + click_7d"。

和 16B 的 model_version 是同一个模式,但这里更强:16B 的多版本特征是"不同算法的输出";这里的多版本归因直接对应不同的账。所以 attribution_model 不只是元数据,它是账单的一部分——报表和账单上必须显示它。

指标 定义 用途
CTR 点击数 ÷ 曝光数 创意质量、素材 A/B
CVR 转化数 ÷ 点击数 落地页与产品质量
eCPM 收入 ÷ 曝光数 × 1000(16A §1.2 用过) 流量变现效率,媒体侧最核心的指标
CPA 花费 ÷ 转化数 广告主最关心:一个转化多少钱
ROI / ROAS 转化带来的收入 ÷ 广告花费 广告主的最终判据

ROAS(Return On Ad Spend,广告支出回报率)= 转化金额 ÷ 广告花费。和 ROI 常被混用,严格说 ROI 应该扣掉成本。本系列统一用 ROAS 指"转化金额 ÷ 花费"这个比值,避免歧义。

隐藏的口径陷阱:CVR 和 ROAS 的分子分母不在同一个时间上。

7 月 23 日的 CVR = ?

写法 A(按转化发生时间):
   分子 = 7月23日【发生】的转化数
   分母 = 7月23日的点击数
   ❌ 分子里的转化,可能来自 7月16-23 日任何一天的点击。分子分母不是同一批人。

写法 B(按点击时间归集,行业通行做法):
   分子 = 归因到【7月23日点击】的转化数
   分母 = 7月23日的点击数
   ✅ 分子分母对齐了。
   ⚠️ 但代价是:这个数字在 7 月 30 日之前都【不是最终值】(§3.3 的收敛问题)。

必须用写法 B,并且必须在报表上标注"数据回溯中,n 天后收敛"。 这是广告报表和其他业务报表最不一样的一点:同一个日期的数字,会在接下来几天里持续变大。 不解释清楚,业务方会认为数据在跳。

一个可落地的做法:报表同时给出当前值成熟度

7月23日 CVR:  1.82%   (数据成熟度 3/7 天,预计还会上升)
7月16日 CVR:  2.31%   (已收敛 ✓)

前三篇压的分别是写路径(15 行级更新)、吞吐与热点(16A)、异构算力与索引(16B)。本篇压的是一个到现在为止都只是"配置项"的东西:状态本身的规模与生命周期

难题的形状可以用一句话概括:

一条 800 万/天的小流,要去关联一条 4 亿/天、且必须活 7 天的大流;关联结果直接对应钱;而小流那一侧的迟到以天计。

拆成五个技术问题,逐个来:

# 问题
1 状态到底多大?曝光归因和点击归因能不能用同一套方案?
2 Flink 的 interval join 到底怎么算的?它有什么盲区?
3 归因窗口是事件时间,state TTL 是处理时间——这个错配会怎样?
4 天级迟到到了,已经计过的费怎么办?
5 口径变了要重算历史,广告为什么比别的业务难?

状态算式:为什么曝光归因和点击归因必须分家

先把状态大小算出来。下面每个数字都标了来源,凡是"假设值"的都不要当基准。

【假设值】单条点击在 RocksDB 里序列化后的大小 ≈ 150 字节
  依据: click_id(约24B) + 三个 id(24B) + did/uid(约24B) + 时间戳(8B)
        + 序列化框架开销 + RocksDB 的 key/value 编码开销
  另外: 【官方口径】Flink 文档说明,RocksDB 状态后端启用 TTL 时
        "stores the last modification timestamp along with the user value",
        每个存储值增加 8 字节。(本算式已含在 150B 的估计里)

点击流状态(归因窗口 7 天):
  4 亿/天 × 7 天           = 28 亿条
  28 亿 × 150 B            ≈ 420 GB

曝光流状态(归因窗口 1 天):
  200 亿/天 × 1 天         = 200 亿条
  200 亿 × 150 B           ≈ 3 TB

自问:3 TB 磁盘并不贵,为什么不能也做成流式关联?

因为状态的成本不在磁盘上,在四个别的地方:

先补两个 RocksDB 的名词,下面三条都要用: SST(Sorted String Table):RocksDB 落在磁盘上的不可变数据文件。写入先进内存,攒够后整体刷成一个新 SST 文件,已存在的 SST 永远不会被就地修改。 compaction(压实):把多个 SST 文件读进来、丢掉其中被覆盖或删除的旧版本、重写成新的 SST 文件的后台过程。因为 SST 不可变,同一个 key 的多次更新会分散在多个文件里,不定期压实就会越读越慢、越占越多。

  1. compaction 需要额外的磁盘空间:重写期间新旧 SST 同时存在。【推断】 实际本地盘要按状态大小的 2~3 倍准备,3 TB 状态 = 6~9 TB 本地 SSD。
  2. checkpoint 会出现尖刺。增量 checkpoint 只上传新增的 SST 文件;但 compaction 会重写大量 SST,这些重写产物对增量 checkpoint 而言都是"新文件"。【推断】 因此状态越大,compaction 越频繁,增量 checkpoint 的实际增量就越不"增量",出现周期性的大尺寸 checkpoint。
  3. 恢复时间。作业失败重启要把状态从远端拉回本地。3 TB / 并行度 512 = 每 subtask 6 GB;按每 subtask 有效恢复带宽 100 MB/s(假设值)算,约 60 秒——听起来还行,但这是理想值,实际还要叠加对象存储的并发限流。
  4. 每一次运维都变重。改并行度要做 savepoint 再重启;3 TB 的 savepoint 意味着每次上线都是一场手术。

但真正让这个方案不成立的是投入产出比:

曝光状态 / 点击状态 = 3 TB / 420 GB ≈ 7 倍

而它换来的是什么?
  → view-through 归因(只曝光没点击的转化)
  → 这类归因【广告主本来就普遍不认】
  → oCPM 下转化数据【不参与结算】
  → 所以它既不需要秒级,也不需要精确到分

结论:同一个业务域里,两种归因走两条完全不同的架构。判据只有两条——是否参与结算、是否需要秒级。

┌──────────────────────────────────────────────────────────────┐
│ 点击归因 (click-through)                                      │
│   参与效果承诺、要求准实时(广告主要看到今天的转化)              │
│   → Flink 流式关联,状态 420 GB,秒~分钟级出结果                 │
├──────────────────────────────────────────────────────────────┤
│ 曝光归因 (view-through)                                       │
│   不参与结算、T+1 出结果即可                                    │
│   → 【离线批处理】:T+1 用 Spark 跑一次                          │
│      读昨天的转化 × 前 1 天的曝光分区,做一次大 join             │
│   → 状态问题【消失】:批处理不需要常驻状态,                      │
│      shuffle 的代价只在那一次任务里付                            │
└──────────────────────────────────────────────────────────────┘

这是本系列反复出现的一个判断模式:不要问"这个能不能用流做",要问"它需不需要用流做"。16A 说过 Lambda 架构的分工判据是延迟要求;这里加了第二条判据——是否是结算依据。两条都不满足的,一律走批。

Flink interval join:精确语义,以及它的盲区

【官方口径】 Flink 文档(DataStream Joining 页)对 interval join 的定义:它关联两条流中拥有相同 key、且时间戳满足

b.timestamp ∈ [a.timestamp + lowerBound ; a.timestamp + upperBound]

的元素对。文档明确说明:上下界默认是闭区间(可以通过 lowerBoundExclusive() / upperBoundExclusive() 改为开区间);"The interval join currently only supports event time"(只支持事件时间);并且**"currently only performs inner joins"(只做内连接);输出元素的时间戳取两者中较大**的那个。

对应到归因,a = 点击、b = 转化、lowerBound = 0upperBound = 7 天

-- Flink SQL 写法(等价于 interval join;语义同上)
SELECT
  c.click_id, c.ad_id, c.campaign_id,
  v.conv_type, v.conv_ts
FROM ad_click AS c, ad_conversion AS v
WHERE c.click_id = v.click_id
  AND v.conv_ts BETWEEN c.click_ts AND c.click_ts + INTERVAL '7' DAY;

模拟输入

点击流 ad_click:
  click_id  click_ts
  ck-001    2026-07-16 10:00:00
  ck-002    2026-07-16 11:00:00

转化流 ad_conversion:
  click_id  conv_ts
  ck-001    2026-07-20 09:00:00     (距点击 3 天 23 小时)
  ck-002    2026-07-24 09:00:00     (距点击 7 天 22 小时 —— 超出 7 天窗口)
  ck-999    2026-07-20 15:00:00     (这个 click_id 我们从没见过)

运行结果

click_id | ad_id | conv_type | conv_ts
---------+-------+-----------+---------------------
ck-001   | 55001 | register  | 2026-07-20 09:00:00

(1 row)

为什么是这个结果ck-001 落在闭区间内,输出。ck-002 超出上界,ck-999 找不到匹配的点击——按 inner join 语义,两者都不产生任何输出

自问:只输出匹配上的,不正是我们想要的吗?盲区在哪?

盲区在于:你无法知道"没归上因的转化"有多少、分别因为什么。

回顾 click_id 链路,它有四个断点。现在广告主来问"我回传了 10000 条转化,你们只归因了 7200 条,另外 2800 条去哪了"——用 interval join,你答不出来:那 2800 条在作业里静默消失了,没有输出、没有日志、没有指标。

而这 2800 条的去向恰恰是广告数据质量的头号监控项,因为不同原因对应不同的责任方:

未归因原因 责任方 该怎么办
click_id 从没出现过 广告主(存错/传错)或链路丢参 找对方核对
超出归因窗口 口径问题,不是故障 计数即可,用于评估窗口是否该拉长
点击记录已被清理 我们自己 这是 bug,必须报警
重复回传 广告主的重试没做幂等 去重,并统计重复率

所以生产上的归因作业不用 interval join,而是用 KeyedCoProcessFunction 自己实现。

KeyedCoProcessFunction:Flink DataStream API 里的一个算子基类,用来处理两条已经按同一个 key 分区的流。它给你三个可以重写的方法——processElement1(第一条流来了一条)、processElement2(第二条流来了一条)、onTimer(你注册的定时器到点了)——以及在这三个方法里读写 state、注册定时器、往侧输出发数据的能力。相对于 interval join 这种"你只描述规则、引擎替你实现"的高层算子,它是手动挡:状态怎么存、什么时候清、没匹配上怎么办,全部你自己写。

代价是要自己管状态和定时器(多写约 50 行代码),换来的是未匹配路径可观测。下一节的代码就是这个实现——而且它同时解决了一个更严重的问题。

本篇最重要的一节:state TTL 是处理时间,归因窗口是事件时间

自己管状态,第一个念头一定是"给状态配个 7 天的 TTL 就行了"。

这是错的,而且错得很隐蔽。

先把 Flink 的 state TTL 机制说清楚。【官方口径】 Flink 文档(Working with State · State Time-To-Live):

  • TTL 的更新策略默认是 OnCreateAndWrite(创建和写入时刷新过期时间),可配为 OnReadAndWrite(读也刷新)。
  • 可见性默认 NeverReturnExpired过期但尚未清理的值,读的时候一律不返回)。
  • 清理是 best effort 的:过期值在被读到时显式移除,并在后台按状态后端支持的方式定期回收。文档原话:"If no access happens to the state or no records are processed, expired state will persist"——没人访问、也没有记录流入,过期状态就会一直留着。
  • 后台清理有三种策略,各有限制:全量快照时清理(不清理本地状态,且不适用于 RocksDB 增量 checkpoint);增量清理(仅堆内存状态后端,文档明确写 "Setting it for RocksDB will have no effect",且会增加记录处理延迟);RocksDB compaction filter(会拖慢 compaction)。
  • "TTL configuration is not part of check- or savepoints"——TTL 配置不随 checkpoint/savepoint 保存。

以上都是工程细节。真正致命的是这一条:

【官方口径】"Only TTLs in reference to processing time are currently supported." ——目前只支持处理时间语义的 TTL。

而归因窗口是事件时间语义的:从点击的 event_ts 起算 7 天。

自问:正常运行时,处理时间和事件时间不是差不多同步吗?差这一点有什么关系?

正常时确实没关系。问题出在不正常的时候——而作业一辈子总会有不正常的时候。 两个方向都会出事:

方向一:追数据 / 回放(处理时间慢、事件时间快)

场景: 作业挂了一天,从 Kafka 重放 30 天数据做回补,6 小时跑完。

事件时间: 30 天,飞速前进
处理时间: 只走了 6 小时

TTL = 7 天(处理时间) → 6 小时内【一条都不会过期】
    → 状态里堆着【30 天】的点击,而不是设计的 7 天
    → 状态从 420 GB 涨到 1.8 TB
    → checkpoint 超时 → 作业失败 → 重启 → 又从头堆 → 【死循环】

这条链的最后一环最恶心:checkpoint 超时导致作业无法完成任何一次 checkpoint,于是它没有可恢复的点,重启后重放更多数据,状态更大。 16A §4.2 讲热点时出现过同样形状的正反馈,这里的触发条件不同,但结局一样。

方向二:长时间停机后恢复(处理时间快、事件时间不动)

场景: 作业为了机房迁移停了 8 天,从 savepoint 恢复。

处理时间: 走了 8 天(墙上时钟不会因为作业停了就停)
事件时间: 停在 8 天前

恢复后,状态里所有点击的"最后修改时间戳"都是 8 天前
    → TTL = 7 天(处理时间) → 【全部判定为过期】
    → 默认 NeverReturnExpired → 读到时【返回 null】
    → 归因逻辑看到 null,判定为"没有这条点击"
    → 这一批转化【全部归因失败】

方向二比方向一危险得多,因为它不报错。作业健康、lag 正常、checkpoint 成功、监控全绿——只是归因率悄悄从 72% 掉到了 8%。而 §4.2 已经说过:如果你用的是 interval join,连"归因失败"这件事都看不见。

再加一条容易被忽略的【官方口径】 TTL 配置不属于 checkpoint/savepoint 的一部分。这意味着你在代码里改了 TTL 时长,从旧 savepoint 恢复后新配置立即生效,作用在那些带着旧时间戳的状态上——把 7 天改成 3 天,恢复的瞬间 4 天前到 7 天前的点击全部过期。

顺带一个 Flink SQL 的坑:如果你用 Flink SQL 的普通 join(不带时间区间的 JOIN)来做归因,状态保留由 table.exec.state.ttl 控制。【官方口径】 这个配置项的默认值是 0 ms,含义是"永不清理"(文档描述:指定空闲状态——即未被更新的状态——的最小保留时长)。也就是说,默认配置下这个作业的状态会无限增长,直到磁盘打满。用 SQL 写归因必须显式设置它。

正确做法:用事件时间定时器自己清理状态。

因果链很直接:清理动作必须与归因窗口用同一种时间语义 → 归因窗口是事件时间 → 所以清理必须由 watermark 推进触发 → Flink 提供的事件时间定时器(registerEventTimeTimer)恰好就是这个语义。

代码里还有两个 Flink 名词: ValueState<T>:最简单的一种 keyed state——当前这个 key 下只存一个值value() 读、update() 写、clear() 清。这里每个 click_id 只对应一次点击,所以用它就够(如果一个 key 下要存多条,才需要 ListState / MapState)。 OutputTag<T>侧输出流的标签。主输出用 Collector.collect(),侧输出用 ctx.output(tag, value)——下游可以按标签把这一路单独取出来,写到另一张表或另一个主题。16A §4.4.1 用它分流过异常数据,这里用它分流未归因的转化。

/**
 * 归因主逻辑:点击流 × 转化流,按 click_id 关联。
 * 关键设计:
 *   ① 【不用】 StateTtlConfig —— 它是处理时间语义(§4.3)
 *   ② 用事件时间定时器清理 —— 与归因窗口同语义
 *   ③ 未匹配的转化走侧输出,带原因 —— 补上 interval join 的盲区(§4.2)
 */
public class AttributionFunction
        extends KeyedCoProcessFunction<String, ClickEvent, ConvEvent, AttrResult> {

    private static final long WINDOW_MS = 7L * 24 * 3600 * 1000;  // 归因窗口:7 天
    private static final long GRACE_MS  = 1L * 24 * 3600 * 1000;  // ★ 宽限期:多留 1 天

    private ValueState<ClickEvent> clickState;   // 一个 click_id 只有一次点击,ValueState 够

    public static final OutputTag<ConvEvent> UNATTRIBUTED =
            new OutputTag<ConvEvent>("unattributed") {};   // 未归因转化的侧输出

    @Override
    public void open(Configuration cfg) {
        clickState = getRuntimeContext().getState(
                new ValueStateDescriptor<>("click", ClickEvent.class));
        // ↑ 注意这里【没有】 .enableTimeToLive(...) —— 这是本节的全部要点
    }

    @Override
    public void processElement1(ClickEvent click, Context ctx, Collector<AttrResult> out) {
        clickState.update(click);
        // ★ 事件时间定时器:由 watermark 越过 (click_ts + 8天) 时触发,而非墙上时钟
        ctx.timerService().registerEventTimeTimer(click.eventTs + WINDOW_MS + GRACE_MS);
    }

    @Override
    public void processElement2(ConvEvent conv, Context ctx, Collector<AttrResult> out) {
        ClickEvent click = clickState.value();
        if (click == null) {
            conv.reason = "NO_CLICK_FOUND";       // click_id 从没出现,或已过宽限期
            ctx.output(UNATTRIBUTED, conv);
            return;
        }
        if (conv.convTs - click.eventTs > WINDOW_MS) {
            conv.reason = "OUT_OF_WINDOW";        // ★ 宽限期就是为了能区分出这一类
            ctx.output(UNATTRIBUTED, conv);
            return;
        }
        out.collect(AttrResult.of(conv, click, "last_click", "click_7d"));
    }

    @Override
    public void onTimer(long ts, OnTimerContext ctx, Collector<AttrResult> out) {
        clickState.clear();   // 事件时间已越过窗口+宽限,这条点击不可能再被归因
    }
}

模拟输入(与 §4.2 完全相同,便于对比)

点击流:  ck-001 @ 2026-07-16 10:00     ck-002 @ 2026-07-16 11:00
转化流:  ck-001 @ 2026-07-20 09:00     ck-002 @ 2026-07-24 09:00     ck-999 @ 2026-07-20 15:00

运行结果

【主输出 AttrResult】
  click_id=ck-001  touch_ts=07-16 10:00  conv_ts=07-20 09:00
                   model=last_click  window=click_7d  weight=1.0

【侧输出 unattributed】
  click_id=ck-002  conv_ts=07-24 09:00  reason=OUT_OF_WINDOW
  click_id=ck-999  conv_ts=07-20 15:00  reason=NO_CLICK_FOUND

【定时器触发】
  watermark 越过 07-24 10:00  →  清理 ck-001 的状态  (10:00 + 7天 + 1天)
  watermark 越过 07-24 11:00  →  清理 ck-002 的状态  (11:00 + 7天 + 1天)

为什么是这个结果,以及宽限期为什么必须有ck-002 的转化发生在点击后 7 天 22 小时,已经超出归因窗口。如果没有那 1 天宽限期,它的状态在 7 月 23 日 11:00(事件时间)就被清掉了,转化到达时读到 null,会被判成 NO_CLICK_FOUND——"超窗"和"链路断了"这两种完全不同的问题就被混成了一类。加了宽限期,状态活到 7 月 24 日 11:00,转化在 09:00 到达时点击还在,于是能准确判成 OUT_OF_WINDOW

代价说清楚:宽限期 1 天 / 窗口 7 天 = 状态多 1/7 ≈ 14%,按 §4.1 的算式是 420 GB → 480 GB。这 60 GB 买的不是归因准确性(归因结果一模一样),而是"归因失败原因的可解释性"。 值不值取决于你是否需要向广告主解释差异——在广告场景,需要。

⚠️ 可信度边界:Flink 的 interval join 文档页没有说明它内部如何保留和清理状态。因此我不能断言"interval join 的状态清理是事件时间语义还是处理时间语义"。上面的方案不依赖这个未知——它绕开 interval join,用文档明确定义的事件时间定时器自己实现,所以语义是确定的。如果你想继续用 interval join,请自行阅读对应版本的源码或用实验验证其状态保留行为。

迟到、去重与调账:钱已经算过了怎么办

先分清三种"迟到",它们的处理方式完全不同:

类型 例子 处理
乱序(秒级) 点击的 event_ts 比先到的另一条早几秒 watermark 的 out-of-orderness 兜住,正常处理
窗口内迟到(天级) 转化在点击后第 5 天才发生并回传 这不是迟到,这是业务本身
超窗迟到 回传本身晚了——转化发生在第 3 天,但对方第 10 天才回传 归因窗口按转化发生时间判是命中的,但我们的状态已经清了

自问:基础篇 07 讲的 allowedLateness 能解决第三种吗?

不能。 allowedLateness窗口算子的机制:窗口首次触发后再保留一段时间,让迟到元素能再次触发计算。而归因不是窗口聚合,是双流关联——没有窗口,也就没有"窗口触发后再保留"这回事。§4.3 的宽限期在形式上类似,但它是手写的状态保留期,不是 allowedLateness

第三种的正解是分层,而不是把窗口无限拉长:

实时层(Flink):   处理窗口 + 1 天宽限期内到达的转化 → 覆盖【绝大多数】
                   超出的进侧输出,落到 dwd_ad_conversion_raw 的迟到分区

离线层(T+1 批):  每天用【原始事件明细】重跑一次前 N 天的归因
                   → 能捞回所有实时层漏掉的
                   → 产出【离线口径】的归因结果,与实时口径【并存】

  这就是 16A §4.4 那套实时/离线双跑对账,但这里多一层含义:
  广告的离线口径不只是【校验】实时口径,它本身就是【结算依据】。

去重必须做,而且要幂等。 之前说过转化回传可能重复。去重键的选择:

优先级 1: 对方提供的幂等键(如果协议里有)
优先级 2: (click_id, conv_type)        —— 适用于"每次点击每种转化只算一次"
优先级 3: (click_id, conv_type, 按小时截断的 conv_ts) —— 允许同类型转化多次,但同小时内只算一次

★ 无论用哪个,去重结果要写成 conversion_key,
  作为 dwd_ad_attribution 的主键(§3.5 表 ④),下游按它做 upsert。

最后是"钱已经算过了"这个问题——调账。

7月16日: 出账,广告主 A 消耗 100,000 元,转化 5,000 个
7月26日: 一批迟到回传到了,7月16日的转化应该是 5,200 个

能不能改 7 月 16 日那张账单?—— 【不能】。
  账单已经发出、对方已经入账、可能已经付款。
  改历史账单在财务上等同于篡改凭证。

正确做法(这是财务的通行做法,数据侧照搬):
  不改历史,在【当期】记一笔【调整】:
      7月26日  adjustment  归属日期=7月16日  转化 +200  金额 +0(oCPM 不按转化收费)

这条原则值得抽出来,因为它和数据工程的直觉冲突:

对已交付的结果,用 append-only 的调整记录,而不是原地修改历史。

15 篇讲 Iceberg 的 upsert 时,我们的目标是"让表反映最新真相";这里的目标不同——账必须能复现。7 月 16 日那天出的账是什么样,永远要能查出来;同时"今天我们认为 7 月 16 日应该是什么样"也要能查出来。两者是两条记录,不是同一条记录的两个版本。

落到表上:dwd_ad_attribution 用 upsert 维护"当前最佳归因"(给分析用);另外有一张只追加的 dws_ad_billing_adjustment(给财务用)。同一份业务事实,两种建模,因为两个消费方对"历史可变性"的要求相反。

历史重算:广告为什么比其他业务难

口径变了要重算历史,这件事本身不新鲜(12 篇讲埋点口径、15 篇讲 GMV 口径都提过)。广告难在三个额外约束

约束一:重算的输入不只是事件,还有"当时的配置"。

重算 7 月 16 日的归因,需要知道:
  · 当天广告 55001 的计费模式是 oCPM 还是 CPC?   ← 广告主中途改过
  · 当天 campaign 9001 的归因窗口配的是 7 天还是 14 天? ← 运营改过
  · 当天该广告主的预算状态、折扣系数是多少?        ← 每天都在变

如果用【今天的配置】重算【7 月 16 日的数据】,结果是错的。

这就是 15 篇 SCD Type 2(缓慢变化维,用 valid_from / valid_to 区间保存维度的历史版本)的用武之地——广告的所有配置维表都必须做成 Type 2,重算时按事件时间点查当时生效的那个版本:

-- 重算时的正确关联方式:按事件时间去命中【当时生效】的配置版本
SELECT
  c.click_id, c.event_ts,
  cfg.attribution_window,    -- 取的是 7 月 16 日当天生效的窗口配置
  cfg.billing_type
FROM dwd_ad_click c
JOIN dim_campaign_scd2 cfg
  ON c.campaign_id = cfg.campaign_id
 AND c.event_ts >= cfg.valid_from
 AND c.event_ts <  cfg.valid_to      -- ★ 这两行是"重算能对"的全部关键
WHERE c.dt = '2026-07-16';

模拟输入

dwd_ad_click(7月16日分区):
  click_id  campaign_id  event_ts
  ck-001    9001         2026-07-16 10:00:00

dim_campaign_scd2(campaign 9001 的两个历史版本):
  campaign_id  attribution_window  billing_type  valid_from            valid_to
  9001         click_7d            oCPM          2026-01-01 00:00:00   2026-07-20 15:30:00
  9001         click_14d           oCPM          2026-07-20 15:30:00   9999-12-31 00:00:00
                ↑ 运营在 7 月 20 日把归因窗口从 7 天改成了 14 天

运行结果

click_id | event_ts            | attribution_window | billing_type
---------+---------------------+--------------------+-------------
ck-001   | 2026-07-16 10:00:00 | click_7d           | oCPM

为什么是这个结果event_ts(7 月 16 日)落在第一个版本的 [valid_from, valid_to) 区间内,所以取到的是 click_7d——7 月 16 日当天真正生效的那个配置。如果把 JOIN 条件写成常见的"只取最新版本"(WHERE cfg.valid_to = '9999-12-31'),这里会取到 click_14d用今天的口径去重算 4 天前的数据,重算结果与当初出的账对不上,而且差异原因完全无法解释。

如果维表只有当前快照(Type 1),历史重算在原理上就是不可能的——信息已经丢了,任何工程手段都补不回来。这是本节最该记住的一句:重算能力不是重算时才建的,是在写维表的那一刻就决定的。

如果你的维表已经进了 Iceberg(08 篇),还有一条退路:用 time travel 读回 7 月 16 日那天的表快照。这比 SCD Type 2 省事,但只能精确到快照粒度(当天的多次变更看不到中间态),且依赖快照没有被过期清理掉。两者不冲突,关键配置用 Type 2,一般配置靠 time travel 兜底。

约束二:重算结果会改变已经出过的账。 → 调账机制,不再重复。

约束三:新旧口径必须能并行对比,不能直接切。

之前决策三的四个 ★ 字段在这里兑现价值:

重算不是"覆盖 7 月 16 日的归因结果",而是:

  ① 用新逻辑跑一遍,产出 calc_version = 'v4' 的数据,【追加】写入
     (老的 calc_version = 'v3' 一行不动)

  ② 对比 v3 与 v4 的差异分布 —— 【看分布,不只看总数】

  ③ 差异符合预期 → 把下游读取的默认 calc_version 从 v3 切到 v4
     差异不符合预期 → 什么都不用回滚,v3 一直在那儿

第 ② 步的"看分布"是关键,直接照搬 16A 的对账判据:

差异的分布形态 说明什么
总数变了,但差异均匀分布在所有广告主/广告位上 口径变更的预期效果,正常
差异集中在少数广告主 大概率是这些广告主的数据有特殊形态(如回传特别慢),要单独看
差异集中在时间轴尾部 迟到数据的影响,说明重算时那几天的数据还没收敛(§3.3)
总数没怎么变,但大量单条记录归因到了不同的广告 危险信号:总数掩盖了逐条的剧烈变化,账单会大面积变动

最后一行是广告特有的:总数对得上不等于账对得上,因为账是按广告主逐个出的。重算的验收标准必须下沉到广告主粒度,而不是大盘粒度。

上下游:谁在吃广告的数据

flowchart TB
    subgraph SRC["数据源"]
        A1["曝光/点击<br/>媒体客户端 200亿+4亿/天"]
        A2["转化回传<br/>广告主服务器 800万/天"]
    end
    subgraph CORE["核心链路"]
        B1["Kafka<br/>分主题隔离"]
        B2["Flink 归因作业<br/>状态 480GB"]
        B3["Iceberg 明细层<br/>impression/click/conv/attribution"]
    end
    subgraph DOWN["消费方"]
        C1["① 计费与结算系统"]
        C2["② 广告主投放后台"]
        C3["③ 预算与出价控制"]
        C4["④ 模型训练样本"]
        C5["⑤ 反作弊"]
        C6["⑥ 财务与审计"]
    end
    A1 --> B1
    A2 --> B1
    B1 --> B2
    B2 --> B3
    B3 --> C1
    B3 --> C2
    B2 -.秒级旁路.-> C3
    B3 --> C4
    B1 --> C5
    C1 --> C6

六个消费方,它们对数据的要求彼此矛盾——这正是广告数据平台最难的地方:

# 消费方 要什么 与谁冲突
计费与结算 精确到分、可追溯、可重放。宁可慢,不可错 与 ③ 的秒级要求直接冲突
广告主投放后台 分钟级更新 + 明确的口径说明(哪些数字还在回溯,§3.6)
预算与出价控制 秒级。预算花完了要立刻停投,晚 30 秒就是超投(平台自己贴钱) 与 ① 冲突
模型训练样本 归因结果 = 训练标签。要完整(宁可等 7 天)、要口径与线上推理一致 与 ③ 冲突:③ 要快,④ 要全
反作弊 原始的、未清洗的流。清洗规则本身可能把作弊特征洗掉了 与所有下游冲突(别人都要干净数据)
财务与审计 不可变的历史(§4.4 调账那一条) 与 ② 的"数字持续回溯"冲突

③ 和 ① 的冲突值得单独说,因为它给出了 16A §5.2 那个悬念的答案:

16A 说过:如果有一个消费方【同时】要求"秒级"和"精确到分",
              Lambda 架构就撑不住了。

广告的实际解法不是"做一条既快又准的链路"(做不到),
而是【把这个需求拆成两个消费方,各自降一个要求】:

  预算控制(③): 要秒级,但【不要求精确】
      → 允许超投 1%~2%,用一条独立的、只做粗略累加的秒级链路
      → 快到预算上限时提前降速(而不是精确卡在 100%)
      → 超投的部分平台自己吸收,这是【买延迟的钱】

  计费结算(①): 要精确,但【不要求秒级】
      → T+1 出账,走离线口径,与实时链路对账(§4.4)

结论: "既要秒级又要精确到分"这个需求,
      在工程上是靠【把它拆成两个可分别满足的需求】解决的,
      而不是靠一个更强的技术方案。代价是【超投的那 1%~2%】,
      这笔钱是明码标价买来的。

这是本篇最想传达的工程判断:当两个约束在物理上不可同时满足时,先别找更强的技术,先回去看这两个约束是不是真的必须由同一条链路承担

边界与坑

  1. bid_pricesettle_price 混用。用出价算收入会系统性高估,且高估幅度随竞争激烈程度变化,很难事后发现。
  2. 用广告主的时钟做分区键。对方时钟错乱会往已经对过账的历史分区写数据。
  3. 把归因结果写回点击事实表。会把只追加的表变成需要更新的表,同时让多口径并存变得不可能。
  4. 归因结果不带 attribution_model / calc_version。一旦换模型,历史数据就永远解释不清了。
  5. 给归因状态配 StateTtlConfig。处理时间语义与事件时间的归因窗口错配,追数据时状态爆炸、停机恢复后归因静默丢失。
  6. 用 Flink SQL 普通 join 做归因却不设 table.exec.state.ttl【官方口径】 默认 0 ms = 永不清理,状态无限增长。
  7. 用 interval join 而不管未匹配的转化。inner join 语义下它们静默消失,归因缺口无法归因(这句话不是绕口令)。
  8. 不设宽限期。"超窗"和"链路断了"会被混成一类,失去定位能力。
  9. 对曝光流也做流式归因。7 倍的状态换一个广告主本来就不认的口径。
  10. 拿 T+1 的 ROI/CVR 做决策。这些数字要 7 天后才收敛,报表上必须标注成熟度。
  11. CVR 按转化发生时间算。分子分母不是同一批人,得到的数字没有业务含义。
  12. 修改已出的历史账单。必须用 append-only 的调整记录。
  13. 配置维表只做当前快照。历史重算在原理上就不可能,这是无法用工程手段补救的。
  14. 重算只对大盘总数。总数对得上不等于每个广告主的账对得上。
  15. 给反作弊喂清洗后的数据。清洗规则可能正好把作弊特征洗掉了。
  16. 转化回传去重键选错。用 click_id 单独做键会把"同一次点击的下载 + 注册 + 下单"三个转化误判成重复。
  17. 忽略 view-through 的口径分歧。你报的转化数和广告主统计的对不上,如果说不清原因,会被认为在虚报。
  18. 默认自己的数字和广告主的数字应该一致。跨媒体重复归因是结构性的,不是 bug。

小结

广告的数据形状:三条流,量级相差 2500 倍,时间跨度 7 天,其中一条来自公司外部且不可信。

它把 Flink 的 state 压到极限的完整因果链:

转化发生在广告主侧、可延迟数天
  → 必须把点击缓存 7 天等它
    → 4 亿/天 × 7 天 × 150B ≈ 420 GB 常驻状态
      → 曝光流同样处理会到 3 TB,投入产出比不成立
        → 【曝光归因改走离线批】——判据:不参与结算 + 不需要秒级
      → 状态的生命周期必须与归因窗口同语义
        → state TTL 只支持【处理时间】(官方口径),归因窗口是【事件时间】
          → 追数据时状态爆炸,停机恢复后归因静默丢失
            → 【用事件时间定时器自己清理】,加 1 天宽限期换可解释性(+14% 状态)

三个可以迁移到其他场景的判断:

  1. 不要问"能不能用流做",要问"需不需要用流做"。判据是:是否是结算依据、是否需要秒级。两条都不满足就走批。
  2. 两个物理上冲突的约束,先看它们是不是真的必须由同一条链路承担。广告没有做出"既秒级又精确"的链路,而是把它拆成"秒级但允许超投 1%"和"精确但 T+1",并明码标价买下了那 1%。
  3. 重算能力是在写维表的那一刻决定的。维表只留当前快照,历史重算在原理上就不可能。

和前三篇的关系: 15 篇的 SCD Type 2 在这里从"建模规范"变成了"重算的前提条件";16A 的实时离线对账在这里从"校验手段"变成了"结算依据本身";16B 的 model_version 在这里从"元数据"变成了"账单的一部分"。同一个模式,约束越硬,它的地位越高。

Back to Blog