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 的行级更新能力从"可选特性"变成了"命门"。
第三类输入是维度数据:商品、店铺、用户、类目。它们也来自业务库,特点是——变得慢,但一变就会破坏历史。
举三个真实会发生的变化:
- 商品 P100 的价格从 99 元改成 79 元
- 商品 P100 的类目从"零食/坚果"调整到"生鲜/坚果"
- 店铺 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 定义"是完全正常的。而且这五六个定义都是对的——它们服务不同目的:
- 财务要的是支付时间 + 实付 + 回溯扣减 + 剔除测试(要和银行流水对得上)
- 运营大促复盘要的是下单时间 + 不扣减(要看当时的用户下单意愿,退款是后面的事)
- 商家后台展示的是该商家维度 + 支付口径
- 对外披露的又是另一套(受审计约束)
问:既然都对,是不是就没法统一了?
不是。要做的不是"统一成一个",而是让每个数字都能自证它是哪一个。三条具体做法:
- 指标名带口径后缀,禁止裸用"GMV"。表里的列名写成
gmv_pay_amt_1d(支付口径、实付金额、日粒度)、gmv_create_amt_1d(下单口径)。看到列名就知道口径,不用去问人。 - 口径写进元数据,和表定义放在一起(指标字典,11 篇 §2 已给定义,19 篇展开)。写的是可执行的表达式,不是一句人话描述。
- DWD 明细层保留所有原子字段,不在明细层做口径选择。
create_time、pay_time、refund_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 的三笔代价)。累积快照用"写入时多做工作"换"查询时少做工作"。
代价,三条:
- 它必须支持 UPDATE——这正是第四节要解决的技术问题。在只能 append 的存储上,这张表根本建不出来。
- 它的"当前值"是易变的。今天查到 O1 是"已完成",下周查同一行可能变成"已退款"。所以基于它算出来的历史报表不可重现——除非你能查到某个历史时点的版本(Iceberg 的时间旅行正好能做这件事,08 篇 §4)。
- 它不记录中间过程。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,结果不变。
代价,三条:
- 维表膨胀。行数 = 实体数 × 平均版本数。一个改价频繁的商品一年能产生几十个版本;亿级商品的维表会显著变大。
- JOIN 变贵。多了一个区间条件,而且区间条件很难被索引高效利用(不是等值匹配)。大表关联时这是实打实的开销。
- 必须维护"关链"逻辑。插入新版本时,要同时把旧版本的
end_date从9999-12-31改成新版本生效日的前一天、is_current改成 false。这个更新操作本身又是一次 upsert——于是维表也需要行级更新能力。
第 3 条值得停一下:很多人以为"需要 upsert"只是订单表的事。不对,Type 2 维表把 upsert 需求从事实表扩散到了维度表。这让第四节的技术内容更加不可回避。
Type 3:加列保存有限历史
在同一行里加列,比如 price 和 previous_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%
为什么是这个结果——三点,每一点都是个坑:
41.43%这个"加购→下单"转化率高得可疑,但它不是算错了。它的含义是"在加购过的人里面有 41% 下了单",这是个条件概率,本来就该比整体高。- 但
s4_下单 = 24,113一定小于当天真实下单人数。因为电商详情页有"立即购买",用户完全跳过加购直接下单。严格按序漏斗要求t4 >= t3,而这些人的t3是 NULL,条件为 NULL 不成立,他们被踢出了分子。所以这个数字不能拿去和订单表核对,核不上是正常的。 - 每一级都还带着埋点自身的丢失率(客户端上报会丢)。曝光事件量最大、最容易被采样或丢弃,所以分母偏小、转化率偏高。
那这个漏斗还有什么用
有用,但用途要限定死:
- 能用:同一口径下的趋势对比(本周 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_time、ship_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 算子时的顺序可能已经乱了。四个来源:
- Kafka 分区键选错(拓扑 A 最常见的坑)。如果 topic 按"表名"或轮询分区,同一订单的
PAID和SHIPPED会落进不同 partition。06 篇的结论:partition 内有序,跨 partition 无序。→ 必须按主键 hash 分区。 - 全量 chunk 并行。多个 chunk 并发读,chunk 里的老数据可能晚于 binlog 阶段的新数据到达下游。
- 故障恢复重放。Flink 从 checkpoint 恢复会重放一段,重放的老记录和正常流混在一起。
- 上游多表关联后。两张表各自有序,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 作业里显式做(上面那段代码)。
为什么"改一行"在湖上是难题
三个前提叠加起来就成了难题:
- Parquet 文件不可变:列式格式把一列的数据压缩编码后连续存放,改一行要重算它所在的整个 row group 的编码。
- 对象存储不支持原地修改:只能整对象 PUT。
- 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 删除 | 订单表/维表主链路 |
选择规则:
- 写入是流式、按主键、更新分散 → MOR + equality delete,并同时把 compaction 作业一起上线(不要等出了问题再补)。
- 写入是批量、且更新集中在少数分区 → COW 更省心,读侧零负担。
- 一张表两种负载都有(白天流式入湖、夜里批量刷历史)→ 分区级差异化:近期分区 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 不该用同一套标准去建设。先满足财务,其余都是在它上面放松。
- 严格按序漏斗的绝对值一定偏小,它只能用于同口径的趋势和横向对比,不能拿去对账。