46.1 数据建模

一句话

分析型建模与交易型建模的目标相反:交易型为了“不重复地写”,分析型为了“方便地读”——所以数据仓库用维度建模(星型),把“事实”与“维度”分开,并专门处理“维度会变”(缓慢变化维)。

两种范式,两个用途

范式化(3NF,OLTP) 维度建模(星型,OLAP)
目标 消除冗余、保证一致性 查询简单、扫描少、可聚合
结构 实体表 + 外键,少冗余 事实表(度量)+ 维度表(描述)
写 快(一处更新) 慢(冗余字段要同步)
读 多表 join 事实表 join 少量维度
适合 订单、库存等交易 报表、指标、BI

🔑 判断用哪种:“这张表主要为写入服务,还是为查询服务?” 交易库用 3NF;数仓 ADS/DWS 层用星型;ODS 层保持原样(贴源)。

星型模型的三件套

组件 内容 例子
事实表 度量(可加/半可加)+ 外键 sales(fact_id, product_key, store_key, date_key, amount, qty)
维度表 描述属性 dim_product(product_key, name, category, brand)
退化维度 直接放事实表的单号 order_no(不单独建维)

代码:缓慢变化维(SCD-2)为什么必须存在

package main

import "fmt"

// 维度:商品,带 SCD-2 的生效区间
type dimRow struct {
	key       int    // 代理键(数仓内唯一)
	productID string // 业务键
	category  string // 会变的属性
	validFrom string
	validTo   string // "9999-12-31" 表示当前
}

var dimProduct = []dimRow{
	{1, "P-100", "手机", "2023-01-01", "2024-05-31"},
	{2, "P-100", "智能设备", "2024-06-01", "9999-12-31"}, // 类目调整
	{3, "P-200", "配件", "2023-01-01", "9999-12-31"},
}

// 事实表:只存代理键(不是业务键)
type sale struct {
	productKey int
	date       string
	amount     float64
}

var sales = []sale{
	{1, "2024-03-15", 100}, // 当时类目还是“手机”
	{3, "2024-03-15", 50},
	{2, "2024-08-20", 80}, // 类目已改成“智能设备”
	{3, "2024-08-20", 40},
}

func categoryOf(key int) string {
	for _, d := range dimProduct {
		if d.key == key {
			return d.category
		}
	}
	return "?"
}

// 按“当前类目”聚合:用业务键找最新版本
func currentCategory(productID string) string {
	cat := "?"
	for _, d := range dimProduct {
		if d.productID == productID && d.validTo == "9999-12-31" {
			cat = d.category
		}
	}
	return cat
}

func keyToProductID(key int) string {
	for _, d := range dimProduct {
		if d.key == key {
			return d.productID
		}
	}
	return ""
}

func main() {
	fmt.Println("=== 按“当时的类目”(事实表代理键 join 维度)===")
	byHistorical := map[string]float64{}
	for _, s := range sales {
		byHistorical[categoryOf(s.productKey)] += s.amount
	}
	for k, v := range byHistorical {
		fmt.Printf("  %-8s %.0f\n", k, v)
	}

	fmt.Println("\n=== 按“当前类目”(用业务键回溯最新维度)===")
	byCurrent := map[string]float64{}
	for _, s := range sales {
		pid := keyToProductID(s.productKey)
		byCurrent[currentCategory(pid)] += s.amount
	}
	for k, v := range byCurrent {
		fmt.Printf("  %-8s %.0f\n", k, v)
	}

	fmt.Println("\n同一个季度的报表,两种口径数字不同——这不是 bug,是建模选择")
}

本机实测(macOS,Apple M1,Go 1.27):

=== 按“当时的类目”(事实表代理键 join 维度)===
  智能设备     80
  手机       100
  配件       90

=== 按“当前类目”(用业务键回溯最新维度)===
  配件       90
  智能设备     180

同一个季度的报表,两种口径数字不同——这不是 bug,是建模选择

读数:

口径 2024-03 的 100 元 2024-08 的 80 元 合计
按当时类目 记在“手机” 记在“智能设备” 手机 100 / 智能设备 80 / 配件 90
按当前类目 回溯成“智能设备” 记在“智能设备” 智能设备 180 / 配件 90

🔑 两种口径都合法,但不能混用:财务口径通常要“按当时”,运营分析常要“按当前”。所以维度表要保留历史版本(SCD-2),事实表存代理键而不是业务键——换类目只新增一条维度,不动历史事实。

SCD 的三种做法

类型 做法 历史 适用
SCD-1 直接覆盖属性 丢失 纠错(地址写错了)
SCD-2 新增一行 + 生效区间 完整 类目、等级、归属等会变且要回溯的属性
SCD-3 加“上一版本”列 只留一版 偶尔变一次、只关心前后对比

反模式

反模式 后果
数仓直接贴源、不做分层 报表 SQL 里全是 join,口径到处复制
事实表存业务键、不存代理键 维度变化后历史数据被“改写”
用 SCD-1 覆盖类目 历史报表回溯时数字变了,财务对不上
维度表没有代理键 无法区分“同一个商品的两个版本”
宽表把维度属性全拍平 属性变更要重刷历史分区
不做一致性维度(不同事实共用维度) 同一指标在不同报表口径不一致

自检

Q1 为什么事实表要存“代理键”而不是业务键?

▸ 答案:因为业务键会对应多个版本(SCD-2),事实表在写入时应该“锁定当时的版本”。存代理键 = 指向那一行具体维度;存业务键 = 只能事后按某个时间点回溯,一旦维度表被覆盖或新增版本,历史事实的含义就变了。代理键让“事实的语义”在写入那一刻固定下来。

Q2 “按当时类目”与“按当前类目”两个口径,什么时候必须用前者?

▸ 答案:当报表要回答“当时发生了什么”时必须用前者:财务报表、绩效考核、监管报送、同比环比(去年的类目结构不能因为今年调整而变)。反过来,“按当前类目看趋势/做预算”用后者。关键是在指标字典里写清口径,否则两个团队会算出两个数。

Q3 星型模型为什么比 3NF 更适合分析?

▸ 答案:因为分析查询是“扫大量事实、按维度聚合”,星型把维度拍平成一张宽表,join 次数少(通常 1–3 次),扫描路径短、优化器容易处理;3NF 要 join 很多表,且每张表都可能再关联,查询复杂且慢。代价是冗余(维度属性重复存),但分析场景写入少、读多,这个交换划算。

关联