4. dbt 项目结构与模型分层
当 dbt 项目从几个模型扩展到几十、上百个模型时,真正决定它能否长期维护的,不是 SQL 写得多巧妙,而是项目结构是否清晰。dbt 官方推荐的三层模型分层——staging、intermediate、marts——正是为了解决这个问题:它把"清洗原始数据""组合中间逻辑""面向业务交付"三类职责拆开,让每个模型只做一件事,也让依赖关系一眼可读。
三层各自解决什么问题
staging(暂存层)是数据进入 dbt 世界的第一站。它的核心职责是"一对一映射源表":每个 staging 模型通常对应一张源表,只做重命名、类型转换、简单清洗和轻量计算,不做 join、不做聚合。这样做的价值在于,源系统的字段变化被隔离在 staging 层,下游模型不必关心原始字段名和脏数据。staging 模型一般物化为 view,因为查询成本低、始终反映最新源数据。
intermediate(中间层)承担"承上启下"的复杂逻辑。它把多个 staging 模型 join 起来、做聚合、处理业务规则,但不直接面向最终消费者。中间层模型可以物化为 view 或 ephemeral,也可以对性能敏感的部分物化为 table。它的存在让 marts 层保持简洁——marts 只负责"选择、重命名、暴露字段",而不是从头写复杂逻辑。
marts(集市层)是面向业务用户的交付层,通常按业务域划分,如 marts/finance、marts/marketing、marts/product。每个 mart 模型对应一张宽表或事实/维度表,字段命名贴近业务语言,物化为 table 或 incremental,保证查询性能。业务方、BI 工具、报表都只消费这一层。
目录组织:让路径即语义
dbt 默认从 models/ 目录读取模型,子目录结构完全由项目决定。推荐的组织方式是让目录路径直接反映分层和业务域:
models/
├── staging/
│ ├── salesforce/
│ │ ├── _salesforce__sources.yml
│ │ ├── stg_salesforce__accounts.sql
│ │ └── stg_salesforce__opportunities.sql
│ └── stripe/
│ ├── _stripe__sources.yml
│ └── stg_stripe__payments.sql
├── intermediate/
│ └── finance/
│ └── int_finance__opportunity_payments_joined.sql
└── marts/
├── finance/
│ ├── _finance__models.yml
│ ├── fct_revenue.sql
│ └── dim_customers.sql
└── marketing/
└── fct_campaign_performance.sql
这里有几个约定值得坚持:每个源系统或业务域一个子目录;每个目录放一个 _<domain>__sources.yml 或 _<domain>__models.yml 集中管理该域的 source 定义、模型描述和测试;文件名前缀(stg_、int_、fct_、dim_)与目录层级保持一致,这样即使模型被移动,也能从名字判断它属于哪一层。
命名规范:前缀 + 源/域 + 实体
dbt 社区广泛采用的命名模式是 <前缀>_<来源或业务域>__<实体>,双下划线分隔"来源"和"实体",便于阅读和排序。具体前缀约定如下:
| 层级 | 前缀 | 示例 | 物化 |
|---|---|---|---|
| staging | stg_ | stg_stripe__payments | view |
| intermediate | int_ | int_finance__opportunity_payments_joined | view / ephemeral |
| marts 事实表 | fct_ | fct_revenue | table / incremental |
| marts 维度表 | dim_ | dim_customers | table |
命名时还要注意:staging 模型名中的"来源"应与 source 定义中的 source name 一致,例如 source('stripe', 'payments') 对应 stg_stripe__payments;marts 层不再带来源前缀,因为业务方不关心数据来自哪个系统。字段命名统一用 snake_case,布尔字段用 is_ 或 has_ 前缀,时间字段用 _at 后缀(如 created_at),日期字段用 _date 后缀。
依赖方向与分层约束
三层之间必须保持单向依赖:staging 只引用 source,intermediate 引用 staging 和 intermediate,marts 引用 intermediate 和 staging,绝不反向引用。marts 之间也不应互相引用,否则会形成难以排查的循环依赖。dbt 的 ref() 函数会自动构建 DAG,如果出现反向引用,dbt build 会直接报错,这是分层约束的天然保障。
一个可落地的分层示例
假设要构建一张收入事实表 fct_revenue,涉及 Stripe 支付和 Salesforce 商机两个源。按分层设计,实现路径如下:
stg_stripe__payments:从source('stripe','payments')读取,重命名id → payment_id、amount → amount_cents,把created转成created_at,过滤测试数据。stg_salesforce__opportunities:从source('salesforce','opportunities')读取,重命名Id → opportunity_id、Amount → opportunity_amount,统一币种字段。int_finance__opportunity_payments_joined:把上述两个 staging 模型按opportunity_idjoin,计算每个商机的实收金额,物化为 view。fct_revenue:从 intermediate 模型选择业务字段,按opportunity_id和revenue_date组织,物化为 table,并配置unique和not_null测试。
这样,当 Stripe 字段改名时,只需改 stg_stripe__payments 一个模型;当业务口径变化时,只需改 int_finance__opportunity_payments_joined;fct_revenue 始终稳定。这就是分层带来的隔离价值。
dbt_project.yml 中可以用路径批量配置物化方式,避免逐个模型设置:models: my_project: staging: +materialized: view; marts: +materialized: table
常见反模式
- staging 里做 join:staging 应保持一对一,join 逻辑放到 intermediate。
- marts 直接引用 source:绕过 staging 会让清洗逻辑分散,源字段变化时难以定位。
- marts 之间互相引用:形成隐式依赖链,DAG 难以理解,容易循环。
- 命名不带前缀:
payments和stg_stripe__payments相比,后者能直接告诉读者它属于哪一层、来自哪个源。
把分层、目录和命名三件事同时定下来,dbt 项目就从"一堆 SQL 文件"变成了"有结构的数据资产"。后续新增模型时,只需回答两个问题:它属于哪一层?它属于哪个业务域?答案自然决定了它的路径、前缀和物化方式。