事务事实表设计:设计原则、行来源与典型场景

事务事实表,说白了就是记录「每一笔业务事件」的明细表。我做了这么多年数据仓库,接触最多的就是它。你想想看,销售订单、银行交易流水、网站点击日志……这些都属于事务事实表。

今天咱们就把它拆开揉碎了讲清楚。我会结合自己踩过的坑,把设计原则、行来源和两个典型场景说透。

一、设计原则:四个必须守住的红线

事务事实表的设计,我总结出四条铁律。每条都是拿实际教训换来的。

原则 核心要求 我踩过的坑
粒度声明 每行代表一次业务事件 订单头和订单行混在一个表里,分析时根本拆不开
维度退化 业务主键直接作为事实表的维度属性 订单号单独建维度表,查询要关联两次,性能惨不忍睹
不可变 已写入的事实行不允许修改 直接更新订单金额,导致历史汇总对不上账
可加性 度量字段必须能跨维度累加 把单价放进来,结果按日期求和得到一堆无意义数字

核心观点:事务事实表的粒度,决定了整个数据仓库的分析能力上限。粒度越细,灵活性越高,但存储成本也越大。我个人习惯在业务允许范围内,选择最细的粒度。

二、行来源:每一行数据从哪里来?

行来源,就是事实表里每一行数据的「出生证明」。我见过太多项目,上线后发现数据对不上,追根溯源都是行来源没搞清楚。

事务事实表的行来源,通常有这几种:

  • 业务系统操作日志:比如订单创建、支付成功、发货确认。每一笔操作产生一条记录。
  • 交易流水文件:银行、支付渠道提供的对账单。每行就是一笔交易。
  • 消息队列事件:实时系统推送的业务事件。每收到一条消息,就生成一行事实。
  • ETL增量抽取:从源表按时间戳或自增ID拉取新增数据。

避坑指南:我曾经接手过一个项目,行来源用了「全量快照+增量比对」的方式。结果每天跑批要对比几千万条数据,性能极差。后来改成基于业务时间戳的增量抽取,效率提升了10倍。记住:事务事实表就应该用增量方式,别搞全量比对。

三、典型场景一:销售订单

销售订单是事务事实表最经典的应用。咱们直接看设计。

3.1 粒度选择

订单的粒度有两种选择:

  • 订单头粒度:一行代表一个订单。适合分析订单总数、总金额。
  • 订单行粒度:一行代表一个商品明细。适合分析商品维度的销量、销售额。

我建议优先选订单行粒度。为什么?因为订单头粒度丢失了商品信息,后续想做商品分析还得关联子表,反而更麻烦。

3.2 表结构示例

CREATE TABLE fact_sales_order (
  -- 维度外键
  order_date_sk    INT NOT NULL,      -- 下单日期
  customer_sk      INT NOT NULL,      -- 客户
  product_sk       INT NOT NULL,      -- 商品
  store_sk         INT NOT NULL,      -- 门店
  -- 退化维度
  order_no         VARCHAR(30),       -- 订单号(退化维度)
  order_line_no    INT,               -- 订单行号
  -- 度量
  quantity         DECIMAL(12,2),     -- 数量(可加)
  unit_price       DECIMAL(12,2),     -- 单价(不可加,仅做参考)
  line_amount      DECIMAL(12,2),     -- 行金额(可加)
  discount_amount  DECIMAL(12,2),     -- 折扣金额(可加)
  -- 元数据
  etl_batch_id     INT,
  create_dt        TIMESTAMP
);

注意:unit_price 这个字段,我建议只保留在事实表里做参考,不要用它做汇总。因为不同商品的单价没有累加意义。真正可加的度量是 line_amount 和 quantity。

3.3 行来源逻辑

销售订单的行来源,通常来自订单系统的订单明细表。ETL 流程大概是:

  1. 每天凌晨,从源系统拉取前一天新增或变更的订单行。
  2. 通过订单日期、订单号、行号判断是否已存在。
  3. 对于新增行,直接插入事实表。
  4. 对于变更行(比如退款),插入一条新的事实行,用「事件类型」字段标记。

嗯,这里要注意:不要直接更新原行。我见过有人把退款金额直接减到原行上,结果历史数据全乱了。正确的做法是新增一行,用「-1」的数量或金额来表示退款。

四、典型场景二:交易流水

交易流水,比如银行转账、支付交易,和销售订单有些不同。它的特点是:一笔交易涉及两个账户。

4.1 粒度选择

交易流水的粒度,我建议按交易事件来定。一行代表一笔交易。但这里有个坑:一笔交易可能涉及借方和贷方两个账户。

我的做法是:拆成两行。一行记录借方(扣款方),一行记录贷方(收款方)。通过交易流水号关联。

4.2 表结构示例

CREATE TABLE fact_transaction (
  -- 维度外键
  trans_date_sk    INT NOT NULL,      -- 交易日期
  account_sk       INT NOT NULL,      -- 账户
  trans_type_sk    INT NOT NULL,      -- 交易类型(转账、消费、充值)
  -- 退化维度
  trans_no         VARCHAR(40),       -- 交易流水号
  trans_seq        INT,               -- 交易序号(1=借方,2=贷方)
  -- 度量
  trans_amount     DECIMAL(16,2),     -- 交易金额(可加)
  balance_after    DECIMAL(16,2),     -- 交易后余额(不可加)
  -- 元数据
  etl_batch_id     INT,
  create_dt        TIMESTAMP
);

关键设计:trans_seq 字段用来区分借方和贷方。这样设计的好处是:按账户汇总时,借方金额是负数,贷方金额是正数,求和就能得到账户余额变化。我做过一个银行项目,就是用这个方案,跑批效率比传统方案高了30%。

4.3 行来源逻辑

交易流水的行来源,通常来自核心系统的交易日志或消息队列。我建议用事件驱动的方式:

  • 交易发生时,业务系统发送一条消息到消息队列。
  • ETL 消费消息,解析出交易信息。
  • 拆分成借方和贷方两行,写入事实表。
  • 如果消息丢失,用 T+1 的批量对账来补漏。

我曾经遇到过一个情况:消息队列积压,导致实时数据延迟了2小时。后来加了监控告警,一旦积压超过1000条就自动扩容消费者。嗯,这种坑踩过一次就记住了。

五、知识体系总览

下面这张图,把事务事实表的核心知识点串起来了。你可以把它当作设计时的检查清单。

事务事实表设计核心知识体系 事务事实表 设计原则 粒度声明 · 维度退化 不可变 · 可加性 行来源 操作日志 · 交易流水 消息队列 · 增量抽取 销售订单 订单行粒度 · 退化维度 可加度量 · 事件类型 交易流水 借方/贷方拆分 · 事件驱动 消息队列 · T+1对账 核心原则:粒度最细、增量抽取、不可变、可加性度量

六、避坑总结

最后,我把这些年踩过的坑汇总一下。你设计事务事实表时,可以对照检查:

  • 粒度不清晰:订单头和订单行混在一起。解决方案:统一用订单行粒度。
  • 直接更新历史数据:导致汇总对不上。解决方案:新增行+事件类型标记。
  • 行来源用全量比对:性能极差。解决方案:改用增量抽取或消息队列。
  • 把不可加度量放进来:比如单价、余额。解决方案:只保留可加度量,不可加字段仅做参考。
  • 忽略退化维度:把订单号单独建维度表。解决方案:订单号、流水号直接放在事实表里。

我的建议:设计事务事实表时,先问自己三个问题:每行代表什么事件?哪些字段可以累加?数据来了之后会不会被修改?这三个问题想清楚了,设计就不会跑偏。