数据建模 是做什么的?
目标平台:PostgreSQL, BigQuery, Snowflake。转换工具:dbt。本地原型验证:DuckDB 2.0。
有关dbt的机械操作(模型配置、测试YAML、快照、语义层、v2迁移),请使用本仓库的de-vendors技能拉取的官方dbt Labs技能。本技能决定建模什么,而非dbt如何拼写。
流程
- 明确业务问题及其消费者(仪表盘、机器学习特征、财务结账)。
- 在任何列之前,用一句话描述粒度:“一行代表每个订单行每天。”如果这句话需要包含“或”,说明应拆分为两个表。
- 选择事实类型(如下),然后列出在该粒度下有效的维度。
- 命名粒度键(自然列或它们的哈希值)。该键需设置unique + not_null约束。
- 在DuckDB中对样本数据原型验证,执行fan-out检查,然后移植。
- 在声明完成前,添加“测试什么”中列出的测试。
粒度
- 粒度优先,列定义其次。每个度量和每个外键在粒度级别上必须是单值的;不属于该粒度的列应放在另一个表中,否则会默默地导致行数膨胀。
- 保持源支持的最低粒度。聚合值可从明细推导,但明细不可从聚合值推导。将汇总表构建为独立的数据集市。
- 绝不要在同一张表中混合不同粒度(例如,订单头总额重复出现在订单行行上)。对订单行求和时,头表金额会被重复计算;应将头表和行表的事实分开建模。
事实与维度
- 事实:事件或度量;数值类型,尽可能可加,由维度键和退化维度(如订单号)关联。
- 维度:用于过滤和分组的上下文描述。宽而反范式化是可以的。
- 分类每个度量:可加性(在所有维度上求和)、半可加性(余额:不能跨时间求和)、非可加性(比率:存储分子和分母,聚合后相除)。
- 事实类型:
- 事务事实表:每个事件一行。默认选择。
- 周期快照事实表:每个实体每个周期一行(每日余额)。当状态很重要且事件稀疏或延迟时使用。
- 累积快照事实表:每个流程实例一行,每个里程碑有一个日期列,原地更新。用于具有已知生命周期的管道(已下单、已发货、已送达)。预期会发生更新;使加载幂等。
- 无事实事实表:没有度量的事件或覆盖(如出勤、资格)。统计行数。
模式形状
- 默认使用星型模式:一个事实表,扁平维度。只有当子维度被共享且较大,或由另一个团队维护时,才使用雪花模式(规范化维度);额外的连接会损害可读性和BI工具兼容性。
- 一致性维度:一个
dim_customer、dim_date被所有事实表共享,具有相同的键和属性含义。只构建一次;另一个具有不同规则的客户表是缺陷,因为跨事实报表会不一致。 - 角色扮演维度(如order_date、ship_date)通过单独的外键列复用一个
dim_date。 - 垃圾/迷你维度用于低基数的标志位;不要在事实表上遗留8个布尔列。
- 未知成员:在每个维度中保留一行(键为
-1或'unknown'的哈希值),以便迟到的事实能保留其行。内连接会静默地删除事实行;应使用左连接并coalesce到未知键。
键
- 自然键:源系统的业务标识符。始终保留为属性。
- 代理键:当自然键为组合键、可能变化、来自多个源,或维度为SCD2(一个实体有多个版本)时,添加代理键。在dbt风格的仓库中,优先使用自然键(加上SCD2的valid_from)的确定性哈希值,而非序列,以确保重新运行和并行构建时一致。
- 不要将代理键作为标识符暴露给业务用户;它们在重建时会改变。
缓慢变化维度
- 类型1(覆盖):当历史记录无分析价值或变更属于修正时使用。
- 类型2(每个版本新增一行,含valid_from / valid_to / is_current):当必须按事件时间报告属性值(如购买时的客户等级)时使用。
- 当源系统不保留历史记录且你仅需捕获未来的变化时,快照足以应对;快照是捕获机制,其上的SCD2维度模型仍然存在。如果源系统已有历史记录或事件日志,则从中派生有效范围,而非进行快照。
- 将事实表与SCD2连接:基于键加上
event_ts >= valid_from and event_ts < valid_to(半开区间,避免重叠)。测试每个自然键下是否无重叠或无间隙的范围。 - 类型3或按属性分类型的类型2:仅在单个“先前值”列能回答问题时才使用;否则跳过。
Fan-out与重复计算
原因:连接粒度不匹配的表,导致“一”方实际为“多”。症状:总计大于源数据,count(*)在左连接后增加,平均值偏移。
- 检测:比较每次连接前后
count(*)和count(distinct grain_key)的值;它们必须相等(连接维度后)。测试维度侧连接键的唯一性(select key from dim group by 1 having count(*) > 1)。 - 核对:数据集市中度量的总和等于相同过滤条件下源数据中的总和。
- 修复方法:在连接前,将“多”方聚合到事实的粒度,或通过带有分配权重的桥接表连接;绝不能使用
distinct隐藏问题。 - 多对多关系(客户到账户,产品到类别):使用带有权重的桥接表,或选择一个主表并说明。
- 这些检查的SQL参见:references/fan-out-checks.md。
分层
- raw/staging:每个源表对应一个staging模型;重命名、类型转换、去重,无连接,无业务逻辑。保持raw层不可变。
- intermediate:连接、重塑、被2个及以上数据集市复用的共享逻辑。不向BI暴露。
- marts:事实表和维度表,每个业务过程对应一个数据集市,已文档化、测试化、契约化。只有marts是公开的。
- 依赖关系单向流动;marts不读取raw,staging从不读取其他staging。
- staging物化为视图,marts物化为表(当数据量大且粒度键稳定时采用增量模式)。
命名
stg_<源系统>__<实体>、int_<实体>_<动词>、fct_<过程>、dim_<实体>;snake_case;统一使用单数或复数(选一种)。- 键:
<实体>_id(自然键)和<实体>_key(代理键);时间戳:<事件>_at(UTC);日期:<事件>_date;布尔值:is_/has_;金额携带单位或货币(amount_usd)。
测试什么
- 粒度键:每个模型上设置unique + not_null;组合粒度测试组合的唯一性。
- 事实表上的每个外键:not_null(或未知成员)以及指向维度的relationships测试。
- 状态/类型列上的已接受值;新值会显式失败,而非被错误分类。
- 每个数据集市与源数据的行数和总和核对,给出明确的容差(财务场景为零)。
- SCD2:无重叠范围,每个自然键恰好有一行当前记录。
- 源数据的新鲜度。在样本数据上至少破坏每个测试一次,以证明它能失败。
OLTP模式审查(PostgreSQL)
- 事务表规范化到3NF;仅在可衡量的读路径上反范式化。每个表都有主键。
- 为外键列建立索引:PostgreSQL不会自动创建,无索引的外键会使父表删除和连接操作扫描子表。
- 使用
timestamptz、货币使用numeric、文本使用text而非任意varchar限制;通过NOT NULL、CHECK、UNIQUE和FK约束强制执行规则,而非仅依赖应用代码。 - 对于大量插入的表,优先使用
bigint标识列或UUIDv7键,而非随机的UUIDv4(索引局部性)。 - 多租户:在每个租户拥有的表上放置
tenant_id,使其成为组合键和索引的引导列,包含在外键中(组合外键)以消除跨租户引用,并配合行级安全策略。默认使用共享schema加tenant_id;仅当需要硬隔离或按租户恢复时才使用每个租户独立schema或数据库。 - 软删除:使用部分唯一索引(
where deleted_at is null)以保持唯一性约束。
陷阱和已观测故障模式:references/gotchas.md