dbt+SQLServer构建数据仓库(12):进阶 FAQ 篇

dbt+SQLServer构建数据仓库(12):进阶 FAQ 篇

继上一篇基础 FAQ 之后,继续围绕本项目梳理 5 个更深入的问题:增量加载、源表变更、数据测试等核心机制。项目结构依然是 staging → core(Data Vault)→ mart 三层架构。


项目背景速览

  • 数据源business_db(SQL Server 业务库,含 customers / orders / products 三张表)
  • 分层架构:staging(贴源)→ core(Data Vault,增量)→ mart(星型集市)
  • 核心特征:core 层全部是 incremental 增量模型,用哈希键(hashkey/hashdiff)做 Data Vault

FAQ 1:core 层的 materialized='incremental' 是什么意思?和 table 有什么区别?

答:incremental(增量)是指每次 dbt run 只把"新数据"追加到表里,而不是整表重建。

三种 materialized 对比

类型 每次 dbt run 做什么 适用场景 数据量
view 只更新视图定义,不存数据 轻量转换、查询少
table DROP 后重建整张表 维度表、全量刷新
incremental 只 INSERT 新数据,不动已有数据 事实表、历史累积数据

hub_order 为例

{{ config(
    materialized = 'incremental',
    unique_key = 'order_hk'
) }}
-- ... 中间逻辑省略 ...

SELECT
    order_hk,
    order_id,
    load_date,
    source_system
FROM new_records

{% if is_incremental() %}
WHERE order_hk NOT IN (SELECT order_hk FROM {{ this }})
{% endif %}

第一次跑(全量):表还不存在,dbt 直接 CREATE TABLE AS SELECT ...,把所有数据都装进去。

第二次及以后跑(增量):表已经存在了,dbt 只把 WHERE 条件筛选出的新记录 INSERT 进去。

为什么 core 层用 incremental,staging 层用 table?

  • staging 层:数据量相对可控,每次全量覆盖最省事,保证和源表一致。实际项目中这一层是全量还是增量,主要看源系统能提供数据的方式。
  • core 层:Data Vault 设计就是只增不改(尤其是 satellite 表,要保留历史快照),天然适合增量。数据量大了之后,全量重建成本太高。

unique_key 的作用

unique_key = 'order_hk' 告诉 dbt:用这个字段判断记录是否重复。如果新数据和老数据的 order_hk 一样,dbt 会执行 UPDATE(而不是 INSERT)。但在我们的 hub 表里,因为有 WHERE order_hk NOT IN (...) 的过滤,所以实际只有新增,不会有更新。

注意:如果 unique_key 没配好,可能导致主键冲突或者数据重复。


FAQ 2:is_incremental() 这个判断是干啥的?为什么第一次跑和后续跑逻辑不一样?

答:is_incremental() 是 dbt 提供的一个 Jinja 函数,用来判断"这次运行是不是增量模式"。

什么时候返回 true / false?

场景 is_incremental() 返回
表不存在(第一次跑) false
dbt run --full-refresh 强制全量 false
表已存在,正常增量跑 true

为什么需要这个判断?以 sat_customer 为例

sat_customer.sql 里的这段逻辑:

{% if is_incremental() %}
-- 增量模式:对比 hashdiff,只追加变化的记录
changed_records AS (
    SELECT ...
    FROM hashed h
    LEFT JOIN latest_existing le
        ON h.customer_hk = le.customer_hk AND le.rn = 1
    WHERE le.customer_hk IS NULL    -- 全新记录
       OR le.hashdiff != h.hashdiff  -- hashdiff 变化(说明属性变了)
)

{% else %}
-- 全量模式:所有记录都是"新"的,直接装
changed_records AS (
    SELECT * FROM hashed
)
{% endif %}

为什么不统一成增量逻辑?

第一次跑的时候,目标表还不存在{{ this }}(指当前模型自己的表)根本没法查。如果强行跑增量逻辑里的 FROM {{ this }},SQL 会报错"对象不存在"。

所以要分两种情况:

  • 全量模式(首次 / full-refresh):表是新的,所有数据都插进去,不需要对比。
  • 增量模式(后续):表已经有数据了,得想办法找出"哪些是新的 / 哪些变了",只追加变化。

