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):表是新的,所有数据都插进去,不需要对比。
- 增量模式(后续):表已经有数据了,得想办法找出"哪些是新的 / 哪些变了",只追加变化。
增量识别新数据的两种常见方式
- 时间戳过滤(最简单):
WHERE load_date > (SELECT MAX(load_date) FROM {{ this }}) - 哈希对比(我们项目用的):算 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_deleted 或 deleted_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 会报错:列数不匹配
解决方案:
- 第一次加字段时,用
dbt run --full-refresh --select sat_customer全量重建一次 - 或者提前在目标表手动加列(不推荐,绕开 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) 收藏 举报
浙公网安备 33010602011771号