产品经理想要上个季度按类别和地区划分的收入,并与去年同期对比。在订单数据库里,这意味着要连接五张表,临时构建一个日历,还要记得客户会在不同地区之间搬家。每个新问题都需要新的连接,两位分析师还会算出两个不同的数字。
星型模型围绕一个业务流程重新组织数据,解决了这个问题:中央是一张存放测量值的事实表,周围是描述这些测量值的维度表。本文从粒度到 SQL,逐步设计一个销售星型模型,内容包括缓慢变化维度、星型与雪花模型的对比,以及列式数据仓库。
什么是星型模型
星型模型有两种表:
- 事实表记录业务事件的测量值,例如一行订单明细:指向各维度的外键,加上数值度量。它们窄而长。
- 维度表保存上下文,即谁、什么、哪里和何时:宽、扁平、行数少的表,包含类别或地区这类文本属性。
为这些技术中的许多命了名的 Kimball Group 把星型模型描述为:事实表通过主键和外键与维度表相连。Microsoft 的 Power BI 指南简洁地概括了它的吸引力:维度表支持筛选和分组,事实表支持汇总。
分四步设计
Kimball 的四步维度设计流程是按顺序做出的四个决定:
- 选择业务流程:门店和网店的零售销售。
- 声明粒度:每行订单明细一行,也就是一张订单中的一件商品。Kimball 把粒度称为有约束力的合约,要在任何维度或事实之前声明。
- 确定维度:日期、商品、门店、客户和促销,每个维度在每行订单明细上只有一个值。
- 确定事实:数量、单价、折扣、净额和成本,在这个粒度上都成立。
从原子粒度开始,也就是数据源能捕获的最低层级;汇总由原子行得出,绝不能反过来。永远不要混用粒度:按订单收取、却存在每一行上的运费,会按行数重复计算。像 Kimball 建议的那样,用业务规则把它分摊到各行,或者给它单独建一张事实表。
示例中的表
| fact_sales 的列 | 角色 | 说明 |
|---|---|---|
date_key |
外键 | 智能键,如 20261120 |
product_key, store_key, customer_key, promotion_key |
外键 | 代理整数 |
order_number |
退化维度 | 背后没有维度表 |
line_number |
行标识 | 与 order_number 一起唯一 |
quantity, discount_amount, net_amount, cost_amount |
可加度量 | 可沿任意维度求和 |
unit_price |
不可加度量 | 不要求和 |
维度刻意做成扁平的:dim_product 在一行里带有品牌、子类别和类别,dim_store 把网店当作又一家门店,channel = 'online'。order_number 是退化维度:订单头的上下文在其他维度里,只剩下订单号,它对购物篮分组仍然有用。
单价是经典陷阱:跨行求和没有意义,应该改为用可加的部分相除,例如 SUM(net_amount) / SUM(quantity)。
代理键与日期维度
每个维度都有一个代理键,即从 1 开始顺序分配、本身没有含义的整数,因为记录历史的维度每个自然键对应多行,而且各数据源对键的看法常常不一致。把自然键(sku、store_code、customer_id)保留为属性,用来把行追溯到数据源。
日期维度是例外:它稳定且可预测,所以键可以有含义,例如 20261120,这也有利于分区。筛选和分组要用它的属性(year、quarter、is_holiday),而不是对键做算术运算。二十年只有约 7,305 行(20 × 365.25)。
事实表的外键不应为空:空键会让这一行在内连接中消失,总数会悄悄变小。对未知或不适用的值使用默认维度行;这里 promotion_key 为 0 表示「无促销」。
缓慢变化维度
缓慢变化维度(SCD)的类型决定属性变化后报表显示什么:
| 类型 | 名称 | 发生变化时 | 保留的历史 |
|---|---|---|---|
| 0 | 保留原值 | 值永不改变 | 仅原始值 |
| 1 | 覆盖 | 原地更新该行 | 无 |
| 2 | 新增行 | 关闭当前行,用新键插入新行 | 完整 |
| 3 | 新增属性 | 把旧值复制到 prior_ 列,再覆盖 |
一个先前的值 |
Kimball 的类型 4 到 7 增加了迷你维度以及这些类型的混合。
类型 2 是主力。客户 C-1042 于 2026 年 11 月 20 日从 Austin 搬到 Denver:
| customer_key | customer_id | city | valid_from | valid_to | is_current |
|---|---|---|---|---|---|
| 1001 | C-1042 | Austin | 2024-03-14 | 2026-11-20 | false |
| 2417 | C-1042 | Denver | 2026-11-20 | 9999-12-31 | true |
之前的销售沿用键 1001,仍归在 Austin;之后的销售使用 2417。事实加载会选择范围包含订单日期的那一行(valid_from <= order_date < valid_to)。Kimball 对类型 2 的定义至少需要生效日期、失效日期和当前行标志;用遥远未来的 valid_to 代替空值,可以让范围筛选保持简单。dbt 快照可以替你实现类型 2。
要按当前城市汇报全部历史,就用相同的 customer_id 连接到当前行。变更数据捕获很适合为类型 2 供数,因为每次更新都以事件的形式到达;参见 change data capture 图。
星型模型、雪花模型与宽表
| 星型模型 | 雪花模型 | 宽表 | |
|---|---|---|---|
| 维度数据 | 每个维度一张扁平表 | 规范化的链:商品、子类别、类别 | 复制到每一行事实上 |
| 每次查询的连接 | 每个用到的维度一个 | 每条链中的每张表一个 | 无 |
| 修正一个属性 | 更新一张维度表 | 更新链中的一张表 | 重写每一行受影响的事实 |
| 最适合 | 大多数数据集市和 BI 模型 | 对分析很少值得 | 一个仪表板或 ML 抽取,由星型模型生成 |
Kimball 不建议使用雪花维度:业务用户更难浏览,可能损害查询性能,而扁平化的维度包含的信息完全相同。Power BI 的指南也持相同看法。
宽表不需要连接,列式压缩也让重复的值保持低成本。代价是维护:修正变成重写,新属性变成重建,所以要从星型模型生成它。
这个模型让查询变简单
SELECT d.year_month,
p.category,
SUM(f.net_amount) AS revenue,
SUM(f.net_amount - f.cost_amount) AS gross_margin,
SUM(f.net_amount) / NULLIF(SUM(f.quantity), 0) AS avg_selling_price
FROM fact_sales AS f
JOIN dim_date AS d ON d.date_key = f.date_key
JOIN dim_product AS p ON p.product_key = f.product_key
JOIN dim_store AS s ON s.store_key = f.store_key
WHERE d.year = 2026
AND s.channel = 'online'
GROUP BY d.year_month, p.category
ORDER BY d.year_month, revenue DESC;
每个连接都从事实表出发,通过一个整数键连到一个维度;筛选条件放在维度属性上,求和放在事实列上。新问题只改变 WHERE 和 GROUP BY,不改变连接,这就是 BI 工具能可靠生成这类查询的原因。
列式数据仓库中的性能
列式数据仓库只读取查询用到的列,这适合窄的事实表,小维度的连接成本也低:在广播连接中,BigQuery 会把较小的表发送到每个 worker。INT64 键和数量按 8 个逻辑字节、NUMERIC 金额按 16 个字节计算,上面的查询每行订单明细从事实表读取 64 字节,5 亿行约 32 GB,无论表有多宽。
- 按日期裁剪。 按
date_key对事实表分区、聚簇或排序。当分区列本身带有常量筛选条件时,BigQuery 才会裁剪分区,所以大规模扫描除了dim_date.year,还应该用常量筛选date_key。 - 即使不强制,也要声明键。 BigQuery 使用声明为
NOT ENFORCED的主键和外键来消除和重排连接,Snowflake 对标记为RELY的约束会消除连接。两者都不检查数据,违反约束可能返回错误结果,所以要在管道中测试键。 - 在 Redshift 中同位存放。 Redshift 的指南是:按共同的键分布事实表和一个维度,对其他一些维度使用 ALL 分布,或者让
AUTO来选择。
星型模型通常位于奖牌架构的金层;数据管道架构图展示数据如何到达那里,ETL 与 ELT则说明转换在哪里运行。
销售星型模型图
图表内容
- fact_sales
- dim_date
- dim_customer
- dim_product
- dim_promotion
- dim_store
- date_key
- customer_key
- promotion_key
- product_key
- store_key
这张图的读法如下:
- 中央的 fact_sales 有五个外键、退化的
order_number、line_number和五个度量。 - 每条线上都有鸦脚:一端是零行或多行事实,另一端是恰好一行维度。标签标明连接键。
- 青绿色卡片是维度,dim_date 在上方,其余在左右两侧。
- 琥珀色卡片 dim_customer 以 SCD 类型 2 保留历史。
- Markdown 块写明粒度,以及哪些度量可以求和。
把星型模型图画清楚
- 把粒度写在图上。 评审的人最先问的就是它,粒度被误读会导致重复计算。
- 把事实放在中间,每个维度只隔一跳。 两个维度之间的连线就是雪花;要么有意地画出来,要么把它扁平化。
- 标出键的角色:PK、自然键用 NK、退化维度用 DD。
- 标出 SCD 类型:在保留历史的维度上。
- 省去:加载作业、暂存表,以及没人用来筛选的列。
- 每张图只画一个业务流程。 当多张事实表共享一致性维度时,加一个总线矩阵:流程为行,维度为列。
鸦脚记法请参见 ER 图示例。
在 Archboard 中构建
- 用 Google 或 GitHub 登录并创建一张图表。
- 如果表已经存在,把它们的
CREATE TABLE语句粘贴到 SQL 转 ER 图工具,或把 DBML 粘贴到 DBML 转 ER 图工具,得到初稿。 - 从实体关系包中添加一个名为 fact_sales 的实体,每行一个属性:五个标记为 FK 的键、标记为 DD 的 order_number、line_number,然后是各个度量。给它蓝色强调色。
- 再添加五个实体节点:dim_date 在事实上方,dim_customer 和 dim_promotion 在左侧,dim_product 和 dim_store 在右侧。把代理键标为 PK,自然键标为 NK;使用青绿色强调色,dim_customer 用琥珀色。
- 用鸦脚把 fact_sales 连到每个维度,事实一端为零或多,维度一端为恰好一个,并在每条线上标出它的键。
- 添加基础包中的文本注释:键图例、SCD 类型 2 规则、智能日期键、默认促销行和网店。
- 在事实下方添加基础包中的 Markdown 块,写明粒度和可加性规则。
这个示例共用 7 个图表节点,在 Free 套餐的限制之内。
常见问题
简单来说,星型模型是什么? 一张中央事实表,保存销售金额之类的测量值,连接到描述它们的维度表,例如日期、商品和客户。画出来像一颗星。
事实表和维度表有什么区别? 事实表按声明的粒度存储事件的测量值,外加外键。维度表存储你用来筛选和分组的属性,例如类别或地区。
星型模型和雪花模型有什么区别? 星型模型把每个维度放在一张扁平表里;雪花模型把它规范化为商品、子类别和类别这样的链。星型模型需要的连接更少,也更易用。
星型模型是规范化的吗? 不完全是。维度表是刻意反规范化的,会在每个商品行上重复类别名称之类的值,而事实表保持窄。
在列式数据仓库中,星型模型还重要吗? 重要。宽表在列式引擎中扫描成本低,但星型模型仍然让你有一个地方修正属性,通过缓慢变化维度保留历史,并让各报表给出一致的答案。