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 很多表,且每张表都可能再关联,查询复杂且慢。代价是冗余(维度属性重复存),但分析场景写入少、读多,这个交换划算。