事实表和维度表到底怎么设计?从星型模型到 Kimball 维度建模(企业实战)

0 阅读44分钟

事实表和维度表到底怎么设计?从星型模型到 Kimball 维度建模

上一篇我们分析了企业为什么要把数据仓库划分为 ODS、DWD、DWS、ADS。

但是理解数仓分层以后,还会遇到一个更实际的问题:

DWD 层里的表到底应该怎么设计?

很多人刚接触数据仓库时,会直接把 MySQL 里的业务表同步到 Hive、Doris 或 StarRocks,然后继续按照业务数据库的方式写 SQL。

结果是:

  • 查询需要关联大量业务表;
  • 同一指标被重复开发;
  • 数据粒度混乱;
  • Join 后金额和订单数被重复计算;
  • 历史状态无法准确还原;
  • 表越来越多,却不知道哪些是真正可信的数据。

这些问题的本质不是 SQL 写得不够好,而是数据模型没有设计好。

本文将从一个电商业务案例出发,逐步解释:

  • 为什么业务表不适合直接分析;
  • 什么是 Kimball 维度建模;
  • 事实表应该如何设计;
  • 维度表应该如何设计;
  • 为什么声明粒度是建模中最重要的一步。

一、为什么业务表不能直接拿来分析

假设一家电商公司有下面几个业务系统:

用户系统
商品系统
订单系统
支付系统
库存系统
物流系统
优惠券系统

每个系统都有独立的数据库和数据表。

订单系统可能包含:

orders
order_items
order_status_log

商品系统可能包含:

products
product_categories
brands
suppliers

用户系统可能包含:

users
user_addresses
user_levels

支付系统可能包含:

payments
refunds
payment_channels

这些表最初都是为了支撑业务系统运行,而不是为了数据分析。

1.1 业务数据库的目标是完成交易

用户下单时,业务系统最关心的是:

  • 订单能不能成功创建;
  • 库存能不能正确扣减;
  • 支付是否成功;
  • 事务是否一致;
  • 单笔请求响应是否足够快。

例如,根据订单号查询一笔订单:

SELECT
    order_id,
    user_id,
    order_status,
    pay_amount
FROM orders
WHERE order_id = 10001;

这种查询通常只读取一条或者少量数据。

业务数据库非常适合这种场景。

但数据分析关注的问题完全不同。

例如,运营人员可能提出:

统计最近30天,福建地区黄金会员购买手机类商品的订单量、支付金额和退款率。

这时可能需要关联:

订单表
订单明细表
用户表
用户等级表
用户地址表
商品表
商品分类表
支付表
退款表
地区表

查询可能变成:

SELECT
    COUNT(DISTINCT o.order_id) AS order_count,
    SUM(oi.pay_amount) AS pay_amount,
    SUM(r.refund_amount) / SUM(oi.pay_amount) AS refund_rate
FROM orders o
JOIN order_items oi
    ON o.order_id = oi.order_id
JOIN users u
    ON o.user_id = u.user_id
JOIN user_levels ul
    ON u.level_id = ul.level_id
JOIN user_addresses ua
    ON u.default_address_id = ua.address_id
JOIN regions rg
    ON ua.region_id = rg.region_id
JOIN products p
    ON oi.product_id = p.product_id
JOIN product_categories pc
    ON p.category_id = pc.category_id
LEFT JOIN refunds r
    ON oi.order_item_id = r.order_item_id
WHERE rg.province_name = '福建'
  AND ul.level_name = '黄金会员'
  AND pc.category_name = '手机'
  AND o.create_time >= CURRENT_DATE - INTERVAL 30 DAY;

这条 SQL 即使能够执行,也会面临多个问题。

1.2 业务表结构过于分散

业务系统通常遵循三范式设计。

例如商品相关信息可能拆成:

product
   ↓
category
   ↓
brand
   ↓
supplier

用户地区可能拆成:

user
   ↓
address
   ↓
city
   ↓
province
   ↓
country

这种结构可以减少数据冗余,也方便业务系统单独维护。

但是在分析场景中,每次查询都要执行多层 Join。

随着数据量增大,SQL 会越来越复杂,查询成本也会越来越高。

1.3 相同业务含义可能分散在不同表中

例如“订单支付金额”,可能存在于:

  • 订单主表;
  • 订单明细表;
  • 支付流水表;
  • 对账表;
  • 退款表。

不同系统中的金额字段可能代表不同含义:

order_amount:订单原始金额
discount_amount:优惠金额
pay_amount:实际支付金额
refund_amount:退款金额
settlement_amount:结算金额

如果数据开发人员不了解业务,很容易选错字段。

1.4 Join 可能导致数据重复

假设一笔订单中包含3个商品,同时发生2次支付尝试。

订单主表中只有一条记录:

订单明细表中有3条记录:

支付表中有2条记录:

如果直接把三张表 Join:

1条订单 × 3条商品明细 × 2条支付记录 = 6条结果

此时再执行:

SUM(pay_amount)

原本1000元的订单,可能被统计成6000元。

这不是 SQL 语法错误,而是数据粒度没有处理正确。

1.5 业务表只反映当前状态

假设用户在下单时是普通会员,一个月后升级为黄金会员。

业务用户表中可能只保留当前状态:

如果直接用当前用户表关联历史订单,系统会认为这名用户过去所有订单都是黄金会员订单。

但实际情况是:

2026-01-01:普通会员
2026-03-01:黄金会员

如果企业要分析不同会员等级下的消费行为,就必须保存历史状态。

业务系统通常不负责这个问题,数据仓库需要通过维度建模解决。

1.6 数据分析需要重新组织数据

因此,数据仓库不能简单复制业务数据库结构。

数据仓库需要围绕分析需求,重新组织数据。

业务数据库通常按照系统组织:

订单系统
用户系统
商品系统
支付系统

数据仓库更适合按照业务过程组织:

下单
支付
退款
发货
浏览
登录
库存变化

这就是数据仓库建模要解决的核心问题:

把面向事务的数据结构,转换成面向分析的数据结构。


二、什么是 Kimball 维度建模

数据仓库建模有多种方法,其中最常见的是 Kimball 维度建模。

Kimball 维度建模的核心思想可以概括为:

围绕业务过程设计事实表,用维度描述业务事实发生时的背景。

它不是按照源系统有多少张表,就在数据仓库里复制多少张表。

它首先关心的是:

  • 企业发生了哪些业务过程;
  • 每个业务过程产生了哪些可分析事件;
  • 每条事实记录代表什么;
  • 可以从哪些角度分析这些事实;
  • 事实中有哪些可以累计或计算的数值。

2.1 什么是业务过程

业务过程是企业中持续发生、可以被记录和分析的行为。

