☰
维度表设计全解析:主键、SCD与电商数仓落地实践
2026/10/15 15:42:30 网站建设 项目流程

某天下午,我正盯着刚跑完的报表,某同事发来一句:“能不能把订单明细里加上会员的城市?”我查了下订单明细表,里面只有user_id和product_id,城市在用户维度表里。我意识到,很多刚接触数据仓库的同学,看到的是一张张孤零零的表,却没有意识到——真正让我们能做出“可解释的分析”的,是散落在事实表外围的那些维度表。

这篇文章就围绕维度表展开:它到底负责什么、主键怎么设计、属性怎么设计才能扛住业务变化、以及一套可以直接抄作业的电商维度表案例。适合刚入门数仓的开发者,也适合天天写报表但总被数据口径搞晕的分析师。我会尽量把概念揉进场景里讲,不堆术语,但该给的落地细节一点不少。

1. 维度表到底是干什么的:一次“跑不出数”引发的思考

1.1 没有维度表时,查一个数有多痛苦

先还原一下最原始的无维度表状态。假设某个订单明细表长这样:

  • order_id
  • user_id
  • product_id
  • amount
  • order_date

这个表里,你能直接算订单量、销售额、客单价,但一旦业务方问出下面这种问题,你会发现所有查询都卡在了“取描述字段”上:

  1. 按“会员等级”看订单分布——你得先去业务库捞用户表。
  2. 按“商品类目”看销量——你得先去商品系统捞类目表。
  3. 按“城市”看下单用户——你又要去另一张用户扩展表里 join 一次。

每个人临时取数时 join 口径都不一样:有人拿用户注册地,有人拿收货地,有人拿订单表里已经冗余的“会员城市”,最后汇报的数据自然对不上。这就是维度表缺失最常见的症状:事实数据本身没问题,但描述事实的上下文是散的、乱的。

1.2 维度表与事实表,责任边界到底在哪

数据仓库建模里最经典的一种方式叫星型模型:中间一张事实表,外面一圈维度表。两者的职责可以用一句话概括:

  • 事实表:记录业务过程产生的“事件”,核心字段是度量值和外键。
  • 维度表:描述业务对象或分析角度,核心字段是各种描述性属性。
维度表(dim_)事实表(fct_)
代表“谁、什么、何时、何地、哪个渠道”等分析角度代表“发生了什么事、花了多少钱、下了几个单”
字段多为文本、状态、标签字段多为金额、数量、时间外键
行数相对少,更新相对低频行数大,且每日持续增长
用于筛选、分组、下钻用于聚合、求和、计数

举个例子:订单事实表里有一行数据是“某用户在某天买了一个商品,支付 199 元”。如果没有维度表,你只知道user_id = 12345的人买了product_id = 678的东西。但配合用户维度表,你能知道这是个来自江州市的白银会员;配合商品维度表,你能知道它是手机配件 > 数据线 > USB-C 线这个类目下的商品。这些用来“解释事实”的属性,就是维度表的核心价值。

1.3 为什么不能把所有字段都塞进一张大宽表

看到这里,很多人会问:“那我把用户城市、商品类目这些字段直接加在订单表里,不也一样能分析吗?还省得 join。”确实,很多临时报表就是这么干的,但作为数仓公共层,这种“大宽表优先”的思路会带来三个很棘手的后果。

第一个是更新节奏冲突。用户的等级、城市是会变的,而订单一旦产生就永远不变。如果你把用户属性冗余进订单表,一旦用户改了城市,你必须去更新所有历史订单,这是极其危险的操作——稍不留神就把事实行改坏。第二个是口径无法统一。用户城市到底指“注册地”还是“收货地”?如果你在订单表里冗余了城市,这张表只能存一种解释。第三是存储膨胀。事实表每日增长,多冗余几十个维度的字段,存储量可能直接翻几倍,而分析价值并不会因此提升。

所以我一直建议:公共维度表要单独建,放到一个所有团队都能共享的位置。谁需要“用户城市”,就去dim_user拿;谁需要“类目层级”,就去dim_product拿。事实表只存维度外键,通过关联拿到需要展示的描述属性。这才是星型模型存在的最朴素理由。

2. 维度表设计的第一道分水岭:主键怎么选

2.1 直接用自然键,你迟早要吃亏