增量识别新数据的两种常见方式

  1. 时间戳过滤(最简单):WHERE load_date > (SELECT MAX(load_date) FROM {{ this }})
  2. 哈希对比(我们项目用的):算 hashdiff,对比最新版本,变了就追加新记录

你们的 satellite 用的是第二种(hashdiff 对比),hub 和 link 用的是第一种思路的变体(主键不存在就插入)。


FAQ 3:增量加载的时候,如果上游源表数据被删除了,dbt 能感知到吗?

答:默认感知不到。dbt 的增量模型只负责"加新数据",不负责"发现被删的数据"。

为什么感知不到?

想象一下这个场景:

第 1 次 dbt run:源表有 100 条订单 → hub_order 装入 100 条
第 2 次 dbt run:源表被删了 5 条,还剩 95 条
                  ↑ dbt 增量只看"新来的",不看"少了的"
                  hub_order 里还是 100 条 ❌

增量 SQL 的逻辑是:

WHERE order_hk NOT IN (SELECT order_hk FROM hub_order)

它只找源表里有、目标表里没有的,不会找目标表里有、源表里没有的。

两种删除方式的处理策略

1. 硬删除(物理删除,DELETE 语句)

源表直接删掉行,dbt 增量模型完全不知道。解决办法:

  • 定期全量刷 staging 层:staging 用的是 table(全量覆盖),所以 stg 层是准的。但 core 层是增量的,还是不对。
  • dbt run --full-refresh 定期重建 core 层:比如每周一次全量,纠正删除。
  • 用 snapshot 快照表:dbt snapshot 能追踪数据变化(包括删除),但需要额外维护。
  • 最推荐:从源头改软删除(看下面)。

2. 软删除(逻辑删除,加 is_deleteddeleted_at 字段)

源表不真删,只是把某条记录标记为已删除:

UPDATE customers SET is_active = 0, deleted_at = GETDATE() WHERE customer_id = 123

这种方式 dbt 可以感知,因为:

  • 源表字段值变了 → staging 层能读到
  • satellite 的 hashdiff 会变化 → 新的快照被追加进去
  • mart 层维度表可以根据 is_active / deleted_at 来判断当前状态

Data Vault 范式本身就倾向于只增不改不删,所以软删除和 Data Vault 的理念非常契合。

本项目目前的情况

你们 stg_customers 里有 is_active 字段,sat_customer 也带了这个字段并参与 hashdiff 计算。所以如果业务库用软删除(改 is_active=0),整条链路是能正确追踪到的。但如果业务库直接 DELETE,core 层和 mart 层都会保留"幽灵数据"。


FAQ 4:源表结构变了(加了字段 / 删了字段),dbt 这边要改哪些东西?

答:看变的是哪一层,影响范围不一样。简单来说:上游加字段,下游逐层传递;上游删字段,下游要清理。