例如电商场景中的业务过程包括:

用户注册
商品浏览
加入购物车
提交订单
完成支付
申请退款
完成发货
签收商品
评价商品

其中,“订单系统”是一个业务系统,而“用户下单”才是一个业务过程。

“支付系统”是一个业务系统,而“支付成功”才是一个可以建模的业务过程。

Kimball 建模强调围绕业务过程组织数据,而不是围绕应用系统组织数据。

2.2 事实和维度

维度建模把数据主要分成两类:

事实 Fact
维度 Dimension

事实描述发生了什么。

例如:

  • 用户下了一笔订单;
  • 用户支付了4500元;
  • 商品被浏览了一次;
  • 系统完成了一次退款;
  • 仓库发生了一次库存扣减。

维度描述事实发生时的背景。

例如:

  • 是哪个用户;
  • 购买了什么商品;
  • 在什么时间发生;
  • 来自哪个地区;
  • 通过哪个渠道;
  • 参加了什么活动;
  • 属于哪个店铺。

例如,一笔订单事实可以表示为:

用户20001
在2026年8月2日
通过App渠道
在福建地区
购买商品30001
支付金额4500元

其中:

  • 支付金额是事实中的度量值;
  • 用户、时间、渠道、地区、商品是分析维度。

2.3 Kimball 建模四步法

Kimball 维度建模通常可以按照四个步骤完成。

第一步:选择业务过程

先明确要分析什么业务过程。

例如:

订单创建
订单支付
订单退款

不要一开始就考虑字段,也不要先照着业务表建表。

第二步:声明粒度

明确事实表中一行数据代表什么。

例如:

一行代表一个订单中的一个商品

或者:

一行代表一次支付成功事件

粒度不同,事实表的字段和主键都会不同。

第三步:确定维度

确定从哪些角度分析业务事实。

例如订单明细事实可以关联:

  • 用户维度;
  • 商品维度;
  • 店铺维度;
  • 时间维度;
  • 地区维度;
  • 渠道维度;
  • 活动维度。
第四步:确定事实

确定需要保存哪些可计算数据。

例如:

  • 商品数量;
  • 商品原价;
  • 优惠金额;
  • 实际支付金额;
  • 退款金额;
  • 运费;
  • 平台补贴金额。

完整过程可以概括为:

选择业务过程
      ↓
声明粒度
      ↓
确定维度
      ↓
确定事实

顺序不能颠倒。

很多建模问题都来自一开始没有确定粒度,就直接往表里添加字段。

2.4 Kimball 模型为什么适合分析

Kimball 模型通常采用事实表加维度表的结构。

例如:

              时间维度
                 |
用户维度 —— 订单事实表 —— 商品维度
                 |
              地区维度
                 |
              渠道维度

查询时,分析人员可以围绕事实表,从不同维度进行切分。

例如:

按时间统计销售额
按地区统计销售额
按用户等级统计销售额
按商品品类统计销售额
按渠道统计销售额

这比每次从多个业务系统重新理解字段和编写复杂 Join 更稳定。

2.5 维度建模不等于做一张大宽表

很多人把维度建模理解成:

把所有业务表 Join 成一张特别宽的表。

这并不准确。

宽表是一种物理实现方式,维度建模是一种数据组织方法。

维度建模首先要求:

  • 业务过程清晰;
  • 粒度清晰;
  • 事实和维度职责清晰;
  • 指标口径清晰;
  • 历史变化处理清晰。

至于最终是否把部分维度字段冗余到事实表中,要根据查询性能、存储成本和实时处理方式决定。


三、事实表到底是什么

事实表英文为 Fact Table。

它用于记录业务过程中已经发生的事件,以及这些事件产生的可度量结果。

可以用一句话概括:

事实表记录业务事件。

例如电商平台中,可以设计:

订单事实表
支付事实表
退款事实表
商品浏览事实表
用户登录事实表
库存变更事实表
物流签收事实表

3.1 如何判断一个业务对象是不是事实

可以从下面几个问题判断:

  1. 它是否代表一个已经发生的业务行为?
  2. 数据是否会随着业务持续新增?
  3. 是否有明确的发生时间?
  4. 是否可以计算次数、金额、数量或时长?
  5. 是否需要从多个角度进行分析?

例如“支付成功”:

  • 是一个业务事件;
  • 每天会不断产生;
  • 有支付时间;
  • 有支付金额;
  • 可以按用户、渠道、地区、商品分析。

因此,支付适合设计为事实表。

而“商品品牌”本身不是一个持续发生的业务事件,它更适合作为维度。

3.2 事实表通常包含什么字段

事实表通常包含三类字段。

业务标识

用于识别业务事件。

例如:

order_id
order_detail_id
payment_id
refund_id
维度键

用于关联不同分析维度。

例如:

user_id
product_id
shop_id
province_id
channel_id
activity_id
度量值

表示业务事件产生的数值结果。

例如:

quantity
original_amount
discount_amount
pay_amount
refund_amount
shipping_fee

一个简化的订单明细事实表可能是:

CREATE TABLE fact_order_detail (
    order_detail_id BIGINT,
    order_id BIGINT,

    user_id BIGINT,
    product_id BIGINT,
    shop_id BIGINT,
    province_id BIGINT,
    channel_id BIGINT,

    product_quantity INT,
    original_amount DECIMAL(18, 2),
    discount_amount DECIMAL(18, 2),
    pay_amount DECIMAL(18, 2),

    order_time DATETIME,
    dt DATE
);

3.3 度量值是什么

度量值是可以参与统计或计算的数据。

例如:

  • 订单金额;
  • 商品数量;
  • 支付金额;
  • 退款金额;
  • 浏览时长;
  • 配送时长;
  • 库存变化数量。

度量值通常可以进行:

SUM
COUNT
AVG
MAX
MIN

但并不是所有数值字段都是度量值。

例如:

user_id = 10001
product_id = 30001
province_id = 35

它们虽然是数字,但只是标识,不适合求和。

3.4 事实表中的事实类型

度量值通常可以分为三类。

可加事实

可以在所有维度上累加。

例如:

  • 支付金额;
  • 商品数量;
  • 退款金额;
  • 优惠金额。

例如:

SUM(pay_amount)

可以按时间、地区、商品等维度汇总。

半可加事实

只能在部分维度上累加。

例如账户余额、库存余额。

某商品每天的库存分别是:

不能计算:

100 + 80 + 70 = 250

因为库存是某个时间点的状态。

它可以在商品、仓库等维度上累加,但不能简单跨时间累加。

不可加事实

不能直接累加。

例如:

  • 转化率;
  • 退款率;
  • 客单价;
  • 平均配送时间。

例如每天的退款率分别是10%和20%,不能直接相加成30%。