自然键是什么?就是业务系统里本来就有、能唯一标识一条记录的字段,比如用户ID、商品编码、订单号。刚学数仓时最容易犯的错误,就是直接用user_id当维度表主键,然后把事实表也存成user_id,两张表直连。

听起来很合理,但自然键有一个致命弱点:它的规则掌握在上游业务系统手里,不受你控制。

我见过一个真实案例:某业务系统升级后,把所有用户ID从数字12345改成字符串U12345,结果之前数仓里所有用user_id关联的历史任务全断,只能靠一堆临时补数 SQL 硬凑。还有更阴间的:同一用户在两套系统里分别是11和01011,看起来不一样,实际上是同一个人,自然键根本表达不了这种映射。

2.2 代理键:给每个维度一份“数仓自营”的身份证

代理键是我在数仓项目里反复强调的底线。简单说,它是你为每一行维度记录额外分配的、在整个数仓范围内唯一、与业务无关的整数编号。比如用户维度的代理键user_sk,从 1、2、3 一路往下走,谁是谁不用管,只要能稳定关联就行。

对比项自然键代理键
来源上游业务系统数据仓库 ETL 自行分配
稳定性随业务系统的改造、合并而变化一旦分配,永久不变
表达能力无法表达“同一实体两套编码”由映射关系统一表达
存储与关联字符串匹配慢,额外浪费空间整型关联,速度快
可读性能直接看懂业务含义无含义,需要字典映射

你可能担心:“没有含义的 ID,我怎么查?”完全不用慌。实际使用中,代理键只用于事实表和维度表之间的等值关联,业务标识字段(比如user_id、product_code)可以继续留在维度表里作为自然键列,供人理解、排错、和上游对接。关联时我们用代理键,展示时我们用自然键,两者可以共存。

在多源合并的场景里,代理键的价值更明显。例如 A 系统的用户 ID 和 B 系统的用户 ID 编码规则完全不同,ETL 阶段把两套自然键先统一映射成同一个user_sk,事实表只用user_sk存储,以后不管上游怎么变,历史数据都不受影响。

2.3 特殊维度的主键:日期维直接拿 yyyymmdd 当代理键

代理键不是绝对教条。有一类维度极其特殊,就是日期维度。日期本身就是全局唯一、永不变更的,20250101一眼就知道是哪一天,你很难碰到“某天升级后 20250101 变成了 2025-01-01”这种破事。因此日期维度表我强烈建议直接用yyyymmdd这种整数当主键,既满足关联性能要求,又天然可读。

日期维度的典型主键设计是:

  • 代理键:date_sk,值为20250101
  • 完整日期:2025-01-01
  • 年、季、月、周、星期几等拆分字段。

后续所有带日期的事实表,都存date_sk作为外键,需要按年、按季、按月、按周统计,直接 join 日期维度取对应字段即可。

3. 维度属性怎么设计才能扛住业务变化:从缓慢变化维到退化维度

3.1 用户改了城市,历史订单要不要跟着改

这是维度表设计里最经典的问题,术语叫缓慢变化维(SCD,Slowly Changing Dimension)。举个具体场景:某个用户在 3 月 1 日下单时,收货城市是江州市;4 月 1 日他搬家到滨江市。现在分析师要统计“第一季度每个城市的下单金额”。问题是:3 月那笔订单应该算江州市,还是滨江市?

答案取决于你想表达“订单所在地”还是“用户当前所在地”。业务上通常更关心“当时的情况”,所以需要保留历史属性。业界把处理方式分为 Type 1、Type 2、Type 3 三种,我用一张表说清楚:

方案实现方式适用场景注意点
Type 1直接更新原记录,用新值覆盖旧值手机号、姓名等“修正型”属性历史无法回溯
Type 2新增一条记录并标记有效期,旧记录保留城市、会员等级、类目归属等“经历型”属性事实表必须用代理键关联到对应历史行
Type 3同一行同时保留“原值”和“当前值”两列只想分析变化前后的对比只能回溯一步,记录不了多次变化

实际项目中 80% 的需求用 Type 2 都能解决,它的落地形态就是拉链表:每条维度记录带有valid_from和valid_to,当前生效行valid_to设为 2999-12-31。事实表在生成时,把订单日期映射到对应区间的维度行,从而获得那个时刻的城市、等级等快照。

