dbt+SQLServer构建数据仓库(4):分层建模实战
本文接上篇,从概念走向实操:基于本项目的 5 个模型,讲清楚分层架构的设计原则和每个模型的 SQL 怎么写。
一、引言
前 3 篇讲了概念、对比和配置:
- 第 1 篇认识 dbt,第 2 篇讲清了为什么不用 SSIS/存储过程,第 3 篇逐行剖析了
dbt_project.yml。 - 但始终没写一行真正的模型 SQL。本篇终于动手------基于本项目的 5 个模型,讲清分层建模怎么落地。
分层架构(raw / staging / marts)是 dbt 社区的事实标准。它不是 dbt 强制的,但几乎所有 dbt 项目都这么干。原因很简单:把"读源数据"和"算业务指标"分开,数仓才不会变成一锅炖。本文就按这套分层,把每层的 SQL 拆开讲。
为简化示例,本文采取精简版的数据分层。生产环境请勿参考本文的分层策略。
二、为什么要分层
不分层的缺点
传统存储过程式数仓长这样:一个超大 SQL,直接从业务库读表,中间 join、过滤、聚合、再 join,最后落到一张报表表。问题:
- 血缘不可追溯:改了某个字段,不知道影响哪些下游。
- 重复计算:同一张源表被 N 个存储过程各读一遍。
- 测试无从下手:中间没有可测的"层"。
- 职责混乱:取数 / 清洗 / 业务逻辑挤在一个 SQL 里,后期谁都不敢动。
分层后:清晰的职责切分
把流程切成三层,每层只做一件事:
| 层 | 职责 | 本项目实现 | 物化 |
|---|---|---|---|
| raw | 原样落库,源系统数据照搬 | dbt seed 加载 CSV | seed (表) |
| staging | 1:1 投影源表,只做重命名+类型规范,不加业务 | stg_customers / stg_orders / stg_payments | view |
| marts | 面向业务消费,做聚合/关联,产出 dim/fact | dim_customers / fct_orders | table |
数据流图
+-------------+ +----------------+ +----------------+
| raw 层 | | staging 层 | | marts 层 |
| (源系统落地) | | (1:1 规范投影) | | (业务聚合) |
+-------------+ +----------------+ +----------------+
| raw_customers|----->| stg_customers |----->| dim_customers |
| raw_orders |----->| stg_orders |----->| fct_orders |
| raw_payments |----->| stg_payments | | |
+-------------+ +----------------+ +----------------+
dbt seed ref()/view ref()/table
每一层只读上一层,绝不跨层引用。这就是分层建模的核心约束。
三、sources.yml:声明源表
在写 staging 之前,得先告诉 dbt"源表在哪"。这就是 sources.yml(file:///Users/wadesong/Documents/trae_projects/dbtms/models/staging/sources.yml) 的作用。
yaml
version: 2
sources:
- name: raw
# seeds 落在 dbt_dev_raw schema (target.schema=dbt_dev + custom_schema=raw)
schema: dbt_dev_raw
description: "源系统原始数据, 由 dbt seed 加载."
tables:
- name: raw_customers
description: "客户主数据原始表."
- name: raw_orders
description: "订单原始表."
- name: raw_payments
description: "支付流水原始表."
为什么要用 source() 而不是直接写表名
如果直接写 from dbt_dev_raw.raw_customers,表名是硬编码的字符串,dbt 完全不知道它的存在。用 source('raw', 'raw_customers') 则有三个好处:
- 可追踪血缘 :dbt 在生成的 DAG 里能看到"这张 staging 表依赖哪个 source",下游模型
refstaging 时,整条链路从源表到最终表全可见。 - 可做新鲜度测试 :source 可声明
loaded_at_field和freshness,dbt 能检查源表是不是过期了(本项目暂未启用,但接口预留)。 - 可统一描述:在 sources.yml 里写一次 description,生成文档时自动带上,不用在每个模型里重复。
逐字段解读
name: raw:source 组名,后续source('raw', ...)的第一个参数。schema: dbt_dev_raw:实际落在哪个 schema。本项目用 dbt-core 标准拼接,target.schema=dbt_dev+custom_schema=raw→dbt_dev_raw。description:组级别的描述,文档用。tables:本组下的源表清单。name: raw_customers:表名,source('raw', 'raw_customers')的第二个参数。description:表级别描述。
四、staging 层设计原则与实现
设计原则
- 1:1 投影:行数与源表一致,不聚合、不过滤(除了明显的脏数据清洗,但本项目保持纯 1:1)。
- 字段重命名:把源系统不规范的名字(snake_case 之外、缩写、匈牙利命名)规范化。
- 类型转换 :用
cast把字符串/不规范的类型规范成标准类型。 - 不加业务逻辑:staging 不做 join、不做聚合、不做 case when 业务分支。业务逻辑全留给 marts。
最小案例:stg_customers.sql
sql
-- staging 层: 对 raw_customers 做字段重命名与类型规范,
-- 保持 1:1 投影, 不做业务过滤与聚合.
select
cast(id as int) as customer_id,
first_name,
last_name
from {{ source('raw', 'raw_customers') }}
逐行解读:
- 第 1-2 行注释写明本层的职责边界,这是团队约定,便于后续维护者一眼看清。
cast(id as int) as customer_id:把源表通用的id字段重命名为业务可读的customer_id,同时显式转成int。这一步让下游模型不再操心类型。first_name/last_name:原名已经规范,原样保留。from {{ source('raw', 'raw_customers') }}:用 Jinja 的source()引用源表。编译时 dbt 会把它替换成dbt_dev_raw.raw_customers。
对比:stg_orders.sql 与 stg_payments.sql
sql
-- staging 层: 对 raw_orders 做字段重命名与类型规范.
select
cast(id as int) as order_id,
cast(user_id as int) as customer_id,
cast(order_date as date) as order_date,
status
from {{ source('raw', 'raw_orders') }}
sql
-- staging 层: 对 raw_payments 做字段重命名与类型规范.
select
cast(id as int) as payment_id,
cast(order_id as int) as order_id,
payment_method,
cast(amount as numeric(18, 2)) as amount,
status
from {{ source('raw', 'raw_payments') }}
类型转换的差异化处理:
| 字段 | 源类型(推测) | cast 目标 | 原因 |
|---|---|---|---|
order_date |
datetime / varchar | date |
业务只关心日期,不需要时间部分,统一为 date |
amount |
varchar / float | numeric(18, 2) |
金额必须用定点数,避免浮点误差 |
id / user_id / order_id |
bigint / int | int |
统一主键类型,join 时类型必须一致 |
注意 user_id → customer_id 这种重命名:源系统叫 user,业务口径叫 customer,staging 这层就把术语对齐,下游永远不用关心"user"这个词。
为什么 staging 用 view 物化
看 dbt_project.yml的配置:
yaml
models:
dbt_sqlserver_dw:
staging:
+materialized: view
+schema: staging
marts:
+materialized: table
+schema: marts
这里只是为了讲解技术细节所以使用View。实际项目中,我强烈建议这一层用table,方便后续数据加载问题的回溯。
五、marts 层设计原则与实现
marts 层职责
marts 是面向业务消费的层,做两件事:
- 聚合:count、sum、min、max 这类业务指标。
- 关联:把多张 staging 表 join 起来,产出宽表。
维度建模简介
本项目 marts 采用星型模型,产出两类表:
- 维度表(dim_*) :描述业务实体,每行一个实体,字段是属性。如
dim_customers描述客户。 - 事实表(fct_*) :描述业务事件,每行一个事件,字段是度量 + 外键。如
fct_orders描述订单。
案例 1:dim_customers.sql
sql
-- 客户维度表: 汇总每个客户的首末订单时间、订单数、累计消费金额 (LTV).
with customers as (
select * from {{ ref('stg_customers') }}
),
orders as (
select
customer_id,
min(order_date) as first_order_date,
max(order_date) as most_recent_order_date,
count(order_id) as number_of_orders
from {{ ref('stg_orders') }}
group by customer_id
),
payments as (
select
o.customer_id,
sum(p.amount) as lifetime_value
from {{ ref('stg_orders') }} o
inner join {{ ref('stg_payments') }} p
on o.order_id = p.order_id
where p.status = 'completed'
group by o.customer_id
)
select
c.customer_id,
c.first_name,
c.last_name,
o.first_order_date,
o.most_recent_order_date,
o.number_of_orders,
coalesce(p.lifetime_value, 0) as lifetime_value
from customers c
left join orders o
on c.customer_id = o.customer_id
left join payments p
on c.customer_id = p.customer_id
案例 2:fct_orders.sql
sql
-- 订单事实表: 每个订单一行, 关联客户与已完成支付的金额.
with orders as (
select * from {{ ref('stg_orders') }}
),
payments as (
select
order_id,
sum(amount) as total_amount
from {{ ref('stg_payments') }}
where status = 'completed'
group by order_id
)
select
o.order_id,
o.customer_id,
o.order_date,
o.status,
coalesce(p.total_amount, 0) as amount
from orders o
left join payments p
on o.order_id = p.order_id
事实表设计要点:
- 粒度:每行一个订单(事实表的核心是粒度清晰,这里粒度 = order_id)。
- 度量 :
amount------订单的已完成支付总额。 - 外键 :
customer_id------指向dim_customers的外键。 - payments CTE :按
order_id聚合支付(一个订单可能有多笔支付),同样只计completed。 - left join :没支付的订单保留,
amount兜底 0。
注意 dim_customers 和 fct_orders 共享同一份 stg_orders 和 stg_payments 的引用------分层后,源数据只在 staging 落地一次,marts 层各自消费,不重复读源表。
六、CTE 模式:为什么 dbt 推荐 with ... as
with ... as 是 Common Table Expression(CTE)。dbt 社区强烈推荐用 CTE,而不是嵌套子查询。
CTE 的可读性优势
看 dim_customers 的结构:
with customers as (...),
orders as (...),
payments as (...)
select ... from customers
left join orders ...
left join payments ...
自上而下,每个 CTE 是一个逻辑步骤,命名清晰:customers / orders / payments。读 SQL 时,先看三个 CTE 各做什么,再看最后的 SELECT 怎么拼。逻辑是线性的,不用在脑子里维护嵌套层级。
dbt 社区约定
- 每个 CTE 一个逻辑步骤:取数、过滤、聚合、join 各自独立,不混在一起。
- CTE 命名清晰 :用业务名词(
customers/orders/payments),不用t1/t2/subquery。 - CTE 只做一件事:不在一个 CTE 里同时聚合和 join。
对比传统存储过程的嵌套子查询
传统写法常常是嵌套子查询:
sql
select c.customer_id, c.first_name, o.first_order_date, ...
from (select * from dbt_dev_raw.raw_customers) c
left join (
select customer_id, min(order_date) as first_order_date, ...
from (select * from dbt_dev_raw.raw_orders) o
group by customer_id
) o on c.customer_id = o.customer_id
...
这种写法:
- 嵌套层级深,读起来要在脑子里一层层展开。
- 子查询没有名字,只能靠缩进区分。
- 改起来要数括号,容易出错。
CTE 把嵌套拍平,每一步都命名,可读性碾压。
七、ref() 的作用
前面所有 marts 模型都用 {{ ref('stg_customers') }}。ref() 是 dbt 最核心的函数,做三件事:
1. 编译期替换表名
ref('stg_customers') 编译后变成实际的 schema 限定表名:
ref('stg_customers') → dbt_dev_staging.stg_customers
schema 拼接规则来自 dbt_project.yml 的 +schema: staging,叠加 target.schema=dbt_dev,得到 dbt_dev_staging。你只写 ref('stg_customers'),dbt 替你算出全路径。
2. 自动建立依赖关系(DAG)
dbt 扫描模型里的 ref() 和 source(),自动构建有向无环图(DAG)。本项目的 DAG:
source:raw.raw_customers ──> stg_customers ──┐
├──> dim_customers
source:raw.raw_orders ────> stg_orders ────┬─┤
│ ├──> fct_orders
source:raw.raw_payments ──> stg_payments ─┤
│
(stg_orders ─┘
stg_payments 也被 dim_customers
的 payments CTE 引用)
3. 自动决定执行顺序
改了 stg_orders,运行 dbt run 时:
- dbt 知道 dim_customers 和 fct_orders 都依赖 stg_orders。
- 自动先跑 stg_orders(它是 view,会重新创建),再跑依赖它的 marts。
- 你只声明"我用谁"(
ref),不声明"先跑谁"------这就是声明式的好处。传统存储过程要靠 Agent 作业里的步骤顺序,改顺序得改作业配置;dbt 里改了依赖,执行顺序自动更新。
八、常见反模式与避坑
反模式 1:staging 层做聚合
错误示例:
sql
-- stg_orders 里写聚合, 违反 1:1 原则
select customer_id, count(*) as order_cnt
from {{ source('raw', 'raw_orders') }}
group by customer_id
问题:staging 失去"源表投影"的语义,下游想看订单明细就没了。聚合永远放 marts,staging 保持 1:1。
反模式 2:marts 层直接引用 source()
错误示例:
sql
-- fct_orders 里直接读源表, 跳过 staging
select * from {{ source('raw', 'raw_orders') }}
问题:跳过 staging 等于绕过了类型规范和重命名,marts 层要自己处理类型转换,逻辑混在一起。而且 source 被多次直接引用,血缘和新鲜度测试失效。正确做法:marts 永远 ref staging。
反模式 3:一个模型做太多事
错误示例:把 dim_customers 和 fct_orders 塞进一个 customer_orders_summary.sql,既算维度又算事实。
问题:职责混乱,下游想用维度还得解析这个大表。星型模型的核心是 dim 和 fact 分开,各司其职。一个模型只产出一个业务实体或业务事件。
反模式 4:不用 CTE,写嵌套子查询
错误示例:
sql
select * from (
select * from (
select * from {{ ref('stg_orders') }}
) a where status = 'completed'
) b
问题:可读性差、难维护、难测试。dbt 模型一律用 CTE,每个 CTE 一个逻辑步骤。
九、小结
分层建模的核心要点:
- 三层职责清晰:raw 原样落库, staging 1:1 规范投影, marts 业务聚合。每层只做一件事。
- staging 四原则:1:1 投影、字段重命名、类型转换、不加业务逻辑。这是 staging 和 marts 的分界线。
- source() 声明源表:可追踪血缘、可做新鲜度测试、可统一描述。绝不硬编码表名。
- ref() 建立依赖:编译期替换表名、自动构建 DAG、自动决定执行顺序。声明式而非命令式。
- CTE 模式:每个 CTE 一个逻辑步骤,命名清晰,告别嵌套子查询。
- 物化策略:staging 用 view(轻量、自动反映源数据),marts 用 table(聚合提速)。需要时再单独调优。
- marts 用星型模型:dim_* 描述实体,fct_* 描述事件,粒度清晰,外键指向维度。
- 避开反模式:staging 不聚合、marts 不跳层、一模型一职责、不用嵌套子查询。