通常应该在事实表中保存原始分子和分母:

refund_amount
pay_amount

查询时再计算:

SUM(refund_amount) / SUM(pay_amount)

而不是直接汇总每天计算好的退款率。

3.5 常见事实表类型

事实表不只一种。

事务事实表

一行代表一次业务事件。

例如:

一笔订单明细
一次支付
一次退款
一次商品浏览

事务事实表通常数据量最大,也最常见。

周期快照事实表

按照固定时间间隔记录业务状态。

例如:

每天每个商品的库存余额
每天每个用户的账户余额
每月每个客户的信用额度

例如:

累积快照事实表

一行记录一个业务流程从开始到结束的关键节点。

例如订单履约过程:

创建时间
支付时间
发货时间
签收时间
完成时间

表结构可能是:

CREATE TABLE fact_order_lifecycle (
    order_id BIGINT,
    user_id BIGINT,
    create_time DATETIME,
    pay_time DATETIME,
    ship_time DATETIME,
    receive_time DATETIME,
    finish_time DATETIME
);

随着订单状态推进,同一行会被持续更新。

这种模型适合分析:

  • 从下单到支付耗时;
  • 从支付到发货耗时;
  • 从发货到签收耗时;
  • 整个订单履约周期。

3.6 事实表中的退化维度

有些字段具有维度性质,但没有必要单独建维度表。

例如:

订单号
支付流水号
物流单号
优惠券使用编号

这些字段通常直接保存在事实表中。

它们被称为退化维度。

例如订单号可以用于:

  • 查询某一笔订单;
  • 统计去重订单数;
  • 关联订单明细。

但是单独建立一张只有订单号的维度表,没有实际价值。

3.7 事实表不是业务表的复制

业务订单表可能同时包含:

订单当前状态
用户收货地址
支付信息
物流信息
优惠券信息
订单金额
售后状态

数据仓库不应该照搬这张表。

需要先区分业务过程:

下单事实
支付事实
退款事实
物流事实

每个业务过程分别建模,粒度和度量更加清晰。


四、维度表到底是什么

维度表英文为 Dimension Table。

维度用于描述事实发生时的业务背景。

可以用一句话概括:

事实表回答“发生了什么”,维度表回答“在什么背景下发生”。

例如一笔支付事实是:

用户20001支付了4500元

如果只保存这一句话,分析价值有限。

加入维度后,可以描述为:

用户20001
在2026年8月2日
通过App渠道
在福建地区
购买手机类商品
支付了4500元

这里:

  • 用户;
  • 日期;
  • 渠道;
  • 地区;
  • 商品;

都属于维度。

4.1 常见维度有哪些

电商场景中常见维度包括:

用户维度
商品维度
店铺维度
地区维度
时间维度
渠道维度
活动维度
品牌维度
供应商维度

不同业务领域还会有不同维度。

例如金融领域:

客户维度
账户维度
机构维度
产品维度
风险等级维度

游戏领域:

玩家维度
角色维度
服务器维度
游戏版本维度
活动维度
设备维度

4.2 维度表通常包含描述性字段

用户维度可能包含:

user_id
user_name
gender
age_group
member_level
register_channel
register_date
province
city

商品维度可能包含:

product_id
product_name
category_name
brand_name
supplier_name
price_range
product_status

维度表中的字段通常用于:

  • 查询过滤;
  • 分组统计;
  • 报表展示;
  • 数据分类;
  • 标签分析。

例如:

SELECT
    product_category,
    SUM(pay_amount)
FROM fact_order_detail f
JOIN dim_product p
    ON f.product_id = p.product_id
GROUP BY product_category;

4.3 维度表和事实表有什么区别

例如:

fact_payment

每天可能新增几百万甚至几亿条。

而:

dim_region

可能只有几千条记录,并且变化很少。

但“维度表数据量一定小”不是绝对规则。

例如用户维度可能有数亿条数据,只是相对于点击日志、订单明细等事实数据,增长频率通常较低。

4.4 维度表为什么经常设计得比较宽

业务数据库通常会把商品拆成:

商品表
商品分类表
品牌表
供应商表

在维度建模中,可能将这些描述属性合并到商品维度:

CREATE TABLE dim_product (
    product_id BIGINT,
    product_name STRING,
    category_id BIGINT,
    category_name STRING,
    brand_id BIGINT,
    brand_name STRING,
    supplier_id BIGINT,
    supplier_name STRING,
    price_range STRING,
    product_status STRING
);

这样查询时只需要关联一次商品维度。

优点包括:

  • 减少 Join;
  • 查询逻辑更简单;
  • 对分析人员更友好;
  • 更适合星型模型;
  • 提升 OLAP 查询效率。

代价是存在一定数据冗余。

但数据仓库更看重查询便利和口径统一,不像业务数据库那样极度追求减少冗余。

4.5 自然键和代理键

维度表中经常涉及两种键。

自然键

来自业务系统的真实主键。

例如:

user_id = 10001
product_id = 30001
代理键

由数据仓库自己生成的主键。

例如:

user_sk = 900001
product_sk = 800001

代理键通常没有业务含义,只用于维度版本管理和事实表关联。

例如用户等级发生变化时,可以生成两个维度版本:

虽然业务用户 ID 都是10001,但两个历史版本对应不同的代理键。

历史订单关联900001,新订单关联900002。

这样就能准确保留用户状态变化。

4.6 时间维度为什么也要单独建表

很多人会问:

事实表里已经有日期字段,为什么还需要时间维度?

时间维度不仅保存日期,还可以保存丰富的时间属性。

例如:

date_key
full_date
year
quarter
month
week_of_year
day_of_month
day_of_week
is_weekend
is_holiday
holiday_name
fiscal_year

通过时间维度,可以方便分析:

  • 工作日和周末差异;
  • 节假日销售趋势;
  • 财务季度表现;
  • 月度和周度汇总。

如果每次都在 SQL 中计算这些字段,会产生大量重复逻辑。

4.7 一致性维度

Kimball 建模中一个重要概念是一致性维度,也叫共享维度。

例如订单事实表、支付事实表和退款事实表,都应该使用统一的:

用户维度
商品维度
地区维度
时间维度

而不是每个业务主题自己创建一套不同的用户定义。

例如:

订单主题中的用户等级
支付主题中的用户等级
退款主题中的用户等级

如果定义不同,就无法在不同事实表之间进行一致分析。

一致性维度可以让多个业务过程共享统一分析口径。


五、建模最重要的一步:声明粒度

在事实表设计中,最重要的不是字段数量,也不是表名,而是粒度。

粒度英文为 Grain。

它表示:

事实表中一行数据究竟代表什么。

在设计事实表前,必须先用一句完整的话声明粒度。