这里要特别提醒:Type 2 的代价是同一个用户会在维度表里出现多行,因此事实表不能再用user_id关联,必须用user_sk。这一条和上一节的代理键设计正好接上。没有代理键,SCD2 根本没法落地。

3.2 退化维度:这把钥匙就留在事实表里,别急着拆

很多入门者容易被“所有维度都要单独拆表”这个想法误导,见到订单号、发票号、物流单号,就想建一张dim_order。但实际上,如果这张维度表只有订单号、下单时间等少数属性,把它单独拆出来价值很低。

订单号这类字段的典型特征是:它的属性少、分析价值高度集中在订单号本身,且其他维度已经能覆盖绝大多数分析路径。对这种字段,业内直接把它留在事实表里,作为普通字段使用,称为退化维度。

保留退化维度有很多实际好处。例如要统计“每个订单买了几种商品”,你直接用事实表里的order_no去重计数就行,不用 join 任何表;要计算“大促期间平均一个订单包含几个商品”,同样直接从事实表完成。这比“凡是字符串都拆表”的做法干净太多。

3.3 雪花维和层级维度:能用冗余解决的,就别用多表

星型模型里还有一种常见“变体”叫雪花模型——把商品维度拆成商品表、品牌表、类目表,查询时要跨三张表 join。它的好处是去掉了一些冗余,但代价是查询路径变长、口径分散、ETL 维护复杂。在离线数仓场景,除非你的业务对象极其复杂且属性占用空间惊人,否则我推荐“能用冗余就用冗余”。

比如商品维度的“类目层级”,实际项目中往往直接合并到一张dim_product表里:

  • category_l1:一级类目,如手机配件
  • category_l2:二级类目,如数据线
  • category_l3:三级类目,如 USB-C 线

这张表里的类目字段虽然冗余了,但分析师想按任意层级分组下钻,只需在这张表里选不同字段,完全不需要额外的关联。同理,区域维度可以把“大区、省份、城市”放在一张表里,实现从顶层到明细的自由钻取。教训就一句话:雪花表威力有限,绝大多数项目不值得付出查询复杂度换那点存储。

4. 一套可直接抄作业的电商维度表设计

4.1 日期维度:用一段 SQL 把未来十年都生成出来

日期维度几乎是所有事实表都要共享的公共维度,所以一次性、稳定地生成比什么都重要。下面是一张建议的日期维度表结构:

CREATE TABLE dim_date ( date_sk BIGINT COMMENT '代理键,格式YYYYMMDD', full_date DATE COMMENT '完整日期', year_no INT COMMENT '年份', quarter_no INT COMMENT '季度', month_no INT COMMENT '月份', week_no INT COMMENT '周序号', day_of_month INT COMMENT '月内第几天', day_of_week INT COMMENT '周内第几天', is_workday TINYINT COMMENT '是否工作日:1是0否', is_holiday TINYINT COMMENT '是否节假日:1是0否', holiday_name STRING COMMENT '节假日名称' );

生成逻辑我习惯用一个“数字表”去和日历做笛卡尔展开,核心是先把起止日期算出来,再用日期函数逐行填字段。实例如下(示意逻辑,具体窗口函数按你使用的引擎微调):

SELECT CAST(DATE_FORMAT(dt, '%Y%m%d') AS BIGINT) AS date_sk, dt AS full_date, YEAR(dt) AS year_no, QUARTER(dt) AS quarter_no, MONTH(dt) AS month_no, WEEKOFYEAR(dt) AS week_no, DAYOFMONTH(dt) AS day_of_month, DAYOFWEEK(dt) AS day_of_week, CASE WHEN DAYOFWEEK(dt) IN (0,6) THEN 0 ELSE 1 END AS is_workday FROM ( SELECT DATE_ADD(DATE('2024-01-01'), INTERVAL n DAY) AS dt FROM numbers WHERE n <= 3650 ) t;

节假日字段每年手动维护一次即可,维护时更新is_holiday和holiday_name,事实表不用动,下一年自动生效。

4.2 用户维度:高频筛选字段和低频字段分开考虑

