维度建模基础回顾:星型模型与雪花模型、事实表与维度表、缓慢变化维(SCD)处理策略

好,咱们正式开始第二讲。在聊实时场景之前,我习惯先把地基夯实。维度建模这东西,说白了就是一套「怎么把数据组织得让业务人员看得懂、查得快」的方法论。我见过太多团队,一上来就搞实时,结果模型乱七八糟,最后查个指标要关联十几张表——那还不如不搞。

这一章我们聚焦四个核心概念:星型模型、雪花模型、事实表与维度表,还有那个让无数人头疼的缓慢变化维(SCD)。嗯,咱们一个一个来。

星型模型 vs 雪花模型

先看两张最经典的图。星型模型,中心一张事实表,周围一圈维度表,像星星一样辐射出去。雪花模型呢,维度表还能继续往下拆,像雪花一样分叉。

核心区别一句话:星型是「空间换时间」,雪花是「时间换空间」。实时场景下,我几乎只用星型。

星型模型(左)与雪花模型(右)对比 星型模型 销售事实表 订单ID | 金额 | 数量 时间维度 日期 | 月份 | 季度 产品维度 产品ID | 品类 | 品牌 门店维度 门店ID | 城市 客户维度 客户ID | 等级 雪花模型 销售事实表 订单ID | 金额 | 数量 时间维度 产品维度 品类表 品牌表 门店维度 城市表 客户维度 星型:查询快,适合OLAP;雪花:存储省,但关联多,实时场景慎用

我个人习惯,在实时数仓里几乎只用星型模型。为什么?你想想看,实时场景下每多一次 JOIN,延迟就多一分。雪花模型虽然省存储,但那个「省」在实时场景里根本不值一提——你省的那点磁盘,还不够弥补查询延迟带来的损失。

对比维度 星型模型 雪花模型
查询性能 高(单层 JOIN) 低(多层 JOIN)
存储空间 有冗余 无冗余
ETL 复杂度 简单 复杂
实时场景推荐度 强烈推荐 不推荐

我的经验:在实时 Flink 作业里,我习惯把维度表做成「宽表」或者直接加载到内存中。星型模型天然适合这种玩法——维度表少,缓存命中率高。

事实表与维度表

这两个概念是维度建模的基石。我换个说法帮你理解:事实表记录「发生了什么」,维度表描述「谁、什么、在哪、何时」。

事实表的三种类型

事实表不是只有一种。我遇到过不少新手,把所有事实都塞进一张表,结果数据膨胀得吓人。其实事实表分三类:

  • 事务事实表:每发生一次事件就记一行。比如每笔订单、每次点击。这是实时场景最常用的。
  • 周期快照事实表:按固定时间间隔拍快照。比如每天统计一次库存水位。
  • 累积快照事实表:跟踪一个流程的生命周期。比如从下单到发货到签收的全过程。

在实时场景里,我打交道最多的就是事务事实表。Kafka 里流过来的每条消息,基本都能映射成事务事实表的一行。

维度表的设计要点

维度表相对稳定,但也不是一成不变。设计时我习惯遵循几个原则:

  1. 代理键优先:别直接用业务主键,加一列自增的代理键。为什么?业务主键可能会变,代理键不会。
  2. 退化维度:如果某个维度只有一个属性,直接塞进事实表,别单独建表。比如订单号,很多时候就是退化维度。
  3. 维度一致性:同一个维度在不同事实表里要能共享。比如「时间维度」在所有事实表里定义必须一致。

避坑指南:我曾经在一个项目里直接用业务主键关联维度表,结果业务方某天改了主键规则,整个数仓数据对不上了。从那以后,代理键成了我的铁律。

缓慢变化维(SCD)处理策略

终于到了这个让无数人头疼的话题。SCD 说白了就是:维度属性变了,怎么办?

比如客户的等级从「普通」变成了「VIP」,或者产品的分类从「食品」调整成了「生鲜」。这些变化怎么处理?业界有几种经典策略。

SCD 类型一:直接覆盖

最简单粗暴。旧值不要了,直接改成新值。适合那些「错了就改」或者「历史不重要」的属性。

-- 客户等级变更,直接覆盖
UPDATE dim_customer
SET customer_level = 'VIP'
WHERE customer_id = 12345;

优点:实现简单,查询快。缺点:历史丢失了。如果你需要回溯分析,比如「上个月VIP客户有多少」,那就完蛋了。

SCD 类型二:增加新行

这是我最常用的策略。每次变化都新增一行,用生效时间区分。

-- 客户等级变更,新增一行
INSERT INTO dim_customer (customer_id, customer_level, effective_date, end_date, is_current)
VALUES (12345, 'VIP', '2024-06-01', '9999-12-31', 1);

-- 旧行关闭
UPDATE dim_customer
SET end_date = '2024-05-31', is_current = 0
WHERE customer_id = 12345 AND is_current = 1;

这样你既能查当前状态,也能回溯历史。实时场景下,我习惯在 Flink 里维护一个「最新维度快照」,同时把变更流写入历史表。

SCD 类型三:增加新列

这个策略比较奇葩——每次变化加一列。比如第一次变化用 level_1,第二次用 level_2。说实话,我几乎不用。列数会无限膨胀,而且查询时你得知道该查哪一列。

策略 实现方式 历史可追溯 实时场景适用性
SCD 1 直接覆盖 否 高(简单快速)
SCD 2 增加新行 是 中(需维护版本)
SCD 3 增加新列 有限 低(列膨胀)

注意:实时场景下 SCD 2 的实现要格外小心。Flink 的状态后端如果保存了全量维度历史,状态会越来越大。我一般只保留「当前有效版本」在状态里,历史版本丢到 HBase 或 MySQL 里。

实时场景下的 SCD 选择建议

嗯,这里我直接给结论吧:

  • 维度属性变化不频繁(比如性别、出生日期):用 SCD 1,直接覆盖。
  • 维度属性变化频繁且需要回溯(比如客户等级、产品分类):用 SCD 2,但只把当前版本放内存,历史版本放外部存储。
  • 别碰 SCD 3。除非你确定这个属性最多只变一次,而且业务方明确要求保留两次值。

我记得有一次做实时用户画像,客户等级一天变好几次。如果用 SCD 2 全量存状态,Flink 的状态能撑到 10GB。后来改成「只存最新 + 变更流写 Kafka」,状态降到 200MB,问题就解决了。

一个小技巧:在实时维度表里加一个 version 字段,每次变更 version + 1。这样下游消费时,如果发现 version 变了,就知道维度更新了,可以重新关联。