例如:

一行代表一个订单

或者:

一行代表一个订单中的一个商品

或者:

一行代表一次支付行为

或者:

一行代表一个订单的一次状态变化

这几种表都与订单有关,但它们不是同一张事实表。

5.1 订单可以有多种粒度

假设订单10001包含两个商品:

订单粒度

一行代表一笔订单:

适合分析:

  • 订单数;
  • 客单价;
  • 每笔订单商品数量;
  • 订单状态。
订单商品粒度

一行代表订单中的一个商品:

适合分析:

  • 商品销量;
  • 品类销售额;
  • 品牌贡献;
  • 单品退款率。
支付粒度

如果订单发生两次支付尝试:

一行代表一次支付行为。

适合分析:

  • 支付成功率;
  • 支付渠道表现;
  • 支付失败原因;
  • 重试次数。
状态变化粒度

一行代表一次状态变化。

适合分析:

  • 状态流转;
  • 订单停留时长;
  • 履约效率;
  • 异常订单。

5.2 粒度混乱会导致什么问题

假设一张表中既有订单字段,又有商品明细字段:

如果执行:

SELECT SUM(order_amount)
FROM fact_order_detail;

结果是:

2000

但真实订单金额只有1000。

因为订单金额属于订单粒度,却被重复写入订单商品粒度的每一行。

这类问题在真实项目中非常常见。

解决方式包括:

  • 不在商品粒度中存订单级金额;
  • 将订单金额合理拆分到商品;
  • 查询时按订单去重;
  • 单独建立订单粒度事实表。

最重要的是,字段必须与表的粒度一致。

5.3 先声明粒度,再选择字段

假设要设计订单明细事实表。

首先声明:

一行代表一个订单中的一个商品明细。

然后再确定字段。

适合保存:

order_detail_id
order_id
product_id
user_id
quantity
product_original_amount
product_discount_amount
product_pay_amount

不适合直接保存:

整单支付金额
整单运费
整单优惠金额
订单包含商品种类数

因为这些字段属于订单级别。

除非已经有明确、可解释的分摊规则,否则不应该直接复制到每个明细行中。

5.4 粒度决定主键

如果粒度是一行一个订单,主键可能是:

order_id

如果粒度是一行一个订单商品,主键可能是:

order_detail_id

或者:

order_id + product_id + sku_id

如果粒度是一行一次支付,主键应该是:

payment_id

如果粒度是一行一次状态变化,主键可能是:

order_id + status + change_time

因此,粒度不明确时,主键也无法正确设计。

5.5 粒度决定维度

订单粒度可能关联:

用户
地区
渠道
下单时间

订单商品粒度还可以关联:

商品
品类
品牌
供应商

支付粒度还可以关联:

支付渠道
支付机构
支付方式

粒度决定了哪些维度能够合理地出现在事实表中。

5.6 粒度决定度量值

订单商品粒度适合保存:

商品数量
商品金额
商品优惠金额
商品支付金额

支付粒度适合保存:

支付请求金额
支付成功金额
支付手续费

库存快照粒度适合保存:

期末库存数量
可售库存数量
锁定库存数量

如果一个度量无法与当前粒度一一对应,就需要重新考虑设计。

5.7 原子粒度优先原则

在大多数情况下,事实表应该尽量保存最低的、可用的原子粒度。

例如订单分析中:

一个订单中的一个商品

通常比“一天一个品类”更接近原始业务事实。

原子粒度的优势是:

  • 可以支持更多分析场景;
  • 可以向上汇总;
  • 可以重新计算指标;
  • 数据可追溯性更强。

例如订单商品明细可以汇总成:

订单粒度
商品粒度
品类粒度
用户粒度
地区粒度
日期粒度

但如果一开始只保存每天每个品类的销售额,就无法反向还原具体订单和用户。

不过,原子粒度不代表所有事件都放进一张表。

不同业务过程仍然需要独立事实表。

5.8 声明粒度的标准写法

建模文档中不要只写:

订单明细表

应该明确写:

本表粒度为一行代表一个订单中的一个商品明细。

支付事实表应该写:

本表粒度为一行代表一次支付请求。

用户日活跃快照表应该写:

本表粒度为一天、一个用户一条记录。

商品库存快照表应该写:

本表粒度为一天、一个仓库、一个商品一条记录。

只要这句话无法准确说清,就说明模型还没有设计完成。

5.9 第五章小结

粒度决定:

  • 一行数据代表什么;
  • 表的主键是什么;
  • 可以关联哪些维度;
  • 可以保存哪些度量值;
  • 数据如何聚合;
  • 是否会产生重复计算。

因此,维度建模中最重要的原则之一是:

先声明粒度,再设计事实表。

Kimball 四步法之所以把“声明粒度”放在确定维度和事实之前,就是因为后面的所有设计都依赖粒度。

接下来需要继续解决的问题是:

  • 事实表和维度表如何组合;
  • 什么是星型模型;
  • 什么是雪花模型;
  • 为什么企业分析场景通常更偏向星型模型;
  • 用户等级、商品分类等维度发生变化时,历史数据应该如何保存。

六、星型模型与雪花模型

事实表和维度表确定以后,还需要考虑它们之间如何组织。

在维度建模中,最常见的两种结构是:

  • 星型模型;
  • 雪花模型。

两者的核心区别,不是有没有事实表和维度表,而是维度是否继续拆分。


6.1 什么是星型模型

星型模型英文为 Star Schema。

它的结构通常是:

  • 事实表位于中心;
  • 多张维度表分布在事实表四周;
  • 事实表直接关联各个维度表;
  • 维度表之间通常不再继续关联。

例如电商订单分析模型:

                    dim_date
                       |
                       |
dim_user —— fact_order_detail —— dim_product
                       |
                       |
                  dim_region
                       |
                       |
                  dim_channel

中心是订单明细事实表:

fact_order_detail

周围是:

dim_user
dim_product
dim_date
dim_region
dim_channel

从结构上看,像一颗星,所以被称为星型模型。


6.2 星型模型中的事实表

订单明细事实表可能包含:

CREATE TABLE fact_order_detail (
    order_detail_id BIGINT,
    order_id BIGINT,

    user_sk BIGINT,
    product_sk BIGINT,
    date_sk INT,
    region_sk BIGINT,
    channel_sk BIGINT,

    product_quantity INT,
    original_amount DECIMAL(18, 2),
    discount_amount DECIMAL(18, 2),
    pay_amount DECIMAL(18, 2),

    order_time DATETIME
);

其中:

user_sk
product_sk
date_sk
region_sk
channel_sk

用于关联维度表。

而:

product_quantity
original_amount
discount_amount
pay_amount

属于事实表中的度量值。


