全文共 11,038 字 预计阅读 32 分钟
bg

bg15.电商

帅哥,买我的茶叶吗

在所有的内容之前,先来了解一下电商是怎么赚钱的:

模式 平台赚的是 核心指标 数据链路上的后果
自营(平台自己进货卖货) 进销差价 毛利率、库存周转、缺货率 必须打通供应链数据(采购、仓储、物流),指标算错直接压货亏钱
平台/marketplace(撮合商家与买家) 佣金 = GMV × take rate GMV、take rate、商家数、履约时效 GMV 是对外口径也是对内考核口径,口径分歧最激烈的地方
广告变现(商家买流量) 广告费 曝光量、CTR、ROI 需要归因——把成交归给哪次广告展示

GMV(Gross Merchandise Volume,商品交易总额):一段时间内平台上成交的商品总金额。注意"成交"这个词本身就是含糊的——后文会证明它有几十种解释。

take rate(货币化率):平台实际收入 ÷ GMV。举个示意数字:GMV 100 亿、佣金收入 3 亿,take rate = 3%。它是把"交易规模"换算成"公司收入"的乘数。

为什么先讲这两个:因为 take rate 是个乘数,GMV 口径的误差会被它放大成收入预测的误差。你把 GMV 口径搞错 5%,收入预测就错 5%。这就是为什么电商的 GMV 口径不是"数据团队内部约定",而是要过财务的东西。

钱越靠近,一致性要求越高

指标离钱多远  →  它的错误后果  →  对数据链路的要求
─────────────────────────────────────────────────────────
页面 UV / 停留时长  →  运营看趋势时判断偏了  →  最终一致就行、允许 1% 误差
转化漏斗           →  产品优化方向错        →  要求趋势可比,绝对值可以不准
GMV / 佣金          →  财务报表错、对外披露错 →  必须与业务库逐笔可对上
结算 / 打款         →  给商家多打或少打钱     →  必须逐笔精确、可审计、可追溯

记住这条梯度。它解释了本篇后面一系列"为什么要这么麻烦"的选择——同一条链路上不同的表,一致性要求差好几个档,不该用同一套标准去建设

电商数仓的输入不是"一堆数据",而是性质完全不同的两条链路,加上一类会偷偷变化的维度数据。这个三分结构是本篇的骨架。

交易数据的源头是业务数据库它的形状由订单状态机决定:

stateDiagram-v2
    [*] --> 待付款: 提交订单
    待付款 --> 已付款: 支付成功
    待付款 --> 已关闭: 超时未付 / 主动取消
    已付款 --> 待发货: 商家接单
    待发货 --> 已发货: 出库交运
    已发货 --> 已收货: 签收 / 超时自动确认
    已收货 --> 已完成: 售后期结束
    已付款 --> 退款中: 申请退款
    待发货 --> 退款中: 申请退款
    已发货 --> 退款中: 申请退货退款
    已收货 --> 退款中: 申请售后
    退款中 --> 已退款: 退款成功
    退款中 --> 已发货: 退款被拒
    已完成 --> [*]
    已退款 --> [*]
    已关闭 --> [*]

看这张图,三个技术上要命的性质直接就读出来了:

性质一:一行数据会被 UPDATE 很多次。 按状态机的边数算,一个正常走完的订单至少 6 次状态变更;再加上物流单号回填、优惠核销、发票信息、备注修改这些非状态字段的更新,实际次数远多于此。

【推断】 具体倍数各家差异很大,我给不出可信数字。但"一单多次 UPDATE"这个性质是确定的——它由状态机决定,不由某家公司的实现决定。这就够支撑后面的技术结论了。

性质二:终态可能在很久以后才到。 已收货 → 退款中 这条边可以在下单三周后触发。这意味着今天的数据会修改三周前的分区。对比 12 篇讲的埋点:埋点的 ODS 表按 server_time 分区后,"今天写入的数据只落进今天的分区"是个不变量;交易表没有这个不变量。

性质三:量不大但价值密度极高。 一笔订单一行、可能几百字节到几 KB。日千万级订单,一天的原始订单数据是 GB 量级——比埋点小两个数量级。但它每一行都直接对应钱。

行为流:来自埋点,只追加,量大两个数量级

这条链路就是 12 篇 的产物:曝光、点击、搜索、加购、页面停留。性质与交易流几乎处处相反——只追加、不修改、单条价值低、量极大、允许一定丢失率。

这两条流容易被混为一谈(都叫"业务数据"),所以值得逐维度摊开——这是全篇最该记住的一张表

维度 交易流(CDC) 行为流(埋点)
源头 业务数据库的变更日志 客户端/服务端 SDK 上报
语义 一行的状态变化(有 before/after) 一个已发生的事实(无 before)
是否可变 可变,一行反复 UPDATE,还能被 DELETE 不可变,只追加
数据量级(示意) 日千万行 日千亿行,大两个数量级
单行价值 极高,对应真金白银 低,单条丢了无所谓
容忍丢失 不容忍,丢一条订单是资损 容忍,1‰ 丢失率是常态
时间属性 变更时间与业务发生时间可能差数周 事件时间与上报时间差通常在分钟内
落湖方式 必须 upsert(按主键更新) append 即可

最后一行是本篇的技术核心:"必须 upsert"这四个字,把 Iceberg 的行级更新能力从"可选特性"变成了"命门"

第三类输入是维度数据:商品、店铺、用户、类目。它们也来自业务库,特点是——变得慢,但一变就会破坏历史

举三个真实会发生的变化:

  1. 商品 P100 的价格从 99 元改成 79 元
  2. 商品 P100 的类目从"零食/坚果"调整到"生鲜/坚果"
  3. 店铺 S9 改名了,从"老王食品"改成"老王食品旗舰店"

现在问题来了:一个月前的那笔订单,报表里应该显示 99 元还是 79 元?应该算在"零食"类目下还是"生鲜"类目下?

这个问题没有唯一正确答案,它取决于业务想看什么——而**"取决于业务想看什么"意味着它是建模决策,必须显式做出选择并记录下来**。这就是缓慢变化维(SCD)

GMV 不是一个指标,是一族指标。它的分歧不是沟通问题,是建模问题。

要把"GMV"这个词落成一个可计算的表达式,至少要做五个互相独立的选择:

选择一:按哪个时间归属?

  • 下单时间create_time):用户点"提交订单"的时刻
  • 支付时间pay_time):钱实际到账的时刻

差别有多大?一个 23:58 下单、次日 00:03 支付的订单,两种口径下落在不同的两天。大促零点前后这类订单是海量的。

选择二:算不算未支付的订单?

  • 下单 GMV:只要提交了订单就计入(包含"待付款"和"已关闭")
  • 支付 GMV:只算实际付了钱的

电商行业的下单未付款比例不低,两个数字可以差出很大一截。

选择三:退款怎么处理? 这是最麻烦的一个,三种做法:

  • 不扣减:GMV 只看成交那一刻,退款不影响历史
  • 回溯扣减:退款发生时,把当初那一天的 GMV 减掉
  • 当期扣减:退款发生时,从退款当天的 GMV 里减掉

