4. 维度建模基础与Databricks实现
维度建模的目标,是把业务过程拆成“可度量的事实”和“描述事实的维度”,让分析人员能够用简单的连接查询回答“谁、在什么时间、通过什么渠道、买了什么、买了多少”。在 Databricks 上,这一目标通常落在 Delta 表上:事实表记录业务事件的度量与外键,维度表记录实体的描述属性,二者通过代理键关联。本节以销售主题为例,说明星型模型与雪花模型的取舍、事实表与维度表的设计要点,并给出可直接运行的 Delta 建表与装载示例。
一、星型模型与雪花模型:先定结构,再定表
星型模型以事实表为中心,维度表直接挂在事实表周围,维度不再继续向外拆分。雪花模型则把维度进一步规范化,例如把“产品”拆成产品表与类别表,把“门店”拆成门店表与区域表。两者的核心差异不在“好不好看”,而在查询路径长度与维护成本:星型模型连接少、查询简单、适合 BI 高频聚合;雪花模型冗余低、更新一致性好,但连接层级多,查询更复杂。
在 Databricks 中,Delta 表支持 ACID 事务、时间旅行与 Schema 演进,因此星型模型并不需要为了“避免冗余”而过度拆分。对于销售主题,推荐以星型模型为主:fact_sales 作为事实表,dim_date、dim_product、dim_store、dim_customer 作为维度表。若产品类别需要独立维护且被多个事实表共享,可将其作为雪花分支保留,但不要为了规范化而把常用维度拆得过细。
二、事实表设计:粒度、度量与代理键
事实表设计的第一步是确定粒度。销售主题最常用的粒度是“订单行”,即一张订单中的一个商品行。粒度决定了事实表的唯一性:如果同一订单同一商品可能分多次发货,就需要把粒度细化到“发货行”,否则度量会被重复计算。在 Databricks 中,可以用 MERGE INTO 按业务主键做幂等写入,避免重复装载。
事实表的字段分为三类:外键、度量和退化维度。外键指向各维度表的代理键;度量是可加、半可加或不可加的数值,如 quantity(可加)、amount(可加)、discount_rate(不可加,需按行计算);退化维度是订单号、发票号等不单独建维但常被查询的字段。代理键建议用 BIGINT 并由 monotonically_increasing_id() 或维度表生成,避免直接使用业务键导致维度缓慢变化时事实表需要重写。
-- 事实表:销售订单行
CREATE TABLE IF NOT EXISTS sales_gold.fact_sales (
sales_sk BIGINT GENERATED ALWAYS AS IDENTITY,
order_no STRING,
date_sk INT,
product_sk BIGINT,
store_sk BIGINT,
customer_sk BIGINT,
quantity INT,
unit_price DECIMAL(10,2),
discount_rate DECIMAL(4,3),
amount DECIMAL(12,2),
cost DECIMAL(12,2)
)
USING DELTA
PARTITIONED BY (date_sk)
TBLPROPERTIES ('delta.autoOptimize.optimizeWrite' = 'true');
这里按 date_sk 分区,是因为销售查询几乎总带时间范围过滤,分区裁剪能显著减少扫描量。注意不要按 product_sk 这类高基数字段分区,否则会产生大量小文件。Delta 的 autoOptimize 可在写入时自动合并小文件,适合每日增量装载。
三、维度表设计:代理键、属性与缓慢变化维
维度表的核心是代理键与业务键的映射。以 dim_product 为例,product_sk 是代理键,product_id 是业务键,product_name、brand、category_id 是描述属性。当产品名称或品牌发生变化时,需要决定是覆盖旧值(SCD Type 1)还是保留历史版本(SCD Type 2)。销售分析通常需要按“下单时的产品属性”统计,因此推荐对关键属性使用 SCD Type 2,用 valid_from、valid_to、is_current 标记版本。
-- 维度表:产品(SCD Type 2)
CREATE TABLE IF NOT EXISTS sales_gold.dim_product (
product_sk BIGINT GENERATED ALWAYS AS IDENTITY,
product_id STRING,
product_name STRING,
brand STRING,
category_id STRING,
valid_from DATE,
valid_to DATE,
is_current BOOLEAN
)
USING DELTA;
装载维度时,先用 MERGE 关闭旧版本,再插入新版本。判断“是否变化”应基于业务属性列,而不是整行比较,否则时间戳字段会导致误判。对于 dim_date,建议一次性生成 10 年日期,包含年、季度、月、周、星期、是否节假日等字段,避免在查询中反复调用日期函数。
四、从源表到星型模型的装载链路
在 Databricks 中,典型链路是 Bronze(原始订单)→ Silver(清洗与去重)→ Gold(星型模型)。Silver 层完成空值处理、金额校验、订单行拆分;Gold 层完成代理键查找与事实表写入。代理键查找可用广播连接:维度表通常较小,广播后与事实数据做 LEFT JOIN,把业务键替换为代理键。
-- 用维度代理键替换业务键,写入事实表
MERGE INTO sales_gold.fact_sales AS t
USING (
SELECT
s.order_no,
d.date_sk,
p.product_sk,
st.store_sk,
c.customer_sk,
s.quantity,
s.unit_price,
s.discount_rate,
s.quantity * s.unit_price * (1 - s.discount_rate) AS amount,
s.cost
FROM sales_silver.orders_clean s
JOIN sales_gold.dim_date d
ON s.order_date = d.date
LEFT JOIN sales_gold.dim_product p
ON s.product_id = p.product_id AND p.is_current = true
LEFT JOIN sales_gold.dim_store st
ON s.store_id = st.store_id
LEFT JOIN sales_gold.dim_customer c
ON s.customer_id = c.customer_id
) AS src
ON t.order_no = src.order_no AND t.product_sk = src.product_sk
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *;
执行后应检查三类问题:一是代理键为空,说明维度表缺少对应业务键,需要先补维度;二是金额为负或为零,说明折扣率或单价异常;三是同一订单行重复,说明 MERGE 的匹配键不完整。可以用 SELECT COUNT(*), COUNT(DISTINCT order_no, product_sk) FROM fact_sales 对比行数与唯一键数,若不一致则需回溯 Silver 层去重逻辑。
delta.enableChangeDataFeed,便于下游增量消费维度变化;事实表则优先保证分区裁剪与文件合并,不必对每个度量列建索引。五、设计检查清单
- 事实表粒度是否唯一且与业务过程一致,是否存在重复计数风险。
- 所有外键是否指向维度表的代理键,而非业务键。
- 维度表是否包含
is_current或版本字段,以支持历史属性分析。 - 分区字段是否与高频过滤条件一致,是否避免高基数分区。
- 金额、数量等度量是否在 Silver 层完成校验,Gold 层只做聚合与关联。
- 是否用
MERGE保证幂等,重复运行不会产生重复事实行。
星型模型与雪花模型并非互斥:在 Databricks 上,可以先用星型模型满足大多数分析,再对共享的、更新频繁的维度做雪花拆分。关键是让事实表保持窄而长、维度表保持宽而稳定,并用 Delta 的事务与时间旅行能力支撑可回溯的装载过程。