本文要讲的是很多用dbt和Snowflake搭数仓的开发者都踩过的坑:明明设置了增量加载,结果因为模型依赖顺序错,跑的时候把之前的全量数据给覆盖了,白忙活了好几天的同步全没了。

一、问题背景:增量加载变全量覆盖的真实场景

1.1 踩坑的具体例子

国内某做生鲜电商的团队,用dbt+Snowflake做订单相关的数仓,每天要同步前一天的订单数据做增量统计,本来运行了半年都没问题,上个月因为调度工具的配置调整,把“订单统计模型”的运行时间提前了5分钟,结果当天的订单统计报表直接从之前的120万订单变成了0。技术人员排查了3个小时,最后才发现是依赖顺序搞反了:订单统计模型要等“原始订单增量模型”跑完,拿到前一天的订单数据才能计算,但调度时统计模型先跑,这时候原始订单的增量数据还没生成,统计模型的增量逻辑找不到数据,就把之前存好的全量统计数据给清空了。

1.2 核心矛盾点

dbt做Snowflake的增量加载时,不是随便就能跑的,必须保证上游需要的表先生成好,不然增量的逻辑会失效,直接当成全量来处理,把已经有的数据覆盖掉——这就是为什么顺序错会出大问题,小则浪费时间,大则影响业务决策。

二、为什么顺序会错?DBT的DAG是隐形规则

2.1 DBT的核心:依赖是一张“流程地图”

你可以把dbt的模型当成一个个积木,每个积木的位置都有要求:要先放最下面的基础积木(比如原始订单表),再放上面的统计积木(订单统计),不然积木会倒。这个位置关系就是dbt说的DAG(有向无环图),它会自动梳理模型之间的依赖,比如你写ref('ods_order'),dbt就知道这个模型依赖ods_order,会把它排在前面。但如果你自己改了调度顺序,或者不小心写错了引用,就会把顺序搞反。

2.2 Snowflake增量加载的“隐形前提”

Snowflake的增量加载,是指每次只把新的数据加到目标表里,不会动老数据。但这个逻辑有个前提:必须有已经存在的目标表,并且上游的增量数据是对的。如果上游数据没准备好,dbt的增量代码跑的时候,会因为找不到新数据,直接把目标表里的老数据全部删掉,再插入空数据——这就等于全量覆盖了,和你想要的“加新数据”完全相反。

三、踩坑的具体代码示例

3.1 技术栈明确:dbt v1.5 + Snowflake

所有示例都用这个组合,不混其他工具,确保场景真实可复现。

3.2 错误的模型代码(导致覆盖的写法)

-- 错误模型:dwd_order_invalid_stats.sql
{{ config(
    materialized='incremental', -- 设置为增量加载
    unique_key='stat_date' -- 唯一键,用来合并数据
) }}

-- 错误点1:直接读raw层的原始表,不引用dbt的上游模型,dbt丢失依赖追踪
-- 错误点2:WHERE条件写死为昨天,依赖的上游表没跑时,没有数据返回
SELECT 
    DATE(created_at) AS stat_date,
    COUNT(order_id) AS order_cnt,
    SUM(amount) AS total_amount
FROM raw.ods_order -- 直接读底层原始表,不是dbt模型
WHERE DATE(created_at) = CURRENT_DATE - INTERVAL '1 day' -- 只取前一天的数据
GROUP BY DATE(created_at)

这个模型的问题是没有用ref引用dbt的上游模型,导致dbt不知道它依赖raw层的ods_order,不会把raw层的调度放在前面,当raw层的增量没加载时,这个模型就会跑空,进而覆盖老的统计数据。

3.3 正确的模型代码(避免覆盖的写法)

-- 正确模型:dwd_order_valid_stats.sql
{{ config(
    materialized='incremental',
    unique_key='stat_date'
) }}

-- 用ref引用上游dbt模型,让dbt自动管理依赖顺序
WITH valid_order AS (
    SELECT * FROM {{ ref('ods_order') }} -- 正确引用,dbt会自动把这个上游放在前面跑
)
SELECT 
    stat_date,
    COUNT(order_id) AS order_cnt,
    SUM(amount) AS total_amount