6.3 星型模型中的维度表

商品维度可能是:

CREATE TABLE dim_product (
    product_sk BIGINT,
    product_id BIGINT,
    product_name STRING,
    category_id BIGINT,
    category_name STRING,
    brand_id BIGINT,
    brand_name STRING,
    supplier_id BIGINT,
    supplier_name STRING,
    price_range STRING,
    product_status STRING
);

这里没有继续拆成:

商品表
分类表
品牌表
供应商表

而是把常用的商品描述属性放在同一张维度表中。

这会产生一定冗余,但查询时更简单。

例如,统计不同商品品类的销售额:

SELECT
    p.category_name,
    SUM(f.pay_amount) AS pay_amount
FROM fact_order_detail f
JOIN dim_product p
    ON f.product_sk = p.product_sk
GROUP BY p.category_name;

只需要关联一张商品维度。


6.4 星型模型为什么适合分析

星型模型的第一个优点是查询简单。

分析人员通常只需要:

事实表
  +
一个或多个维度表

不需要理解业务数据库中复杂的表关系。

第二个优点是 Join 层级少。

例如查询:

福建地区、黄金会员、手机品类的支付金额。

可以直接关联:

事实表
+ 用户维度
+ 地区维度
+ 商品维度

不会继续从商品维度关联分类,再从分类关联品牌。

第三个优点是业务含义清晰。

事实表负责记录业务事件,维度表负责提供分析角度,职责边界比较明确。

第四个优点是适合 OLAP 引擎。

Doris、StarRocks、ClickHouse 等系统更擅长大规模扫描和聚合。减少 Join 层级,通常有利于查询性能和执行计划稳定性。


6.5 星型模型的缺点

星型模型并不是没有代价。

由于维度表中会保存较多描述字段,因此可能存在:

  • 数据冗余;
  • 维度表字段较多;
  • 属性更新时需要同步修改;
  • 大维度维护成本较高。

例如同一个品牌名称可能在商品维度中重复出现很多次。

但在分析系统中,适度冗余通常是可以接受的。

因为数据仓库更关注:

  • 查询效率;
  • 使用便利;
  • 口径统一;
  • 模型可理解性。

而不是像业务数据库那样尽量消除所有冗余。


6.6 什么是雪花模型

雪花模型英文为 Snowflake Schema。

它是在星型模型基础上,将维度继续规范化拆分。

例如商品维度拆成:

fact_order_detail
        |
        ↓
dim_product
        |
        ↓
dim_category
        |
        ↓
dim_brand
        |
        ↓
dim_supplier

地区维度也可能拆成:

dim_city
   ↓
dim_province
   ↓
dim_country

完整结构可能是:

                        dim_date
                           |
                           |
dim_user —— fact_order_detail —— dim_product
                           |           |
                           |           ↓
                       dim_city    dim_category
                           |           |
                           ↓           ↓
                    dim_province   dim_brand

因为维度继续向外展开,结构像雪花,所以称为雪花模型。


6.7 雪花模型的优点

雪花模型更接近规范化设计。

主要优点包括:

  • 减少维度属性重复;
  • 维度层级关系更清晰;
  • 独立维护品牌、品类、供应商等主数据;
  • 某些公共维度更容易复用;
  • 适合层级复杂、治理要求高的场景。

例如品牌信息由专门团队维护时,可以单独设计品牌维度。

商品维度只保存:

product_id
product_name
brand_id
category_id

品牌名称和品牌属性统一放在品牌维度中。


6.8 雪花模型的缺点

雪花模型的主要问题是查询复杂。

统计商品品类销售额时,可能需要:

事实表
  ↓
商品维度
  ↓
品类维度

统计品牌和供应商数据时,Join 层级会进一步增加。

这会带来:

  • SQL 更复杂;
  • 查询人员理解成本更高;
  • Join 数量增加;
  • 查询性能可能下降;
  • 模型更像业务数据库;
  • 自助分析难度提高。

如果维度拆分过度,最终会重新回到多表 Join 的问题。


6.9 星型模型和雪花模型如何选择

可以简单比较:

在大多数企业分析场景中,可以优先采用星型模型。

只有满足以下情况时,才考虑把部分维度雪花化:

  • 维度层级非常复杂;
  • 某个子维度需要被多个模型独立复用;
  • 主数据由独立系统维护;
  • 维度数据规模很大;
  • 属性更新频率和权限边界明显不同。

实际项目不必在星型和雪花之间做绝对选择。

很多企业采用的是混合模型:

核心分析路径保持星型,少量复杂维度根据治理需要适当拆分。


6.10 星型模型不等于只有一张事实表

真实数据仓库通常有多张事实表。

例如电商交易域可能包括:

fact_order_detail
fact_payment
fact_refund
fact_delivery

这些事实表可以共享一致性维度:

dim_user
dim_product
dim_date
dim_region
dim_channel

形成多个相关星型模型。

例如:

                  dim_user
                     |
                     |
dim_product —— fact_order_detail —— dim_date
                     |
                 dim_region

支付事实表:

                  dim_user
                     |
                     |
dim_channel —— fact_payment —— dim_date
                     |
                 dim_region

退款事实表:

                  dim_user
                     |
                     |
dim_product —— fact_refund —— dim_date
                     |
               dim_refund_reason

通过共享维度,可以跨业务过程分析。

例如:

  • 下单后支付成功率;
  • 支付后退款率;
  • 不同用户等级的退款情况;
  • 不同商品品类的支付转化。

七、维度发生变化怎么办:SCD 缓慢变化维

事实表会不断新增业务事件,维度表则用于描述业务背景。

但维度并不是永远不变。

例如:

  • 用户从普通会员升级为黄金会员;
  • 商品从手机分类调整到智能设备分类;
  • 用户从福建搬到上海;
  • 门店从华东区域调整到华南区域;
  • 客户风险等级从低风险变为高风险。

这些变化会带来一个重要问题:

查询历史事实时,应该使用维度当前值,还是事实发生时的历史值?

这就是缓慢变化维需要解决的问题。

SCD 的全称是 Slowly Changing Dimension。

它用于处理维度属性随时间变化的问题。


7.1 为什么直接更新维度会有问题

假设用户10001在2026年1月是普通会员,3月升级为黄金会员。

用户维度最初是:

3月升级后直接更新为:

现在查询1月份订单:

SELECT
    u.member_level,
    SUM(f.pay_amount)
FROM fact_order_detail f
JOIN dim_user u
    ON f.user_id = u.user_id
WHERE f.order_time >= '2026-01-01'
  AND f.order_time < '2026-02-01'
GROUP BY u.member_level;

结果会把1月份的历史订单归为黄金会员订单。

但用户在1月份实际上还是普通会员。