用户维度是除了日期以外最常被 join 的维度表。设计时我会先问自己几个问题:

  • 分析上常用来筛选/分组的字段有哪些?如城市、会员等级、注册日期、渠道来源。
  • 这些字段会不会变化?要不要保留历史?
  • 哪些字段只是展示用的,如性别、年龄段,变化频率低,直接用 Type 1 覆盖即可。

一个最简可落地的dim_user长这样:

CREATE TABLE dim_user ( user_sk BIGINT COMMENT '代理键,数仓分配', user_id VARCHAR(64) COMMENT '业务自然键', reg_date DATE COMMENT '注册日期', reg_city VARCHAR(64) COMMENT '注册城市', city_name VARCHAR(64) COMMENT '当前城市', user_level VARCHAR(32) COMMENT '会员等级', gender VARCHAR(16) COMMENT '性别', age_group VARCHAR(32) COMMENT '年龄段', valid_from DATE COMMENT '生效日期', valid_to DATE COMMENT '失效日期', is_current TINYINT COMMENT '是否当前有效行' );

这里的关键取舍:reg_city用 Type 1,因为注册地基本不会变;city_name和user_level走 SCD2(用valid_from/valid_to/is_current表达),因为它们是分析历史订单最常用的视角。ETL 每跑一次,比对上游业务系统的最新值,若发生变化就关掉旧记录、插入新记录。

4.3 商品维度:类目层级合并成一张表

商品维度同样建议把层级属性做冗余。核心字段包括商品 ID、商品名称、品牌、上下架状态、上市时间,以及多级类目。一个可以抄的模板:

CREATE TABLE dim_product ( product_sk BIGINT COMMENT '代理键', product_id VARCHAR(64) COMMENT '商品自然键', product_name STRING COMMENT '商品名称', brand_name STRING COMMENT '品牌名', category_l1 STRING COMMENT '一级类目', category_l2 STRING COMMENT '二级类目', category_l3 STRING COMMENT '三级类目', shelf_status STRING COMMENT '上下架状态', on_sale_date DATE COMMENT '上市日期', valid_from DATE COMMENT '生效日期', valid_to DATE COMMENT '失效日期', is_current TINYINT COMMENT '是否当前有效行' );

有一个容易纠结的点:商品价格放维度表还是事实表?我的默认答案是:如果分析只需要“当前价格”,可以放维度表;但如果要看“下单当时的价格”,必须放事实表,因为价格是事实度量,跟具体订单绑定,不是稳定不变的描述属性。同理,折扣、优惠金额也归事实表。

4.4 订单事实表如何引用维度外键

有了三种维度表,订单事实表的结构就清晰了。我先定义事实粒度:一行代表一个订单中的一条商品明细。

CREATE TABLE fct_order ( order_sk BIGINT COMMENT '订单明细代理键', order_no VARCHAR(64) COMMENT '订单号,退化维度直接保留', order_date_sk BIGINT COMMENT '下单日期维度外键', user_sk BIGINT COMMENT '用户维度外键', product_sk BIGINT COMMENT '商品维度外键', store_sk BIGINT COMMENT '门店维度外键(如有)', quantity INT COMMENT '购买数量', amount DECIMAL(18,2) COMMENT '订单金额', discount_amount DECIMAL(18,2) COMMENT '优惠金额' );

关联查询的姿势也固定了:所有分组和筛选,优先经过维度表的外键。比如想看“2024 年每月、各一级类目的订单数和销售额”:

SELECT d.year_no, d.month_no, p.category_l1, COUNT(DISTINCT f.order_no) AS order_cnt, SUM(f.amount) AS gmv FROM fct_order f LEFT JOIN dim_date d ON f.order_date_sk = d.date_sk LEFT JOIN dim_product p ON f.product_sk = p.product_sk LEFT JOIN dim_user u ON f.user_sk = u.user_sk WHERE d.year_no = 2024 GROUP BY d.year_no, d.month_no, p.category_l1;

这不再是临时拼凑的表结构,而是稳定可复用、口径清晰的公共模型。业务方要什么维度的报表,都能从同一套事实表和维度表上长出来。

5. 维度表上线后,我反复劝别人避开的那些坑

5.1 多业务系统编码不一致:统一入口做映射