选择四:金额取哪个字段?

  • 商品原价 × 数量(原始金额
  • 减掉商家优惠后(商家优惠后金额
  • 再减掉平台补贴的优惠券(用户实付
  • 实付里含不含运费?含不含

选择五:范围要不要剔除? 刷单订单、内部测试订单、员工内购、跨境保税与一般贸易——算不算进来?

保守估计每个维度的取值数:时间 2 × 是否含未付 2 × 退款 3 × 金额 4 × 范围 2 = 96 种

现实中不会有 96 张表,但"公司内部同时活跃着五六个 GMV 定义"是完全正常的。而且这五六个定义都是对的——它们服务不同目的:

  • 财务要的是支付时间 + 实付 + 回溯扣减 + 剔除测试(要和银行流水对得上)
  • 运营大促复盘要的是下单时间 + 不扣减(要看当时的用户下单意愿,退款是后面的事)
  • 商家后台展示的是该商家维度 + 支付口径
  • 对外披露的又是另一套(受审计约束)

问:既然都对,是不是就没法统一了?

不是。要做的不是"统一成一个",而是让每个数字都能自证它是哪一个。三条具体做法:

  1. 指标名带口径后缀,禁止裸用"GMV"。表里的列名写成 gmv_pay_amt_1d(支付口径、实付金额、日粒度)、gmv_create_amt_1d(下单口径)。看到列名就知道口径,不用去问人。
  2. 口径写进元数据,和表定义放在一起(指标字典,11 篇 §2 已给定义,19 篇展开)。写的是可执行的表达式,不是一句人话描述。
  3. DWD 明细层保留所有原子字段,不在明细层做口径选择create_timepay_timerefund_time原始金额优惠金额实付金额运费是否测试单 全部原样落下来。口径选择只发生在 DWS/ADS 层

第 3 条是最重要的架构原则,它的因果链是:口径一定会变(业务会改主意、财务会提新要求);如果口径固化在明细层,口径变更就要重算全部历史;如果明细层保留原子字段,口径变更只需重算汇总层。

代价:DWD 层更宽、存储更多、下游写查询时要自己拼口径(更容易写错)。这个代价换的是"口径变更不用重刷历史明细"——在电商这种口径每季度都动的场景里,这笔交易划得来。

订单事实表的三种粒度

粒度(grain)的定义:事实表里一行代表什么。这是维度建模里第一个要定的东西,定错了后面全错。

一笔"电商订单"至少对应三种不同的粒度,必须建三张表:

用户一次下单,买了 3 件商品,用了 2 张券,分 2 笔支付(余额 + 微信)
   │
   ├── ① 订单头(order header)粒度:1 行
   │      一行 = 一笔订单。字段:订单号、买家、店铺、整单实付、状态、各时间戳
   │
   ├── ② 订单行(order item)粒度:3 行
   │      一行 = 订单里的一个商品。字段:订单号、商品、SKU、数量、单价、分摊后金额
   │
   └── ③ 支付流水粒度:2 行
          一行 = 一笔资金流动。字段:订单号、支付方式、金额、支付时间、交易号

假设把订单头和订单行 JOIN 成一张宽表:

模拟输入

order_head:
  order_id  pay_amount(整单实付)
  O1        100.00

order_item:
  order_id  item_id  qty  item_amount(分摊后)
  O1        A         1    50.00
  O1        B         1    30.00
  O1        C         1    20.00

在宽表上执行

-- 危险写法:把订单头的"整单实付"和订单行 JOIN 到一起后直接求和
SELECT SUM(pay_amount) AS gmv          -- pay_amount 是"每单一个值"的整单金额
FROM order_head h
JOIN order_item i ON h.order_id = i.order_id;   -- JOIN 后 O1 变成了 3 行

运行结果

gmv
300.00        <- 真实值是 100.00,虚增 3 倍

为什么是这个结果pay_amount粒度是订单,JOIN 到订单行粒度后它被复制了 3 份,SUM 于是把同一笔钱数了 3 次。这类错误的通用名字叫扇出(fan-out)——把粗粒度的度量值 JOIN 到细粒度上再聚合,一定虚增,且虚增倍数等于细粒度的行数,所以不同订单虚增倍数还不一样,你没法用一个系数修回来。

正确做法:整单口径的金额只在订单头表上聚合;要按商品维度看金额,必须用已经分摊过的 item_amount。而"整单优惠怎么分摊到每个商品"本身就是一个口径决策(按原价占比分摊?按数量均摊?),必须显式定义并记录。

一条可以直接用的检查规则:任何一次 JOIN 之后跟着 SUM,先问一句"我 SUM 的这个字段,它的粒度和 JOIN 之后的粒度一致吗?"不一致就一定错。

累积快照事实表:Kimball 三类事实表里最特殊的一类

维度建模(Ralph Kimball 体系,见《The Data Warehouse Toolkit》)把事实表分成三类。这三类容易混,逐维度对比:

类型 一行代表 写入行为 电商里的例子
事务事实表
transaction fact
一个已发生的事件 只追加,永不修改 支付流水、每次点击、每次退款申请
周期快照事实表
periodic snapshot fact
一个固定周期的状态切片 每周期追加一批,历史不改 每日库存余额、每日账户余额
累积快照事实表
accumulating snapshot fact
一个有生命周期的流程实例 一行被反复 UPDATE,随流程推进 订单、退款单、履约单

累积快照事实表的定义:为一个有明确开始和结束、要经过若干里程碑的流程建一行,流程每推进一个里程碑就回来更新这一行,把该里程碑的时间戳和相关度量填上。

订单表就是它的教科书例子:

order_id  create_time  pay_time    ship_time   receive_time  finish_time  refund_time
O1        07-29 10:00  07-29 10:03 07-29 18:20 07-31 09:15   08-07 09:15  NULL
                       ↑           ↑           ↑             ↑
                       这些列一开始全是 NULL,随流程推进被一次次 UPDATE 填上

为什么值得单独建成这种形态

因为它让流程效率分析变成一次减法

-- 算各店铺的平均发货时长(下单到出库),单位小时
SELECT shop_id,
       AVG(TIMESTAMPDIFF(HOUR, pay_time, ship_time)) AS avg_ship_hours  -- 同一行内做减法
FROM dwd_order_accum
WHERE pay_time >= '2026-07-01' AND ship_time IS NOT NULL
GROUP BY shop_id;

模拟输入(三行订单,取自上面的表结构)

order_id shop_id  pay_time         ship_time
O1       S9       07-29 10:03      07-29 18:20      -> 8.28 小时
O2       S9       07-29 11:00      07-30 20:00      -> 33.0 小时
O3       S7       07-29 09:00      07-29 12:00      -> 3.0 小时

运行结果

shop_id  avg_ship_hours
S9       20.64
S7       3.00

为什么是这个结果:两个里程碑的时间戳在同一行,所以是行内减法,不需要 JOIN。如果用事务事实表存(下单一行、支付一行、发货一行),同样的问题要变成自关联 + 按订单号配对——数据量大时这是一次 shuffle(05 篇讲过 shuffle 的三笔代价)。累积快照用"写入时多做工作"换"查询时少做工作"。

代价,三条

  1. 它必须支持 UPDATE——这正是第四节要解决的技术问题。在只能 append 的存储上,这张表根本建不出来。
  2. 它的"当前值"是易变的。今天查到 O1 是"已完成",下周查同一行可能变成"已退款"。所以基于它算出来的历史报表不可重现——除非你能查到某个历史时点的版本(Iceberg 的时间旅行正好能做这件事,08 篇 §4)。
  3. 它不记录中间过程。O1 从"已付款"到"已发货"之间是否有过一次"退款被拒",这张表看不出来——那一列被后来的值覆盖了。所以实务上通常两张表都建:累积快照表回答"现在什么状态、各阶段多久",另配一张状态变更流水表(事务粒度,只追加)回答"它经历了什么"。

回到 之前的问题:商品 P100 的价格从 99 改成 79 了,一个月前那笔订单的报表该显示哪个价格?

先给名字。缓慢变化维(Slowly Changing Dimension,SCD):维度表里的属性值会随时间变化,但变化频率远低于事实表的增长速度。Kimball 体系给了几种标准处理方式,最常用的是三种。

Type 1:直接覆盖

维度表里就一行,新值覆盖旧值。

dim_item(Type 1)
item_id  item_name  price   category
P100     坚果礼盒    79.00   生鲜/坚果      <- 99.00 和"零食/坚果"都被覆盖没了
  • 效果:所有历史订单关联出来的都是当前价格和当前类目。
  • 好处:实现最简单,维表不膨胀。
  • 代价历史被改写了。上个月的报表今天再跑一次,数字会变。财务不能接受这个。
  • 什么时候用它:属性的历史版本对业务无意义时。比如商品的"详情页图片 URL"、店铺的"客服电话"——没人会问"上个月那笔订单当时的客服电话是多少"。

Type 2:加行保存历史(拉链表)

这是电商的默认选择。 属性一变就插入一行新记录,用生效区间标记每个版本的有效期。因为每行带一个 [生效日, 失效日] 区间、把同一个实体的历史串成一条链,中文业界习惯叫它拉链表

-- Type 2 维度表(拉链表)的标准结构
CREATE TABLE dim_item_scd2 (
    item_sk      BIGINT,       -- 代理键(surrogate key): 每个"版本"一个唯一 id, 事实表存的是它
    item_id      BIGINT,       -- 自然键(natural key): 业务上的商品 id, 同一商品多个版本共享
    item_name    STRING,
    price        DECIMAL(10,2),
    category     STRING,
    start_date   DATE,         -- 本版本生效日(含)
    end_date     DATE,         -- 本版本失效日(含), 当前版本填 '9999-12-31'
    is_current   BOOLEAN       -- 冗余标志位, 只为"查当前版本"这个高频查询加速
);

两个新名词(一次性提及,本系列不再展开)

  • 自然键(natural key):业务本身的标识,这里是 item_id。Type 2 下它不再唯一——一个商品有几个版本就有几行。
  • 代理键(surrogate key):数仓自己生成的、每个版本唯一的无业务含义 id,这里是 item_sk。事实表关联维表时存的是代理键,这样"这笔订单当时关联的是哪个版本"就被固化下来了。生成方式一般是自增或雪花 ID(14 篇 §4.3 提过雪花 ID)。

模拟输入:商品 P100 在 07-01 上架定价 99 元、类目"零食/坚果";07-20 调价到 79 元并改类目到"生鲜/坚果"。两笔订单:O1 在 07-10 支付,O2 在 07-25 支付。

维表内容:

item_sk  item_id  price   category      start_date   end_date     is_current
9001     P100     99.00   零食/坚果      2026-07-01   2026-07-19   false
9002     P100     79.00   生鲜/坚果      2026-07-20   9999-12-31   true

查询:

-- 目标:让每笔订单关联"下单当时"的商品版本
SELECT o.order_id, o.pay_time, d.price AS price_at_order_time, d.category
FROM dwd_order_item o
JOIN dim_item_scd2 d
  ON  o.item_id = d.item_id                        -- 用自然键连
  AND DATE(o.pay_time) BETWEEN d.start_date AND d.end_date;  -- 关键:用订单时间落进版本区间

运行结果

order_id  pay_time         price_at_order_time  category
O1        2026-07-10 ...   99.00                零食/坚果
O2        2026-07-25 ...   79.00                生鲜/坚果

为什么是这个结果BETWEEN start_date AND end_date 把"时间"变成了 JOIN 条件的一部分,于是同一个 item_id 的两个版本里,只有区间覆盖了订单时间的那一行能匹配上。O1 落在 [07-01, 07-19],O2 落在 [07-20, 9999-12-31]。历史因此可重现:一个月后重跑这段 SQL,结果不变。

代价,三条

  1. 维表膨胀。行数 = 实体数 × 平均版本数。一个改价频繁的商品一年能产生几十个版本;亿级商品的维表会显著变大。
  2. JOIN 变贵。多了一个区间条件,而且区间条件很难被索引高效利用(不是等值匹配)。大表关联时这是实打实的开销。
  3. 必须维护"关链"逻辑。插入新版本时,要同时把旧版本的 end_date9999-12-31 改成新版本生效日的前一天、is_current 改成 false。这个更新操作本身又是一次 upsert——于是维表也需要行级更新能力

第 3 条值得停一下:很多人以为"需要 upsert"只是订单表的事。不对,Type 2 维表把 upsert 需求从事实表扩散到了维度表。这让第四节的技术内容更加不可回避。

Type 3:加列保存有限历史

在同一行里加列,比如 priceprevious_price

  • 好处:不膨胀行数,JOIN 还是简单等值。
  • 代价只能存固定个数的历史版本(通常就 1 个),再变就丢。而且"上一个价格是什么时候生效的"这个信息没有位置放。
  • 什么时候用它:只关心"当前 vs 上一次"的对比场景,比如"类目调整前后的对比看板"。电商主链路基本不用它——因为你无法预知业务将来要看多少个版本的历史。

一个更彻底的方案:把值冗余进事实表

还有第四条路,实务里非常常见,值得单独说:下单那一刻,直接把商品价格、类目、店铺名快照进订单事实表

dwd_order_item
order_id  item_id  price_snapshot  category_snapshot  ...
O1        P100     99.00           零食/坚果
  • 好处:历史绝对不变,查询不需要 JOIN 维表(省掉一次 shuffle),最简单也最快。
  • 代价一:存储冗余——每笔订单都存一份商品属性。
  • 代价二(更要紧):只固化了下单当时你想到要存的那几个字段。半年后业务问"按商品的品牌维度看历史 GMV",而当初没冗余 brand,你就再也拿不到"当时的品牌归属"了——只能用当前维表近似。

所以实务上是组合拳:金额类、与钱直接相关的属性(成交价、优惠、税费)冗余进事实表,因为它们必须逐笔精确且永不变;分析类属性(类目、品牌、店铺层级)走 Type 2 维表,保留将来按任意历史版本回溯的能力。

转化漏斗:以及它为什么算不准

漏斗(funnel)的定义:一组按序发生的行为步骤,逐级统计到达人数,看每级流失多少。电商的商品域标准漏斗是:

曝光(item_impression) → 点击(item_click) → 加购(add_cart) → 下单(order_submit) → 支付(order_pay)

前四步来自埋点(行为流),最后一步既能从埋点拿也能从交易流拿——这个"两个来源"是后面全部问题的根源

一个朴素实现

-- 目标:算 2026-07-29 商品详情页的严格按序转化漏斗
-- 引擎方言:标准 SQL,云数仓 / Spark SQL 都能跑
WITH e AS (
    SELECT user_id, item_id, event_name, event_time
    FROM dwd_event_detail                       -- 12 篇产出的埋点明细层
    WHERE dt = '2026-07-29'                     -- 按 server_time 分区(12 篇 §7)
      AND event_name IN ('item_impression','item_click','add_cart',
                         'order_submit','order_pay')
),
step AS (
    -- 对每个(用户,商品)对,取每一步"最早发生时间"
    SELECT user_id, item_id,
           MIN(CASE WHEN event_name='item_impression' THEN event_time END) AS t1,
           MIN(CASE WHEN event_name='item_click'      THEN event_time END) AS t2,
           MIN(CASE WHEN event_name='add_cart'        THEN event_time END) AS t3,
           MIN(CASE WHEN event_name='order_submit'    THEN event_time END) AS t4,
           MIN(CASE WHEN event_name='order_pay'       THEN event_time END) AS t5
    FROM e GROUP BY user_id, item_id
)
SELECT
    COUNT(DISTINCT user_id)                                           AS s1_曝光,
    -- 每一级都要求:本级时间存在,且不早于上一级(这就是"严格按序")
    COUNT(DISTINCT CASE WHEN t2 >= t1 THEN user_id END)               AS s2_点击,
    COUNT(DISTINCT CASE WHEN t3 >= t2 AND t2 >= t1 THEN user_id END)  AS s3_加购,
    COUNT(DISTINCT CASE WHEN t4 >= t3 AND t3 >= t2 AND t2 >= t1
                        THEN user_id END)                             AS s4_下单,
    COUNT(DISTINCT CASE WHEN t5 >= t4 AND t4 >= t3 AND t3 >= t2
                        AND t2 >= t1 THEN user_id END)                AS s5_支付
FROM step;

模拟输入(示意规模:当日该品类曝光去重用户 184 万)

运行结果

s1_曝光    s2_点击   s3_加购   s4_下单   s5_支付
1,842,301  312,455   58,207    24,113    21,650

逐级转化率:      16.96%    18.63%    41.43%    89.78%
累计转化率:      16.96%     3.16%     1.31%     1.18%

为什么是这个结果——三点,每一点都是个坑:

  1. 41.43% 这个"加购→下单"转化率高得可疑,但它不是算错了。它的含义是"在加购过的人里面有 41% 下了单",这是个条件概率,本来就该比整体高。
  2. s4_下单 = 24,113 一定小于当天真实下单人数。因为电商详情页有"立即购买",用户完全跳过加购直接下单。严格按序漏斗要求 t4 >= t3,而这些人的 t3 是 NULL,条件为 NULL 不成立,他们被踢出了分子。所以这个数字不能拿去和订单表核对,核不上是正常的。
  3. 每一级都还带着埋点自身的丢失率(客户端上报会丢)。曝光事件量最大、最容易被采样或丢弃,所以分母偏小、转化率偏高

那这个漏斗还有什么用

有用,但用途要限定死:

  • 能用:同一口径下的趋势对比(本周 vs 上周)、横向对比(商品 A vs 商品 B、渠道 A vs 渠道 B)。这些用法里,系统性偏差在两边同时存在,会被抵消掉。
  • 不能用:当成绝对值向外汇报;拿去和财务/订单表对账。真实下单量、真实 GMV 永远从交易流取,不从埋点取。

这条纪律是那条梯度的直接应用:离钱近的数字走交易流,看趋势的数字走行为流。

无序漏斗,与引擎提供的加速函数

上面写的是严格按序漏斗。另一种是无序漏斗:只要在窗口内做过这些动作就算,不要求先后。无序漏斗数字更大、更"好看",也更接近"有多少人做过这些事",但它不能用来判断流失发生在哪一步。两种都合法,必须在指标定义里写清是哪一种。

另外,上面那段 SQL 有个真实缺陷:用 MIN(...) 取每一步最早时间,会漏掉这种路径——用户 09:00 从搜索页点进来(t2=09:00),10:00 又在首页看到一次曝光(t1 的最小值是 09:00 之前的另一次…)。一旦用户有多次进入,MIN 取到的各步最早时间未必属于同一条路径t2 >= t1 就可能误判。

正确做法是按会话(session)或按路径逐条判断序列。这类计算代价高,所以 OLAP 引擎普遍内置了专门函数——ClickHouse 系提供 windowFunnel(window)(timestamp, cond1, cond2, ...),在一次扫描里对每个用户做状态机推进。

前三节全部在铺垫同一个技术需求:订单表和 Type 2 维表都要求"按主键更新湖上已有的行"。这一节讲这件事怎么做,以及它贵在哪。

先否掉旧方案:T+1 全量拉表

Hive 时代的标准做法:每天凌晨用一个 Sqoop/Spark 作业去业务库 SELECT,把订单表整张拉下来,落成一个日期分区。四个问题,每个都单独致命:

Sqoop 是什么(一次性提及):Apache 的一个批量数据搬运工具,专做"关系型数据库 ↔ Hadoop/Hive"的整表导入导出,做法就是并发跑若干 SELECT 分片。它是 Hive 时代的标配,已于 2021 年 6 月退役(retired)、7 月完成迁入 Apache Attic(来源:attic.apache.org/projects/sqoop.html),本系列不展开。这里提它只为说明"旧方案"具体是什么样。

问题一:压线上库。 假设订单表 20 亿行 × 1 KB = 2 TB。这不是文件拷贝,是走 MySQL 查询路径的一次全表扫描——要过 InnoDB 的 buffer pool,会把线上业务的热数据挤出缓存。就算只拉从库,从库的复制延迟会在这期间飙升,而很多线上读请求是走从库的。

buffer pool 是什么(一次性提及):InnoDB 在内存里维护的数据页缓存,读到的表/索引页都先缓存在这里,后续命中就不用碰磁盘。它容量固定、按近似 LRU 淘汰,所以一次全表扫描会把大量冷页灌进去、把业务真正需要的热页挤出来——扫描结束后线上查询的磁盘 I/O 会明显上升。这是"离线作业不能直连线上库"最常被引用的技术理由。

问题二:拿不到中间状态。 一笔订单当天经历 待付款 → 已付款 → 待发货 → 已发货,T+1 拉到的只有最后一个状态。于是 §3.3 那张累积快照表的 pay_timeship_time永远拿不到真值——它们在数据库里被 UPDATE 覆盖了,而你只在一天的末尾看了一眼。"下单到发货多久"这个指标从源头上就算不出来。

问题三:发现不了 DELETE。 如果为了减轻问题一而改成增量拉(WHERE update_time >= 昨天),那被物理删除的行永远不会出现在结果里——湖里那条脏数据会一直留着。

问题四:延迟就是一天。 实时大盘、实时风控、大促作战室全部没法用。

这四个问题有一个共同的根SELECT 看到的是状态,而我们需要的是变化。查询一张表只能看到"它现在是什么样",看不到"它是怎么变成这样的"。要拿到变化,只能去读数据库自己记录变化的那个东西——binlog

binlog 的角色是"主从复制的搬运机制"。做 CDC 要多知道一层:binlog 有三种记录格式,只有一种能用

格式 记什么 能不能做 CDC
STATEMENT SQL 语句原文,如 UPDATE orders SET status='PAID' WHERE order_id=1 不能
ROW 每一行变更的前镜像 + 后镜像(before image / after image) 能,必须用这个
MIXED 两者混用,MySQL 自行决定 不能(含 STATEMENT)

为什么 STATEMENT 不行:那条 SQL 要复现出"哪些行变了、变成什么",必须有一份和源库一样的表数据去执行它。从库有,所以主从复制能用 STATEMENT;而 Flink 手里没有订单表,它只有一个流。ROW 格式则是自包含的——"order_id=1 这一行,status 从 CREATED 变成 PAID",不需要任何外部状态就能理解。

Debezium 是一组开源的 CDC 连接器,每个连接器对接一种数据库,把该数据库的私有变更日志格式翻译成统一结构的变更事件。它解决的是"每种数据库的日志格式都不一样"这个问题——MySQL 是 binlog,PostgreSQL 是逻辑复制流,Oracle 又是另一套。

【官方口径】(Debezium 官方架构文档):

  • 部署方式:「Most commonly, you deploy Debezium by means of Apache Kafka Connect.」另一条路是「Another way to deploy Debezium is by using the Debezium server.」
  • MySQL 怎么读:「The MySQL connector uses a client library for accessing the binlog.」
  • 运行位置:「Kafka Connect operates as a separate service besides the Kafka broker.」

Kafka Connect 是什么(一次性提及):Kafka 官方提供的一个数据搬运框架 + 独立运行的服务进程,专门用来把外部系统的数据搬进 Kafka(source connector)或从 Kafka 搬出去(sink connector)。你只需写/配置连接器,重试、位点管理、并发这些由框架负责。它是独立集群,不跑在 broker 里。本系列不展开它。

变更事件的结构:Debezium 的事件是个"信封",里面通常包含变更前的行(before)、变更后的行(after)、操作类型(op)、来源元信息(source,含 binlog 文件名与位点)。op 的取值一般是 c(create) / u(update) / d(delete) / r(read,指全量快照阶段读出来的行)。

Flink CDC:把 Debezium 当库嵌进 Flink

【官方口径】(Flink CDC 官方文档 stable 版,访问于 2026-07-30):debezium.* 配置项的说明是「Pass-through Debezium's properties to Debezium Embedded Engine which is used to capture data changes from MySQL server.」

这一句话说清了两者关系:Flink CDC 不是 Debezium 的替代品,它把 Debezium 当成一个嵌入式引擎(Embedded Engine)用——不经过 Kafka Connect 集群,直接跑在 Flink 的 TaskManager 里。

于是有两种拓扑,取舍很明确:

flowchart LR
    subgraph A["拓扑 A:经 Kafka(Debezium on Kafka Connect)"]
        M1[("MySQL<br/>binlog")] --> D1["Debezium<br/>on Kafka Connect"]
        D1 --> K1[("Kafka<br/>按主键 hash 分区")]
        K1 --> F1["Flink 作业 1<br/>入湖"]
        K1 --> F2["Flink 作业 2<br/>实时大盘"]
        K1 --> F3["下游 N…"]
        F1 --> I1[("Iceberg<br/>upsert")]
    end
    subgraph B["拓扑 B:Flink CDC 直连"]
        M2[("MySQL<br/>binlog")] --> F4["Flink CDC Source<br/>内嵌 Debezium Engine"]
        F4 --> I2[("Iceberg<br/>upsert")]
    end
  • A 的好处:binlog 只被拉一遍,落进 Kafka 后 N 个下游各自消费(06 篇讲的"一份数据多个消费组")。Kafka 还提供了缓冲,下游挂掉不影响采集。
  • A 的代价:多一跳,端到端延迟增加;多维护一套 Kafka Connect 集群。
  • B 的好处:链路短、延迟低、组件少。
  • B 的代价每个作业都要自己去连库拉一遍 binlog。N 个作业就是 N 份 binlog 拉取压力和 N 个 replication 连接(Flink CDC 官方要求每个 reader 有唯一的 server-id,就是因为它在冒充一个从库)。

规则:一张表的变更只有一个消费方、且延迟敏感 → B。有两个以上消费方 → A。电商核心订单表几乎一定是 A,因为想吃订单变更的下游太多了

链路搭好了,但有个开局问题:Iceberg 表是空的,MySQL 里已经有 20 亿行历史数据。必须先搬全量(snapshot),再从某个 binlog 位点接着读增量。这两段的边界怎么对齐?

问:随便记个位点不行吗?

不行,两个方向的错都很严重:

时间轴 ───────────────────────────────────────────────────►
                  ┌─────── 全量读取(耗时几十分钟) ───────┐
   位点记太晚 ─────┤                                     │
                  └─ 这期间发生的变更: 全量没读到(它在读之前的数据)
                     位点又在它之后 → binlog 也不会重发 → 【永久丢数据】

   位点记太早 ─── 这期间的变更会被 binlog 重发一遍
                  全量里也已经包含了 → 【重复】

"重复"是可修的——下游按主键 upsert,同一条变更来两次结果一样(幂等,[13 篇] 讲过幂等写为什么能救 at-least-once)。"丢"是不可修的,事后你根本不知道丢了什么。所以宁可重复不可遗漏。

朴素解法:锁表

FLUSH TABLES WITH READ LOCK   -- 全局读锁: 整个实例禁止写入
  → 记下当前 binlog 位点
  → 读全量 (20 亿行, 几十分钟)
UNLOCK TABLES
  → 从记下的位点开始读 binlog

逻辑上完全正确:锁住期间没有任何变更,所以位点和全量数据严格对齐。代价是线上库停写几十分钟——这在电商是不可能的。

无锁增量快照:把大问题切成小问题

Flink CDC 的默认方案。【官方口径】(同上文档):它使用「incremental snapshot algorithm, which avoid acquiring global read lock (FLUSH TABLES WITH READ LOCK)」,并且「The algorithm is inspired by [DBLog Paper]」(论文链接 arxiv.org/pdf/2010.12597v1.pdf)。配置项 scan.incremental.snapshot.enabled 默认开启

核心思路两步:

第一步:把表切成 chunk。 官方口径:「MySQL CDC source will identify the primary key column of the table and use the first column in primary key as the splitting column」;主键是数值/自增时「efficiently splits chunks by fixed step length」,默认 chunk 大小 8096 行。20 亿行于是变成约 25 万个 chunk,每个 chunk 可以独立、并行地处理

第二步:每个 chunk 内部用"位点信号"对齐(官方称 Offset Signal Algorithm,六步,引用原文要点):

① Record current binlog position as LOW offset          -- 记下 LOW 位点
② SELECT * FROM MyTable WHERE id > low AND id <= high   -- 读这个 chunk 的行, 缓冲在内存
③ Record current binlog position as HIGH offset         -- 记下 HIGH 位点
④ 读 LOW → HIGH 之间、属于本 chunk 的 binlog 记录
⑤ 把这些 binlog 记录 upsert 进内存缓冲, 然后把缓冲区全部输出
   官方原文: "emit all records in the buffer as final output (all as INSERT records)"
⑥ HIGH 之后的 binlog 由一个单并发的 binlog reader 继续读

这个算法为什么对[LOW, HIGH] 这段窗口内,chunk 数据可能被改动过——但那些改动必然出现在 LOW→HIGH 的 binlog 里,第 ④⑤ 步把它们合并进缓冲,于是输出的每一行都是 HIGH 时点的正确值。锁的范围从"全表 + 几十分钟"缩小到"不需要锁",代价只是每个 chunk 多读一小段 binlog。

第 ⑤ 步那句"全部当 INSERT 发出"值得单独说:为什么不区分 INSERT 和 UPDATE?因为下游按主键 upsert,在 upsert 语义下 INSERT 和 UPDATE 是同一件事——都表示"这个主键的最新值是这个"。这个观察把算法复杂度降了一档。但它也给下游提了硬要求:下游必须支持按主键 upsert。如果下游只能 append,这个算法不成立。

代价与边界(官方明确说明的):

  • 全量阶段可以并行(要求 server-id 配一个大于并行度的范围),但 binlog 阶段只能单并发——因为 binlog 是一条全局有序的流,切不开。所以增量阶段的吞吐上限就是单个 subtask 的处理能力。
  • 官方提供 scan.incremental.snapshot.backfill.skip 跳过第 ④⑤ 步(省内存和一次 binlog 读),但文档明确警告这样「might lead to data inconsistency」,且只保证「at-least-once semantic」。除非下游能容忍,不要开。

binlog 本身是严格有序的,但它到达 Flink 算子时的顺序可能已经乱了。四个来源:

  1. Kafka 分区键选错(拓扑 A 最常见的坑)。如果 topic 按"表名"或轮询分区,同一订单的 PAIDSHIPPED 会落进不同 partition。06 篇的结论:partition 内有序,跨 partition 无序。→ 必须按主键 hash 分区
  2. 全量 chunk 并行。多个 chunk 并发读,chunk 里的老数据可能晚于 binlog 阶段的新数据到达下游。
  3. 故障恢复重放。Flink 从 checkpoint 恢复会重放一段,重放的老记录和正常流混在一起。
  4. 上游多表关联后。两张表各自有序,join 之后的输出顺序不保证。

按主键分区是必要的,但不充分——它只解决第 1 条。第 2/3/4 条必须在下游处理。

方法:每条变更带一个单调递增的版本号(binlog 里的操作时间戳 op_ts,或更严格地用 binlog 文件名 + 位点组成的复合序号)。下游为每个主键记住"见过的最大版本",任何不超过它的变更直接丢弃

// Flink DataStream API。上游已按 order_id keyBy,所以每个 order_id 有自己独立的一份 state
public class LatestVersionOnly
        extends KeyedProcessFunction<Long, OrderChange, OrderChange> {

    // ValueState: Flink 提供的"每个 key 各一份"的状态容器(07 篇讲过 state)
    // 这里存该订单"已见过的最大 op_ts",相当于一条按订单隔离的版本水位线
    private ValueState<Long> maxOpTs;

    @Override
    public void open(Configuration cfg) {
        maxOpTs = getRuntimeContext().getState(
                new ValueStateDescriptor<>("maxOpTs", Long.class));
    }

    @Override
    public void processElement(OrderChange in, Context ctx, Collector<OrderChange> out)
            throws Exception {
        Long seen = maxOpTs.value();          // 该订单此前见过的最新版本时间;首次为 null
        if (seen != null && in.opTs <= seen) {
            return;                          // 旧版本或重复版本 → 丢弃,不往下游发
        }
        maxOpTs.update(in.opTs);              // 推进水位线
        out.collect(in);                      // 只有更新的版本才发给 Iceberg sink
    }
}

模拟输入(同一订单 O1 的三条变更,到达顺序被打乱)

到达次序   order_id   order_status   op_ts
①         O1         PAID           10:00:05
②         O1         CREATED        10:00:01     <- 迟到的旧版本
③         O1         SHIPPED        10:00:09

运行结果

① → 输出 O1 PAID     (state: null → 10:00:05)
② → 丢弃              (10:00:01 <= 10:00:05,不满足推进条件)
③ → 输出 O1 SHIPPED  (state: 10:00:05 → 10:00:09)

Iceberg 表中 O1 的最终状态: SHIPPED   ✅
若不做这层过滤:            CREATED   ❌ 订单会显示成"未支付"

为什么是这个结果:state 是按 key 隔离的,每个订单各自维护一条版本水位线,任何 op_ts 不超过水位的变更都被判定为"已被更新的版本覆盖过"。因此那条迟到的 CREATED 无法把一个已发货的订单改回未支付——这就是为什么它是资损级的坑:错的方向是"把已收的钱改成没收"。

代价:这份 state 的大小 ≈ 活跃订单数 × (key + 8 字节 long + 状态后端元数据)。它会随时间只增不减——三年前的订单 key 还占着位置。必须配 state TTL(07 篇讲过状态后端)。而 TTL 设多长是个真实取舍:设短了,一笔 TTL 过期后才到的退款变更会被当成新版本放过去;设长了,state 无限膨胀。17 篇会把这个矛盾推到极限——广告归因的窗口以天计,state 大到成为主要瓶颈。

能不能让表格式自己解决? Apache Paimon(另一种湖表格式,14 篇 已给定义)提供 sequence.field,声明一个列作为版本、自动丢弃旧版本。Iceberg 侧我没有核实到等价的配置项,所以不要按"它自带版本比较"来设计——请自己在所用版本的连接器文档里确认,确认不了就在 Flink 作业里显式做(上面那段代码)。

为什么"改一行"在湖上是难题

三个前提叠加起来就成了难题:

  1. Parquet 文件不可变:列式格式把一列的数据压缩编码后连续存放,改一行要重算它所在的整个 row group 的编码。
  2. 对象存储不支持原地修改:只能整对象 PUT。
  3. Iceberg 靠"换元数据指针"实现原子性

所以"更新一行"只能表达成:写出新文件,然后让新快照包含新文件、排除旧文件。问题在于——新文件的最小单位是"一个文件",而你只想改一行。这个错配就是 COW/MOR 分歧的全部来源。

【官方口径】(Apache Iceberg 表规范):format v2「adds row-level updates and deletes for analytic tables with immutable files」,其机制是「The primary change in version 2 adds delete files to encode rows that are deleted in existing data files」,从而允许改动单行「without rewriting the files」。

Copy-on-Write:整文件重写

定义:更新一行时,把这一行所在的整个数据文件读出来,在内存里改掉那一行,写出一个全新的文件,然后在新快照里用新文件替换旧文件。读的时候什么都不用做——文件里的每一行都是最新的。

写放大算式(以下全是为了算式可算而设的假设值,不是任何公司的真实数据):

表结构:  订单事实表,按 dt(下单日) 分区
文件:    单个 Parquet 数据文件 128 MB,一行订单约 1 KB → 每文件约 13 万行
分区:    每天 1000 万单 × 1 KB ≈ 10 GB ÷ 128 MB ≈ 80 个文件/天
提交:    Flink checkpoint 间隔 5 分钟,即每 5 分钟提交一次 Iceberg 快照
变更:    这 5 分钟内收到 30 万条变更,涉及 20 万个不同 order_id
分布:    因为退款/售后会碰到老订单,这些 order_id 的 dt 散布在最近 15 天
         → 候选文件数 = 15 天 × 80 = 1200 个

命中多少个文件?20 万个随机 order_id 落进 1200 个文件(每文件 13 万行),
按均匀分布,几乎每个文件都会被命中至少一次 → 命中数 ≈ 1200

COW 的 I/O:   读 1200 × 128 MB = 153.6 GB  +  写 153.6 GB
有效数据量:   30 万 × 1 KB ≈ 300 MB
写放大:       153.6 GB ÷ 0.3 GB ≈ 512 倍
吞吐要求:     307 GB 的读+写要在 5 分钟内完成 → 需持续 1 GB/s 以上

结论:COW 在"更新分散在大量文件里"的场景下直接不可用。而电商的更新天生就是分散的——退款会碰到任意历史日期的订单。

反过来说 COW 什么时候好:更新集中在少数文件时。比如"只更新最近 1 小时分区的数据"、或者"每天一次批量全量覆盖某几个分区"。COW 不是落后方案,它是另一种负载下的最优方案——它把全部代价压在写,换来读侧零开销。

Merge-on-Read:写删除标记,读时合并

定义:更新一行时,不动旧文件。只写两样东西:① 一条"旧行已作废"的删除记录(delete file);② 一行新数据。读的时候,引擎必须把 data file 和适用的 delete file 合并,把被标记的行过滤掉。

Iceberg 的删除记录有两种,【官方口径】(同规范):

类型 规范原文 定位方式
position delete 「Mark a row deleted by data file path and the row position in the data file」 文件路径 + 行号
equality delete 「Mark a row deleted by one or more column values, like id = 5 列值匹配

补充两条同样来自规范的细节:equality delete 用 manifest 里的 equality_ids 字段声明"按哪些列判等"(原文:「Field ids used to determine row equality in equality delete files」);position delete「are required to be sorted by file and position」。v3 起 position delete 的编码形式变为二进制删除向量(deletion vectors)

为什么流式 upsert 只能用 equality delete

这是本节最重要的因果链:

Flink 收到一条 "order_id=O1 状态变为 SHIPPED" 的变更
   │
   ├─ 它知道什么? 只知道主键 O1 和新的列值
   │
   └─ 它不知道什么? O1 的旧行躺在哪个 Parquet 文件的第几行
        │
        └─ 要知道,就必须先去湖里**查一次**(扫描 + 定位)
             │
             └─ 而它每 5 分钟要处理 30 万条变更 → 30 万次查询,不可能
                  │
                  └─ 所以只能写"按主键判等"的删除标记 = equality delete

规范本身也印证了这个取舍。在讲行血缘(row lineage)时它说明,通过 equality delete 更新的行无法追踪血缘,因为引擎「avoid reading existing data before writing changes and can't provide the original row ID for the new rows」——"写之前不读已有数据"正是 equality delete 存在的理由

反过来,批处理的 MERGE INTO / DELETE FROM 可以用 position delete:它本来就要扫描一遍表来定位目标行,position 是扫描的副产品,不额外花钱。

一句话记法知道行在哪 → position delete(读时便宜);只知道主键 → equality delete(写时便宜)。 流式入湖属于后者。

MOR 的写侧几乎没有放大:

写入量 = 30 万行新数据(≈300 MB) + 30 万条 equality delete(每条只需主键,约 20 字节 ≈ 6 MB)
写放大 ≈ 1.02 倍          ← 对比 COW 的 512 倍

代价全部转移到读侧,而且随时间累积

5 分钟一次提交 → 一天 288 次提交
每次提交在被触及的分区各产生 delete file
→ 一天下来,热分区上挂着成百个 delete file

读一次这个分区,引擎必须:
  ① 把所有适用的 delete file 读进来
  ② 用它们的主键值建一个内存哈希集合
  ③ 扫 data file 时逐行去这个集合里查,命中就丢掉

为什么 equality delete 比 position delete 读起来更贵:position delete 明确写着"文件 X 的第 N 行",只作用于那一个文件;equality delete 只写了列值,它的作用范围是一批文件。规范的说法是「a delete file must be applied to older data files with the same partition」,并且 manifest 里的 data sequence number「should be used for planning which delete files apply to a data file」。

data sequence number 是什么(一次性提及):Iceberg 给每次提交分配的一个单调递增的整数,记在 manifest 里,用来表示"这批文件内容的相对新旧"。它的作用就是让引擎能判断"这个删除标记该不该作用于这个数据文件"——删除标记只对序号比它小(更早写入)的数据文件生效,否则一条新写入的行会被一条老的删除标记误杀。

可信度说明:上面两句是我核实到的原文。规范里区分两类删除的精确适用条件在 "Scan Planning" 一节,我这次没能取到那一节的正文(页面被截断)。所以本篇只使用一个我确认成立的性质:equality delete 的适用范围按"同分区 + 序号更早"判定,比 position delete 的"精确到一个文件"要宽。精确规则请查规范的 Scan Planning 一节。

这条链的终点是:MOR 表必须定期 compaction,把 delete file 应用掉、重写成干净的 data file。这就是 [13 篇]讲的 rewrite_data_files。而 compaction 本身要抢资源、还会和写入作业产生提交冲突(13 篇 的"不会错但会白干")。

于是 MOR 的真实代价不是"读慢一点",而是"你多了一个必须长期运维的后台作业,而这个作业和你的写入作业争抢资源、还会互相冲突"。 这是选择 MOR 时真正要接受的东西。

这三种组合非常容易混,逐维度对比:

维度 COW MOR + position delete MOR + equality delete
写入代价 极高(整文件重写) 中(要先定位) 极低
写入端是否需要先读表 需要(读整文件) 需要(定位行号) 不需要
读取代价 零额外开销 低(删除只作用于单个文件) (范围宽、要建哈希集合)
是否必须 compaction 不必须 建议 必须
适合的写入模式 批量、更新集中 MERGE INTO / DELETE 流式 upsert
电商里的角色 历史分区重刷、口径变更回溯 批量修数、GDPR 删除 订单表/维表主链路

选择规则

  1. 写入是流式、按主键、更新分散 → MOR + equality delete,并同时把 compaction 作业一起上线(不要等出了问题再补)。
  2. 写入是批量、且更新集中在少数分区 → COW 更省心,读侧零负担。
  3. 一张表两种负载都有(白天流式入湖、夜里批量刷历史)→ 分区级差异化:近期分区 MOR,历史分区 compaction 后当 COW 用。这也是"冷热分层"在表格式层的体现。

大促洪峰:同一套链路在 30 倍流量下的裂缝

以下倍数是示意值(假设峰值为日常均值的 30 倍),用来说明机制,不代表任何真实平台的数据。裂缝本身与倍数无关。

裂缝一:分区热点。 新订单全部落进"当天/当前小时"这一个分区,写入完全不均匀。→ 分区键加分桶(bucket),或在分区下再按 order_id hash 分桶,把并发摊开。

裂缝二:checkpoint 间隔的两难。 缩短间隔 → 提交更频繁 → 小文件和 delete file 更多(13 篇的算式);拉长间隔 → 端到端延迟变大、单次提交的数据量变大、失败重做的代价变大。大促期间通常选择"拉长间隔换吞吐",代价是实时大盘的延迟变差——这个代价要提前和业务说清楚,不能事后解释。

裂缝三:compaction 和写入抢资源。 峰值期写入要吃满资源,而此时产生的小文件也最多、最需要 compaction。→ 错峰:大促当天降低 compaction 频率甚至暂停,事后集中处理。代价是这期间的查询变慢。

裂缝四:维表 lookup join 打爆缓存。 流里关联商品维表,缓存未命中就回查线上库或 KV——峰值期未命中率上升,可能把被关联的服务打挂。→ 提前预热缓存、把维表整份广播(内存换稳定)。

lookup join 是什么(一次性提及):流处理里关联维表的一种方式——流里每来一条记录,就拿它的 key 去外部存储(MySQL / Redis / HBase)现查一次维度值,通常配一层本地缓存。它和批处理的 JOIN 是两种不同的东西:批 JOIN 把两张表都读进来做 shuffle,lookup join 则是"一条一次点查"。代价就是本节说的——它把流量压力转嫁给了被查的那个服务

裂缝五:口径冻结。 这是个组织约定不是技术手段,但同样重要:大促期间禁止改指标口径。因为口径一改,前后数字不可比,而此时所有人都在盯大盘。

flowchart LR
    subgraph UP["上游:数据从哪来"]
        DB[("业务库<br/>订单/商品/店铺<br/>→ CDC")]
        SDK["埋点 SDK<br/>→ 行为流"]
        PAY[("支付系统<br/>→ 资金流水")]
        LOG[("物流/履约<br/>→ 轨迹与时效")]
        RISK[("风控<br/>→ 处罚与标记")]
    end
    subgraph DW["我们负责的部分"]
        ODS["ODS 原样落地"] --> DWD["DWD 明细<br/>保留全部原子字段"]
        DWD --> DWS["DWS 汇总"]
        DWS --> ADS["ADS 应用层"]
    end
    DB --> ODS
    SDK --> ODS
    PAY --> ODS
    LOG --> ODS
    RISK --> ODS
    subgraph DOWN["下游:谁在吃"]
        REC["推荐系统<br/>召回池 + 排序特征"]
        SRC["搜索<br/>商品热度/转化率权重"]
        AD["广告<br/>转化回传 → 17 篇"]
        CRM["营销圈人<br/>人群包 → 14 篇 §4.3"]
        SUP["供应链<br/>补货与库存预测"]
        FIN["财务结算<br/>对账与商家打款"]
        MCH["商家后台<br/>高并发点查"]
        BI["经营分析 / BI"]
    end
    DWD --> AD
    DWD --> FIN
    DWS --> REC
    DWS --> SRC
    DWS --> CRM
    DWS --> SUP
    ADS --> MCH
    ADS --> BI

八个下游对数据的要求互不相同,这正是 14 篇讲"四种交付形态"的现实来源。挑三个差异最大的说:

推荐系统(→ 20 篇展开)。要的是特征:用户近 7 天各类目点击/购买次数、商品近 1 小时转化率。特点是离线训练和在线推理必须用同一套口径——否则模型线上效果和离线评估对不上(training-serving skew)。它对绝对准确度要求不高,但对口径一致性要求极高

商家后台("生意参谋"这类)。要的是高并发点查:几十万商家同时刷自己店铺的实时看板。查询模式固定、并发极高、延迟要毫秒级。绝对不能让它直连数仓(14 篇 §4.2 讲了原因)——必须预聚合好推到 KV 或高并发 OLAP 里。

财务结算:整条链路上最硬的约束。 它要的是逐笔可对账、可审计、可追溯

  • 每一笔佣金必须能追到那笔订单、那笔支付流水
  • 数字一旦出账就不能变——所以它依赖的表必须能查到历史版本(Iceberg 时间旅行)
  • 跨天退款必须有明确的会计期归属规则(回到 §3.1 的选择三)
  • 它是唯一一个"数字错了要赔钱"的下游

所以一条实用的建设纪律:先满足财务,再满足其他人。财务的口径最严、约束最硬;先把最严的做出来,其他下游都是在它上面做放松。反过来做——先上一个"大概对"的实时大盘,再想改造成能对账的——基本要重做。

小结

电商这条链路的全部特殊性来自一件事:它的核心数据带状态,而且状态会反复变,还可能在几周后被改。

顺着这一条性质往下推,就得到了本篇的整条因果链:

订单有状态机
  → 一行被反复 UPDATE、终态可能几周后才到
     → T+1 全量拉表拿不到中间状态、发现不了删除     → 必须 CDC
     → 建模上需要"一行随流程更新"                   → 累积快照事实表
     → 维度属性变了历史会被改写                      → SCD Type 2(拉链表)
        → 拉链表的关链操作本身也是 upsert            → 维表也需要行级更新
           → 湖上"改一行"必须写新文件 + 换指针
              → 写入端只知道主键、不知道行在哪        → 只能用 equality delete
                 → 读时要合并、delete file 会累积     → compaction 成为必需的常驻作业
                    → compaction 与写入抢资源、会冲突 → 这才是选 MOR 的真实代价

另外三条值得单独带走的判断:

  • 口径不是沟通问题,是建模问题。 解法不是"统一成一个 GMV",而是让每个数字都能自证它是哪一个:命名带口径、口径进元数据、明细层不做选择。
  • 离钱的距离决定一致性档位。 同一条链路上,商家打款和页面 UV 不该用同一套标准去建设。先满足财务,其余都是在它上面放松。
  • 严格按序漏斗的绝对值一定偏小,它只能用于同口径的趋势和横向对比,不能拿去对账。
Back to Blog