FROM valid_order
{% if is_incremental() %}
-- 增量时取目标表的最新日期,避免写死,确保只加新数据
WHERE stat_date >= (SELECT MAX(stat_date) FROM {{ this }})
{% endif %}
GROUP BY stat_date

这里用ref引用上游模型,dbt会自动把ods_order排在统计模型前面,确保上游先跑完;另外增量的WHERE条件不是写死的前一天,而是取目标表的最新日期,就算上游延迟,也不会扫到之前的全量数据。

四、解决顺序问题的方法

4.1 用dbt文档可视化DAG

dbt有个很实用的功能叫docs,你只要在终端输入dbt docs generate,再dbt docs serve,就能打开一个网页,看到所有模型的依赖图,箭头指的方向就是dbt自动排的顺序。比如你能看到ods_order指向dwd_order_valid_stats,说明顺序是对的;如果箭头反过来,就是顺序错了,一目了然。

4.2 增加前置检查的逻辑

可以在模型里加个校验,如果上游的最新数据比目标表的旧,就直接报错,不让跑。比如先在packages.yml里加dbt-utils依赖,再写:

-- 带前置检查的模型
{{ config(
    materialized='incremental',
    unique_key='stat_date'
) }}

-- 前置检查:上游最新日期不能比目标表旧
{% set max_ods_date = dbt_utils.max_value_in_range(ref('ods_order'), 'stat_date') %}
{% set max_target_date = dbt_utils.max_value_in_range(this, 'stat_date', default='1900-01-01') %}

{% if max_ods_date < max_target_date %}
{{ exceptions.raise_compiler_error("错误:上游ods_order最新日期" ~ max_ods_date ~ " < 目标表最新日期" ~ max_target_date ~ ",请先跑ods_order模型") }}
{% endif %}

WITH order_data AS (
    SELECT * FROM {{ ref('ods_order') }}
)
SELECT 
    stat_date,
    COUNT(order_id) AS order_cnt,
    SUM(amount) AS total_amount
FROM order_data
{% if is_incremental() %}
WHERE stat_date > {{ max_target_date }}
{% endif %}
GROUP BY stat_date

这样就算调度顺序错了,模型也会直接报错,不会覆盖数据,避免业务损失。

4.3 调度层的顺序配置

如果用Airflow或者其他调度工具,一定要按dbt的DAG顺序来配置任务,比如Airflow里要设置OrderStatsTask << OdsOrderTask,确保上游先跑,下游后跑,从调度层面堵住顺序错的漏洞。

五、优缺点及注意事项

5.1 优点

dbt和Snowflake配合的增量加载,比全量加载快很多,比如1000万条数据的增量,只需要扫当天的新增数据,而全量要扫1000万,节省时间和资源;dbt的DAG管理让模型之间的依赖更清晰,多人协作时不容易乱,适合中大型团队。

5.2 缺点

当模型多的时候,比如有50个以上的模型,DAG会变得复杂,容易漏看依赖;如果不用ref引用,直接读raw层,dbt会丢失依赖,导致顺序错,这是新手最容易犯的错,也是很多人踩坑的根源。

5.3 注意事项

  1. 每次改模型后,一定要用dbt docs看依赖图,确认顺序,不要凭感觉;
  2. 增量模型必须加unique_key,避免重复数据,也能减少合并时的问题;
  3. 增量的WHERE条件必须用目标表的最新日期,不能写死,适配不同的调度延迟;
  4. 要有重试机制,比如如果调度失败,下次自动按重跑顺序跑,减少人工干预。

六、总结

很多时候,dbt和Snowflake的增量加载出问题,都是因为“看不见的依赖顺序”没搞对,看似简单的配置,实则关系到数据的一致性。只要你用好dbt的可视化DAG,用ref管理依赖,加好前置校验,就能避免增量变全量覆盖的坑。数仓的核心是数据准确,一点点的顺序错,可能带来很大的业务麻烦,所以一定要重视这个细节,从模型到调度全链路确保依赖顺序正确。