场景一:源表加了一个字段(比如 customers 加了 vip_level

需要改的文件从上到下依次是:

层级 文件 改什么
staging stg_customers.sql SELECT 里加上 vip_level 字段
staging schema.yml sources 里的 columns 补上新字段描述(可选但推荐)
core sat_customer.sql SELECT 里加 vip_level,并把它加入 generate_hashdiff() 的参数列表
mart dim_customer.sql 从 sat 里把 vip_level 取出来
mart schema.yml dim_customer 的 columns 里加上文档(可选)

重点提醒:satellite 表的 hashdiff 计算必须包含新字段,否则这个字段变化不会触发新的快照版本。

-- 改之前
{{ generate_hashdiff(['customer_name', 'email', ...]) }} AS hashdiff,

-- 改之后(加上 vip_level)
{{ generate_hashdiff(['customer_name', 'email', ..., 'vip_level']) }} AS hashdiff,

场景二:源表删了一个字段(比如 customers 删了 region

层级 文件 改什么
staging stg_customers.sql SELECT 里去掉 region
core sat_customer.sql SELECT 去掉,并从 hashdiff 参数中移除
mart dim_customer.sql 去掉对 region 的引用
mart 下游报表 / 宽表 检查有没有地方用到这个字段

场景三:源表改了字段类型(比如 customer_id 从 INT 改成 BIGINT)

  • staging 层通常不用改(SELECT * 过来或显式列出,类型跟着源表走)
  • 但如果下游有 JOIN 或者哈希计算依赖类型,要验证一下
  • 最安全的做法:在 staging 层显式 CAST(customer_id AS BIGINT) AS customer_id,把类型固定住

一个容易踩的坑:incremental 表加字段

table 类型的模型加字段无所谓,反正每次重建。但 incremental 表加字段要小心

老数据:没有 vip_level 字段(或为 NULL)
新加了字段后增量跑 → SQL Server 会报错:列数不匹配

解决方案

  1. 第一次加字段时,用 dbt run --full-refresh --select sat_customer 全量重建一次
  2. 或者提前在目标表手动加列(不推荐,绕开 dbt 管理)

经验法则:只要增量表的结构变了(加列、改类型),就跑一次 --full-refresh

怎么检查影响范围?

# 看 stg_customers 下游有哪些模型(改了 stg 之后,这些都可能要改)
dbt ls --select stg_customers+

FAQ 5:schema.yml 里的 tests 是干什么的?unique、not_null、relationships 怎么用?

答:tests 是 dbt 的数据质量测试机制,跑 dbt test 时会执行,用来验证数据是否符合预期。

什么是 schema 测试?

schema.yml 里给表和字段写断言,dbt 会生成对应的 SQL 查询去验证。如果返回结果不符合预期,测试就失败。

以你们项目 staging/schema.yml 里的为例:

sources:
  - name: business_db
    tables:
      - name: customers
        columns:
          - name: customer_id
            tests:
              - unique         # customer_id 必须唯一
              - not_null       # customer_id 不能为空

四种内置测试

测试 作用 生成的 SQL 大致逻辑
unique 字段值必须唯一 SELECT customer_id, COUNT(*) FROM ... GROUP BY customer_id HAVING COUNT(*) > 1
not_null 字段值不能为空 SELECT * FROM ... WHERE customer_id IS NULL
accepted_values 字段值必须在指定列表中 SELECT * FROM ... WHERE status NOT IN ('new','paid','shipped')
relationships 外键必须在主表中存在(参照完整性) SELECT customer_id FROM orders WHERE customer_id NOT IN (SELECT customer_id FROM customers)

relationships 的一个例子

你们项目里 orders 的 customer_id 就配了关系测试:

- name: customer_id
  tests:
    - not_null
    - relationships:
        to: source('business_db', 'customers')
        field: customer_id

意思是:orders 表里的每个 customer_id,都必须在 customers 表里存在。如果出现一个订单指向不存在的客户,测试就会失败。

什么时候跑测试?

# 跑所有测试
dbt test

# 只跑某个模型的测试
dbt test --select stg_customers

# 跑 build = run + test 一气呵成
dbt build --select stg_customers+

测试的层级

你们项目在三个地方都有测试:

位置 测试对象 目的
sources: 下面 源表 校验上游源数据质量(数据进来就查)
models: 下面的 staging stg 表 校验贴源后的数据
models: 下面的 mart dim/fct 表 校验最终交付给分析师的数据

经验法则:越靠近源头的测试越重要。问题越早发现,排查成本越低。

测试失败了会怎样?

  • dbt test 只检查不动表,不会修改任何数据
  • 失败会打印出失败的记录数,方便排查
  • 生产环境通常配置为:测试失败 → 阻断下游发布

小结

这 5 个问题,其实都围绕着一个核心主题:dbt 项目不是搭完就完了,它要跟着业务一起演进。

  • 增量加载 → 解决"数据越来越大怎么跑得动"的问题
  • 源表结构变更 → 解决"业务改了 schema 我该怎么办"的问题
  • 删除处理 → 解决"数据不只会增加,还会减少"的问题
  • 数据测试 → 解决"怎么相信这些数据是对的"的问题

把这些机制都理解透,才能从"会写 dbt 模型"进化到"能运维一套 dbt 数仓"。


posted on 2026-08-17 11:35  哥本哈士奇(aspnetx)  阅读(2)  评论(0)    收藏  举报

导航