这就是历史维度被当前值覆盖的问题。


7.2 SCD Type 1:直接覆盖

Type 1 的处理方式最简单:

直接用新值覆盖旧值,不保存历史。

例如:

NORMAL → GOLD

更新后只保留:

这种方式适合:

  • 修正错误数据;
  • 不需要历史分析的字段;
  • 邮箱格式修正;
  • 用户昵称修改;
  • 拼写错误修正。

优点:

  • 实现简单;
  • 存储成本低;
  • 查询方便。

缺点:

  • 历史信息丢失;
  • 无法还原事实发生时的维度状态。

因此,会员等级、组织归属、风险等级等重要分析属性通常不适合只使用 Type 1。


7.3 SCD Type 2:新增历史版本

Type 2 是数据仓库中最常用的历史维度处理方式。

它的核心思想是:

维度发生变化时,不覆盖旧记录,而是新增一个版本。

例如:

其中:

  • user_id 是业务系统自然键;
  • user_sk 是数据仓库代理键;
  • start_date 表示版本生效时间;
  • end_date 表示版本失效时间;
  • is_current 表示是否为当前版本。

1月份订单关联:

user_sk = 900001

3月份之后的订单关联:

user_sk = 900002

这样就能保留完整历史。


7.4 为什么需要代理键

如果事实表只保存业务用户 ID:

user_id = 10001

那么同一个用户的多个历史版本都会拥有相同的 user_id

事实表无法判断应该关联哪个版本。

因此,可以在维度表中生成代理键:

user_sk

例如:

900001:普通会员版本
900002:黄金会员版本

事实发生时,将对应版本的代理键写入事实表。

这样历史关联就不会受到后续维度变化影响。


7.5 Type 2 如何匹配历史版本

假设订单发生时间是:

2026-02-10

需要匹配满足下面条件的用户版本:

order_time >= start_date
AND order_time < end_date

示例:

SELECT
    f.order_id,
    f.order_time,
    u.member_level
FROM fact_order_source f
JOIN dim_user_scd u
    ON f.user_id = u.user_id
   AND f.order_time >= u.start_date
   AND f.order_time < u.end_date;

这类关联也叫时间区间关联。

在实时数仓中,还可以通过 Flink Temporal Join 或版本维表方式实现。


7.6 SCD Type 3:增加历史字段

Type 3 不新增记录,而是在同一行保存有限的历史状态。

例如:

优点是结构简单。

缺点是只能保存有限历史。

如果用户继续升级为钻石会员:

更早的普通会员状态就丢失了。

因此,Type 3 适合只关心当前值和上一个值的场景,但实际数据仓库中使用频率通常低于 Type 2。


7.7 哪些字段需要保留历史

不是所有维度字段都需要 Type 2。

可以按照分析价值区分。

适合 Type 1 的字段:

用户昵称
商品描述拼写
联系人电话修正
错误分类修复

适合 Type 2 的字段:

会员等级
客户风险等级
用户所属地区
商品所属分类
门店所属区域
员工所属部门
客户经理归属

判断标准是:

这个字段的历史变化是否会影响过去事实的业务解释?

如果会,就应该考虑保留历史。


7.8 商品价格应该放在哪里

很多人会把商品价格放进商品维度。

但价格变化频繁,而且订单发生时的成交价格属于业务事实。

因此需要区分:

  • 当前商品标价,可以放在商品维度或商品快照中;
  • 订单成交价格,必须保存在订单事实表中;
  • 每日价格变化,可以设计价格快照事实表;
  • 促销价格变化,可以设计价格变更事实表。

不能通过商品当前价格去还原历史订单金额。

事实表应该保存业务发生时已经确定的度量值。


7.9 实时场景中的维度更新

在 Flink 实时数仓中,维度可能存放在:

  • MySQL;
  • HBase;
  • Redis;
  • Doris;
  • PostgreSQL;
  • Flink State;
  • Kafka Compact Topic。

事实流到达后,可以通过:

  • Lookup Join;
  • Temporal Join;
  • Broadcast State;
  • 异步 I/O;

补充维度属性。

例如订单事件中只有:

{
  "order_id": 10001,
  "user_id": 20001,
  "product_id": 30001,
  "pay_amount": 4500
}

通过维表关联后补充:

{
  "order_id": 10001,
  "user_id": 20001,
  "member_level": "GOLD",
  "province": "福建",
  "product_id": 30001,
  "category_name": "手机",
  "brand_name": "Brand A",
  "pay_amount": 4500
}

但要注意:

如果直接查询当前维表,可能会把当前维度状态关联到历史事实。

是否需要历史版本,取决于业务分析口径。


八、电商业务完整维度建模案例

下面按照 Kimball 四步法,为一个电商交易场景完成简化建模。

假设业务需求包括:

  • 统计每天订单量和销售额;
  • 分析不同商品品类销售表现;
  • 分析不同用户等级消费情况;
  • 分析地区和渠道贡献;
  • 分析支付和退款情况;
  • 计算订单履约时长。

8.1 第一步:选择业务过程

首先梳理核心业务过程:

提交订单
完成支付
发起退款
完成发货
签收商品

这些业务过程不应该全部塞进一张表。

可以分别设计:

fact_order_detail
fact_payment
fact_refund
fact_order_lifecycle

8.2 第二步:声明粒度

订单明细事实表

粒度:

一行代表一个订单中的一个商品明细。

主键:

order_detail_id
支付事实表

粒度:

一行代表一次支付请求。

主键:

payment_id
退款事实表

粒度:

一行代表一次退款申请明细。

主键:

refund_id
订单生命周期事实表

粒度:

一行代表一个订单从创建到完成的履约过程。

主键:

order_id

8.3 第三步:确定维度

公共维度包括:

dim_user
dim_product
dim_shop
dim_date
dim_time
dim_region
dim_channel
dim_activity
dim_payment_method
dim_refund_reason

不同事实表不一定关联所有维度。

例如订单明细事实可以关联:

用户
商品
店铺
日期
地区
渠道
活动

支付事实可以关联:

用户
日期
地区
渠道
支付方式

退款事实可以关联:

用户
商品
日期
退款原因

8.4 第四步:确定事实

订单明细事实表中的度量值:

商品数量
商品原价金额
优惠金额
实际支付金额
运费分摊金额
平台补贴金额
商家优惠金额

支付事实中的度量值:

支付请求金额
支付成功金额
支付手续费

退款事实中的度量值:

退款申请金额
实际退款金额
退货商品数量

订单生命周期事实中的度量值:

下单到支付时长
支付到发货时长
发货到签收时长
订单总履约时长

8.5 订单明细事实表设计

示例结构:

CREATE TABLE fact_order_detail (
    order_detail_id BIGINT,
    order_id BIGINT,

    user_sk BIGINT,
    product_sk BIGINT,
    shop_sk BIGINT,
    date_sk INT,
    region_sk BIGINT,
    channel_sk BIGINT,
    activity_sk BIGINT,

    product_quantity INT,
    original_amount DECIMAL(18, 2),
    discount_amount DECIMAL(18, 2),
    pay_amount DECIMAL(18, 2),
    shipping_amount DECIMAL(18, 2),
    platform_subsidy_amount DECIMAL(18, 2),
    merchant_discount_amount DECIMAL(18, 2),

    order_status STRING,
    order_time DATETIME,

    PRIMARY KEY (order_detail_id)
);

粒度是一行一个订单商品明细,因此所有金额都应该是当前商品明细粒度上的金额。

整单金额不能未经处理直接重复写入每个明细行。


8.6 用户维度设计

示例:

CREATE TABLE dim_user (
    user_sk BIGINT,
    user_id BIGINT,

    user_name STRING,
    gender STRING,
    age_group STRING,
    member_level STRING,
    register_channel STRING,
    register_date DATE,
    province_name STRING,
    city_name STRING,

    start_date DATE,
    end_date DATE,
    is_current INT,

    PRIMARY KEY (user_sk)
);

如果会员等级、地区等属性需要保留历史,可以使用 Type 2。

如果昵称变化不需要历史,则可以直接覆盖。


8.7 商品维度设计

示例:

CREATE TABLE dim_product (
    product_sk BIGINT,
    product_id BIGINT,

    product_name STRING,
    sku_code STRING,
    category_id BIGINT,
    category_name STRING,
    brand_id BIGINT,
    brand_name STRING,
    supplier_id BIGINT,
    supplier_name STRING,
    price_range STRING,
    product_status STRING,

    start_date DATE,
    end_date DATE,
    is_current INT,

    PRIMARY KEY (product_sk)
);

商品维度可以适当冗余品类、品牌和供应商名称,减少分析查询中的 Join。

但历史订单金额仍然应该保存于事实表,不能依赖商品维度中的当前价格。


8.8 支付事实表设计

示例:

CREATE TABLE fact_payment (
    payment_id BIGINT,
    order_id BIGINT,

    user_sk BIGINT,
    date_sk INT,
    region_sk BIGINT,
    channel_sk BIGINT,
    payment_method_sk BIGINT,

    request_amount DECIMAL(18, 2),
    success_amount DECIMAL(18, 2),
    payment_fee DECIMAL(18, 2),

    payment_status STRING,
    request_time DATETIME,
    success_time DATETIME,

    PRIMARY KEY (payment_id)
);

一笔订单可能存在多次支付尝试,因此不能用 order_id 作为唯一主键。

支付成功率可以计算为:

SUM(
    CASE WHEN payment_status = 'SUCCESS' THEN 1 ELSE 0 END
) / COUNT(*)

8.9 退款事实表设计

示例:

CREATE TABLE fact_refund (
    refund_id BIGINT,
    order_id BIGINT,
    order_detail_id BIGINT,

    user_sk BIGINT,
    product_sk BIGINT,
    date_sk INT,
    refund_reason_sk BIGINT,

    apply_refund_amount DECIMAL(18, 2),
    actual_refund_amount DECIMAL(18, 2),
    refund_quantity INT,

    refund_status STRING,
    apply_time DATETIME,
    finish_time DATETIME,

    PRIMARY KEY (refund_id)
);

退款率不建议直接作为可加事实保存。

更合理的是保存:

actual_refund_amount
pay_amount

查询时计算:

SUM(actual_refund_amount) / SUM(pay_amount)

8.10 订单生命周期事实表

示例:

CREATE TABLE fact_order_lifecycle (
    order_id BIGINT,

    user_sk BIGINT,
    shop_sk BIGINT,
    region_sk BIGINT,
    channel_sk BIGINT,

    create_time DATETIME,
    pay_time DATETIME,
    ship_time DATETIME,
    receive_time DATETIME,
    finish_time DATETIME,

    pay_duration_seconds BIGINT,
    ship_duration_seconds BIGINT,
    delivery_duration_seconds BIGINT,
    total_duration_seconds BIGINT,

    current_status STRING,

    PRIMARY KEY (order_id)
);

这是一张累积快照事实表。

随着订单状态推进,同一行会持续更新。

它适合分析订单履约效率,但不适合代替订单明细事实表。


8.11 业务矩阵

Kimball 建模中,可以使用业务过程与维度矩阵来梳理模型。

示例:

这个矩阵可以帮助识别:

  • 哪些维度应该统一;
  • 哪些维度可以被多个事实表共享;
  • 哪些业务过程还没有建模;
  • 不同主题是否存在口径冲突。

九、Flink 与 Doris 实时数仓中的建模实践

前面的内容主要讨论逻辑模型。

实际项目中,还需要将这些模型落到 Kafka、Flink、Doris 等技术组件中。

一个典型实时数仓链路可能是:

MySQL
  ↓
Flink CDC / Debezium
  ↓
Kafka ODS
  ↓
Flink SQL / DataStream
  ↓
DWD 事实流与维度关联
  ↓
Doris 明细表
  ↓
DWS 公共汇总
  ↓
ADS 报表与数据服务

9.1 事实表通常来自业务事件流

订单、支付、退款等事实通常来自持续变化的业务数据。

例如:

orders
payments
refunds

通过 Flink CDC 读取 MySQL Binlog,转换为动态表。

订单源表示例:

CREATE TABLE mysql_order_detail (
    order_detail_id BIGINT,
    order_id BIGINT,
    user_id BIGINT,
    product_id BIGINT,
    shop_id BIGINT,
    region_id BIGINT,
    channel_id BIGINT,
    quantity INT,
    original_amount DECIMAL(18, 2),
    discount_amount DECIMAL(18, 2),
    pay_amount DECIMAL(18, 2),
    order_status STRING,
    order_time TIMESTAMP(3),
    update_time TIMESTAMP(3),
    PRIMARY KEY (order_detail_id) NOT ENFORCED
) WITH (
    'connector' = 'mysql-cdc',
    'hostname' = 'mysql',
    'port' = '3306',
    'username' = 'flink',
    'password' = '123456',
    'database-name' = 'demo',
    'table-name' = 'order_detail'
);

Flink 会将 MySQL 的新增、更新和删除表示为动态表变更。


9.2 维度表通常来自主数据

用户、商品、地区、渠道等维度一般来自相对稳定的主数据表。

例如:

users
products
regions
channels

维度可以存放在 MySQL、Doris、HBase 或 Redis。

Flink 处理事实流时,再通过维表关联补充属性。


9.3 Lookup Join

