dbt+SQLServer构建数据仓库(4):分层建模实战

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') 则有三个好处:

  1. 可追踪血缘 :dbt 在生成的 DAG 里能看到"这张 staging 表依赖哪个 source",下游模型 ref staging 时,整条链路从源表到最终表全可见。
  2. 可做新鲜度测试 :source 可声明 loaded_at_fieldfreshness,dbt 能检查源表是不是过期了(本项目暂未启用,但接口预留)。
  3. 可统一描述:在 sources.yml 里写一次 description,生成文档时自动带上,不用在每个模型里重复。

逐字段解读

  • name: raw:source 组名,后续 source('raw', ...) 的第一个参数。
  • schema: dbt_dev_raw:实际落在哪个 schema。本项目用 dbt-core 标准拼接,target.schema=dbt_dev + custom_schema=rawdbt_dev_raw
  • description:组级别的描述,文档用。
  • tables:本组下的源表清单。
    • name: raw_customers:表名,source('raw', 'raw_customers') 的第二个参数。
    • description:表级别描述。

四、staging 层设计原则与实现

设计原则

  1. 1:1 投影:行数与源表一致,不聚合、不过滤(除了明显的脏数据清洗,但本项目保持纯 1:1)。
  2. 字段重命名:把源系统不规范的名字(snake_case 之外、缩写、匈牙利命名)规范化。
  3. 类型转换 :用 cast 把字符串/不规范的类型规范成标准类型。
  4. 不加业务逻辑: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_ordersstg_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 一个逻辑步骤。

九、小结

分层建模的核心要点:

  1. 三层职责清晰:raw 原样落库, staging 1:1 规范投影, marts 业务聚合。每层只做一件事。
  2. staging 四原则:1:1 投影、字段重命名、类型转换、不加业务逻辑。这是 staging 和 marts 的分界线。
  3. source() 声明源表:可追踪血缘、可做新鲜度测试、可统一描述。绝不硬编码表名。
  4. ref() 建立依赖:编译期替换表名、自动构建 DAG、自动决定执行顺序。声明式而非命令式。
  5. CTE 模式:每个 CTE 一个逻辑步骤,命名清晰,告别嵌套子查询。
  6. 物化策略:staging 用 view(轻量、自动反映源数据),marts 用 table(聚合提速)。需要时再单独调优。
  7. marts 用星型模型:dim_* 描述实体,fct_* 描述事件,粒度清晰,外键指向维度。
  8. 避开反模式:staging 不聚合、marts 不跳层、一模型一职责、不用嵌套子查询。
相关推荐
哥本哈士奇(aspnetx)18 小时前
dbt+SQLServer构建数据仓库(3):dbt_project.yml配置精讲
sqlserver
全麦面包 time展天1 天前
走向DBA[MSSQL篇] 从SQL语句的角度 提高数据库的访问性能
数据库·sqlserver·dba
满昕欢喜2 天前
2.4 本地服务器组和中央管理服务器
数据库·sqlserver
哥本哈士奇(aspnetx)3 天前
dbt+SQLServer构建数据仓库(1):认识dbt与项目工作流程
sqlserver
xuefuhe6 天前
SQL Server 监控login的IP
sqlserver
半桶水专家6 天前
SQL Server DML 操作语句完全指南
数据库·sqlserver
测试修炼手册17 天前
[测试技术] JUnit 6 入门与实战:断言、参数化与 Mockito
数据库·junit·sqlserver
心之语歌17 天前
SQL Server 按月分区实战:动态边界自动生成方案
sqlserver
bosins21 天前
部署SSIS并增加SQL Server代理作业,任务正常执行但事件查看器一直报DCOM权限问题
sqlserver·ssis·权限·dcom·事件查看器