维度表上线后第一个容易爆的雷就是编码不统一。比如订单系统里商品编码是sku_1001,库存系统里是1001;用户 ID 在 A 系统是整数,在 B 系统带了前导零。如果事实表在关联维度表时直接拿两边的自然键硬碰,会出现大量关联不上的孤儿数据。

我的处理方法是在 ETL 入口先做一层“自然键标准化映射”。不管上游给的是什么样的 user_id 或 product_id,一律在 DWD 层通过映射表转成统一的user_sk和product_sk。映射表的维护是调度任务的一部分,每天从上游同步编码关系,有新增就分配新代理键,有变更就记录生效区间。这样事实表永远只认代理键,上游怎么折腾都不影响。

5.2 维度记录是物理删除还是打失效标记

删库跑路式的“物理删除”在维度表上是大忌。商品下架、用户注销、门店关闭,如果直接 DELETE 掉,历史订单关联到这些维度时就会断链,之前能统计的“去年某商品销售额”会彻底丢失。

稳妥做法是:给维度增加状态和失效标记。比如商品的shelf_status = 'off',用户的valid_to = 2024-12-31,门店的is_closed = 1。日常查询默认is_current = 1只看到当前有效的行,需要做历史分析时再把历史行捞出来。物理删除只允许出现在确认数据写错、必须回滚的场景。

5.3 给属性设置默认值,别让 NULL 把关联搞丢

维度属性出现 NULL 时,很多人的第一反应是“反正是空,查的时候注意下就行”。但放在公共数仓层,NULL 会带来大量不可控的脏数据:某个商品没有品牌名、某用户没填城市,最终报表里会出现一片“空值行”。

统一的处理规则是:在维度表里设计“未知”成员。城市维度里加一个未知,用户维度里加一个未注册用户,商品维度里加一个未知商品,并用一个约定的代理键承载,比如-1或0。事实表在 ETL 时遇到找不到的业务键,统一对齐到“未知”维度行,而不是写入 NULL。这样后续所有分析都不会因为关联丢行而出现统计口径缺口。

5.4 增量更新要稳住,别盲目上拉链表炫技

维度表更新策略我建议“规模决定方案”:几千行的门店维、品牌维,每天全量刷新完全没问题;几十万行的用户维,全量刷新也扛得住;但如果是上千万级的用户维,就要认真考虑增量加拉链表了。

增量更新的难点不在于技术,而在于重复跑批时的幂等性。设计良好的拉链表,要求同一个代理键不能被重复分配,同一批变更数据重跑也不会生成两条一模一样的记录。一个容易被忽视的细节:ETL 任务里必须对代理键加上唯一约束,并且在重跑前先清理当天的临时分区。否则一次调度重放,就会在维度表里造出一堆重复行,给后续关联埋下隐患。

另外我再强调一遍:稳定可执行比“高级方案”重要得多。如果一个团队连全量刷新的调度都还不够顺,先别急着铺复杂的 SCD2 拉链表,等基础链路稳定了再逐步演进,完全来得及。

5.5 上线前的最后一道检查:孤儿键检测

每次维度表发布、事实表重建之后,我会先跑一个检查任务,这也是我多年养成的习惯。核心逻辑很简单:把事实表里所有维度外键拿出来,去维度表里做 LEFT JOIN,凡是匹配不上的行全部输出。只要这个查询返回空,我才敢说这一夜的调度是安全的。

SELECT f.order_sk, f.user_sk, f.product_sk, f.order_date_sk FROM fct_order f LEFT JOIN dim_user u ON f.user_sk = u.user_sk LEFT JOIN dim_product p ON f.product_sk = p.product_sk LEFT JOIN dim_date d ON f.order_date_sk = d.date_sk WHERE u.user_sk IS NULL OR p.product_sk IS NULL OR d.date_sk IS NULL;

我把这条 SQL 挂在每次核心模型发布前的检查节点里,已经救回了好几次因为上游编码变更导致的关联断裂。说实话,数仓的稳定性很多时候不靠什么高深算法,就是靠这些看似笨拙、但每次都认真执行的校验习惯堆出来的。

如果你现在正准备设计自己的第一张维度表,我最大的建议是:不要急着追求模型有多漂亮,先把“主键稳定、状态可见、空值可控、孤儿可查”这四件事做到位。等你跑了一两个月,自然会明白,这些基础功夫比任何花哨的设计都值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询