如果维度表存放在外部数据库中,可以使用 Lookup Join。

示例:

SELECT
    o.order_detail_id,
    o.order_id,
    o.user_id,
    u.member_level,
    u.province_name,
    o.product_id,
    p.category_name,
    p.brand_name,
    o.pay_amount,
    o.order_time
FROM mysql_order_detail o
LEFT JOIN dim_user FOR SYSTEM_TIME AS OF o.proc_time AS u
    ON o.user_id = u.user_id
LEFT JOIN dim_product FOR SYSTEM_TIME AS OF o.proc_time AS p
    ON o.product_id = p.product_id;

Lookup Join 的特点是:

  • 事实流每来一条数据;
  • 根据主键查询维度;
  • 将维度属性补充到事实记录中。

需要注意缓存、超时和外部数据库压力。


9.4 Temporal Join

如果维度表也是动态变化的,并且需要按照事实发生时的版本关联,可以使用 Temporal Join。

它能够表达:

订单发生在某个时刻时,用户或商品维度是什么状态。

这比直接关联当前维度更适合历史分析。

但实际使用时需要确认:

  • 维度是否有版本字段;
  • 时间属性是否正确;
  • 上游更新顺序是否可靠;
  • 是否需要处理迟到数据。

9.5 是否应该在 Flink 中直接做大宽表

Flink 可以把事实流与多张维度表关联,生成宽表写入 Doris。

例如订单宽表包含:

订单字段
用户等级
用户地区
商品品类
商品品牌
店铺类型
渠道名称
活动名称

优点是:

  • Doris 查询简单;
  • 减少运行时 Join;
  • BI 使用方便;
  • 实时报表响应快。

但也存在问题:

  • 维度更新后历史宽表是否同步;
  • 字段越来越多;
  • 多次 Lookup 增加延迟;
  • 外部维度查询压力较大;
  • 宽表容易绑定特定需求。

因此,推荐做法是:

对高频公共分析场景构建适度宽表,不要追求一张覆盖所有业务的万能宽表。


9.6 Doris 中事实表如何选表模型

以订单明细为例,如果每个 order_detail_id 只保留最新状态,可以考虑主键模型或 Unique Key 模型。

例如:

CREATE TABLE dwd_order_detail (
    order_detail_id BIGINT,
    order_id BIGINT,
    user_id BIGINT,
    product_id BIGINT,
    category_name STRING,
    brand_name STRING,
    member_level STRING,
    region_name STRING,
    quantity INT,
    pay_amount DECIMAL(18, 2),
    order_status STRING,
    update_time DATETIME
)
UNIQUE KEY(order_detail_id)
DISTRIBUTED BY HASH(order_detail_id) BUCKETS 8;

MySQL 中订单明细发生更新时,Flink CDC 可以将更新写入 Doris。

Doris 根据唯一键更新对应记录。

如果需要保存完整状态变更历史,则应该另建事件明细表,而不是只保留最新状态。


9.7 Doris 中的维度表

小型维度可以直接存入 Doris,例如:

dim_region
dim_channel
dim_date

查询时再关联。

对于频繁查询的维度属性,也可以冗余到事实宽表中。

选择取决于:

  • 维度大小;
  • 更新频率;
  • 查询频率;
  • Join 性能;
  • 历史版本需求;
  • 数据一致性要求。

9.8 实时 DWD 与 DWS

实时 DWD 主要负责:

  • 清洗;
  • 去重;
  • 标准化;
  • 业务口径计算;
  • 维度补充;
  • 生成标准事实。

实时 DWS 主要负责:

  • 按分钟、小时、天聚合;
  • 生成用户、商品、城市等主题汇总;
  • 为实时大屏和接口提供结果。

例如:

SELECT
    window_start,
    window_end,
    region_name,
    category_name,
    COUNT(DISTINCT order_id) AS order_count,
    SUM(pay_amount) AS gmv
FROM TABLE(
    TUMBLE(
        TABLE dwd_order_detail,
        DESCRIPTOR(order_time),
        INTERVAL '1' MINUTE
    )
)
GROUP BY
    window_start,
    window_end,
    region_name,
    category_name;

这类结果可以写入 Doris 的实时汇总表。


9.9 常见建模错误

错误一:没有先声明粒度

一张表同时混合订单、商品和支付粒度。

结果是金额重复、订单数错误、Join 膨胀。

错误二:事实表中保存大量不可解释的汇总值

例如只保存退款率,不保存退款金额和支付金额。

后续无法重新计算和校验。

错误三:所有维度都拆成雪花模型

虽然结构规范,但查询复杂,最终没人愿意使用。

错误四:所有字段都塞进宽表

宽表越来越大,维度更新和任务维护成本不断上升。

错误五:使用当前维度解释历史事实

用户当前是黄金会员,不代表历史订单发生时也是黄金会员。

错误六:不同事实表使用不同维度定义

订单主题和支付主题分别定义用户等级,导致跨主题指标不一致。

错误七:把系统表当成业务过程

根据 MySQL 表一对一复制,而没有识别下单、支付、退款等真正业务事实。

错误八:事实表只保留最终状态

如果业务需要分析状态流转,就必须保留事件历史或单独建设状态变更事实表。


总结

维度建模不是简单地把业务表拆成事实表和维度表。

它真正解决的是:

  • 企业应该围绕什么业务过程组织数据;
  • 一行数据到底代表什么;
  • 哪些字段属于可计算事实;
  • 哪些字段属于分析维度;
  • 历史维度变化如何保留;
  • 不同业务过程如何共享统一维度;
  • 模型如何在 Flink、Kafka 和 Doris 中落地。

Kimball 维度建模可以概括为四步:

选择业务过程
      ↓
声明粒度
      ↓
确定维度
      ↓
确定事实

其中,最重要的是声明粒度。

事实表负责回答:

发生了什么业务事件?

维度表负责回答:

这件事是在什么时间、地点、用户、商品和渠道背景下发生的?

星型模型通过较少的 Join,提供简单清晰的分析结构。

雪花模型通过继续拆分维度,提高规范性和独立治理能力。

SCD 则负责保存维度随时间变化的历史状态。

在实时数仓中,典型落地链路是:

MySQL
  ↓
Flink CDC
  ↓
Kafka / ODS
  ↓
Flink 清洗、去重、维度关联
  ↓
DWD 事实模型
  ↓
DWS 公共汇总
  ↓
Doris / ADS / BI

真正好的数据模型,不是表越多越专业,也不是字段越宽越先进,而是能够做到:

粒度清晰、口径统一、历史可追溯、模型可复用、指标可解释。

下一篇可以继续深入:

Flink 如何构建实时数仓?从 CDC、动态表到维表 Join 和实时聚合