3. Delta Lake表设计与建模落地

在 Databricks 上做 AI 数据建模,Delta Lake 不只是一个存储格式,它同时承担了“表结构治理”和“数据版本治理”两件事。事实表与维度表能否长期可演化,取决于五个设计动作:分区、Z-order、合并模式、Schema 演进、时间旅行。它们不是五个独立开关,而是一条从写入到查询、从结构到历史的协同链。

一、分区:先问“过滤条件长什么样”

分区的本质是用目录切分数据,让查询在扫描前就能裁掉无关文件。因此分区键必须来自高频过滤列,而不是“看起来像分类”的列。典型事实表按 event_date 做日期分区,维度表若数据量不大,通常不分区,避免小文件。

分区粒度要克制。按天分区适合日增量百万到千万级的表;若单日数据只有几万行,按天分区会产生大量小文件,反而拖慢查询。此时可改为按月分区,或干脆不分区、依赖文件跳过。判断依据是:单分区文件数控制在合理范围,单文件大小尽量接近 128MB~1GB。

CREATE TABLE IF NOT EXISTS silver.fact_order (
  order_id      BIGINT,
  user_id       BIGINT,
  product_id    BIGINT,
  order_amount  DECIMAL(18,2),
  order_status  STRING,
  event_date    DATE
)
USING DELTA
PARTITIONED BY (event_date)
TBLPROPERTIES (
  'delta.autoOptimize.optimizeWrite' = 'true',
  'delta.autoOptimize.autoCompact'   = 'true'
);

这里开启 optimizeWrite 与 autoCompact,是为了在写入阶段就抑制小文件,而不是等查询变慢后再补救。

二、Z-order:在分区内部再排一次“物理顺序”

分区解决“扫哪些目录”,Z-order 解决“同一分区内先读哪些文件”。它把多个高基数列的位交错映射到文件排序中,使这些列上的过滤能命中更少的文件。适合 Z-order 的列是高频等值或范围过滤列,例如 user_id、product_id。低基数列如 order_status 收益有限。

OPTIMIZE silver.fact_order
ZORDER BY (user_id, product_id);

注意:Z-order 是重写数据的代价换查询收益,不宜每次写入都执行。常见做法是按天或按周对近期分区做一次 OPTIMIZE,并配合 VACUUM 清理旧文件。分区键本身不建议再放进 Z-order,因为分区已经完成了粗粒度裁剪。

三、合并模式:事实表用 MERGE,维度表用 SCD 策略

事实表通常按业务主键做 upsert,用 MERGE 保证幂等重跑。维度表则要区分“覆盖式”与“保留历史式”:前者直接更新属性,后者用生效时间与当前标志保留版本。

MERGE INTO silver.fact_order AS t
USING staging.fact_order_inc AS s
ON t.order_id = s.order_id
WHEN MATCHED AND s.op = 'U' THEN UPDATE SET *
WHEN MATCHED AND s.op = 'D' THEN DELETE
WHEN NOT MATCHED THEN INSERT *;

维度表的 SCD2 合并要同时处理“关闭旧版本”和“插入新版本”,并保证同一业务键只有一条 is_current = true。合并条件必须走主键,否则会退化为全表笛卡尔匹配。

四、Schema 演进:让表结构跟着业务长

上游新增字段是常态。Delta 提供两种演进方式:写入时自动合并新列,或显式覆盖表结构。前者适合加列,后者适合类型放宽。默认情况下,MERGE 的 UPDATE SET * 不会自动加列,需要显式开启。

-- 写入时自动合并新列
SET spark.databricks.delta.schema.autoMerge.enabled = true;

-- 显式演进表结构
ALTER TABLE silver.fact_order
ADD COLUMNS (coupon_code STRING COMMENT '优惠券编码');

演进的原则是“只加不删、类型放宽不收紧”。删除列或收窄类型会破坏下游读取,应通过新表或视图过渡,而不是直接改历史表。

五、时间旅行:把“可回滚”变成默认能力

每次写入都会生成新的 Delta 版本,历史版本可被查询、比对和恢复。排查“某天数据突然变少”时,先看版本历史,再对比相邻版本的行数差异,最后决定是否回滚。

DESCRIBE HISTORY silver.fact_order;

SELECT COUNT(*) FROM silver.fact_order VERSION AS OF 12;
SELECT COUNT(*) FROM silver.fact_order VERSION AS OF 13;

RESTORE TABLE silver.fact_order TO VERSION AS OF 12;

时间旅行依赖保留的历史文件,因此 VACUUM 的保留期决定了可回溯窗口。默认 7 天,若合规要求更长,应显式设置并评估存储成本。

分区、Z-order、合并、演进、时间旅行共同构成 Delta 表的“可演化闭环”:分区与 Z-order 决定读效率,MERGE 决定写正确性,Schema 演进决定结构弹性,时间旅行决定可恢复性。
Delta Lake 事实表 / 维度表 可演化闭环 增量写入 staging 表 / 流批输入 op = I / U / D MERGE 合并模式 事实表:主键 upsert 维度表:SCD1 / SCD2 Schema 演进 autoMerge 加列 / ALTER 显式演进 只加不删 · 类型放宽不收紧 Delta 表物理布局 分区 PARTITIONED BY event_date 目录裁剪 Z-order 文件排序 user_id / product_id 命中 小文件治理 optimizeWrite / autoCompact 时间旅行与版本治理 DESCRIBE HISTORY 查看版本与操作 定位异常写入 VERSION AS OF 按版本查询 对比行数差异 RESTORE TABLE 回滚到指定版本 恢复正确状态 VACUUM 保留期 决定回溯窗口

六、落地顺序与判断依据

实际建模时,建议按“先定主键与分区、再定合并策略、最后补演进与回溯”的顺序推进。判断一张表是否设计合理,可以看四个信号:查询是否只扫必要分区;同一分区内过滤列是否命中少量文件;重跑是否幂等;结构变更后下游是否无需改代码即可读取。

设计动作作用对象关键参数/语句判断依据
分区事实表PARTITIONED BY (event_date)过滤列命中分区,单分区文件数合理
Z-order分区内文件OPTIMIZE ... ZORDER BY高基数列过滤命中文件数下降
合并模式事实/维度表MERGE ON 主键重跑幂等,SCD2 仅一条当前记录
Schema 演进表结构autoMerge / ALTER ADD COLUMNS加列不破坏下游读取
时间旅行历史版本VERSION AS OF / RESTORE可定位异常版本并回滚
常见误区:把低基数列当分区键、对全表频繁 Z-order、MERGE 条件不走主键、直接删除列、VACUUM 保留期过短导致无法回溯。这些问题往往在数据量增长后才暴露,应在建模阶段就规避。

把上述五项落到一张事实表和一张维度表上,Delta Lake 就从“能存数据”升级为“能长期演进、能安全回滚、能高效查询”的建模底座。后续无论是特征表、指标表还是 AI 训练样本表,都可以沿用这套设计语言。