dbt核心原理与工程实践:从数据仓库定位到DAG血缘治理
1. 这不是又一篇“dbt入门指南”——而是一份数据工程师亲手写给自己的实操备忘录
我带过六支数据工程团队,从零搭建过四套核心数仓体系,也亲手把三家公司从Excel报表时代拖进dbt+云数仓的现代流水线。过去三年里,我面试过127位声称“熟悉dbt”的候选人,其中能说清ref()和source()根本区别、能在5分钟内定位dbt test失败是schema问题还是逻辑问题、知道为什么dbt run --select +stg_orders会连带跑出17个上游模型的,不到11人。这不是能力问题,而是市面上90%的dbt教程都在教“怎么敲命令”,却没人告诉你“为什么必须这样敲”“敲错一个空格会炸掉哪条链路”“生产环境里哪个配置项改错会导致整张宽表凌晨三点重跑失败”。
这篇内容,就是我把这六年踩过的坑、调过的夜、被凌晨报警电话叫醒后记下的笔记,浓缩成七根真正支撑你日常工作的“钢筋”。它不讲dbt有多酷、社区多活跃、文档多庞大——那些话术留着去融资PPT里用。这里只讲:当你坐在工位上,面对一个新需求、一张脏数据表、一个突然报错的CI流水线时,你该打开哪个文件、查哪行日志、改哪个参数、心里默念哪三句口诀。
关键词就七个:数据仓库定位、Core与Cloud分野、项目结构即契约、profile是命门、模型即SQL契约、DAG是血缘图谱、测试是上线前最后一道安检门。全文没有一句“通过本文你可以……”,因为真实世界里没有“通过”,只有“跑通”和“炸了”。接下来所有内容,都来自我本地终端里反复rm -rf target && dbt clean && dbt deps && dbt build的真实记录,以及生产环境里dbt debug --config-dir输出的第37行错误堆栈。
2. 数据仓库:不是“存数据的地方”,而是dbt所有动作的物理坐标系
很多人学dbt卡在第一步——连自己连的是什么都不知道。他们以为dbt init之后点几下就进了数据世界,结果第一次dbt run就报Database Error: no such table 'stg_users',然后开始疯狂搜“dbt 表不存在”。其实问题根本不在这儿,而在于没搞懂dbt和数据仓库之间那个最朴素的关系:dbt不是数据库,它是一把精密的扳手,而数据仓库才是那台正在运转的发动机。dbt只负责拧紧、校准、更换零件,但绝不提供发动机本身。
举个生活化例子:你家厨房有冰箱(数据仓库)、有菜刀砧板(ETL工具)、有食谱(dbt项目)。dbt干的活,就是按食谱把冰箱里的生肉、蔬菜切配成半成品,再按步骤组装成一道菜。但它既不造冰箱,也不管冰箱里有没有肉——那是Airflow或Fivetran的事;它也不决定这道菜端给谁吃——那是BI工具的事。它只确保:当食谱写着“取200g五花肉”,你切出来的真是200g,肥瘦比例符合要求,且切法能让后续红烧时不散不柴。
所以,理解数据仓库,本质是理解dbt的“作用域边界”。你必须明确回答三个问题:
我的数据仓库物理位置在哪?是Snowflake账号
acme-prod.us-east-1下的ANALYTICS数据库?是BigQuery项目acme-data-312456里的raw数据集?还是本地DuckDB文件/Users/alex/dbt_learn/dev.duckdb?这个路径决定了profiles.yml里type字段填什么(snowflake/bigquery/duckdb),决定了pip install时该装哪个adapter(dbt-snowflake/dbt-bigquery/dbt-duckdb),更决定了dbt debug连不通时,你该先查网络策略、IAM权限,还是本地文件权限。我的数据仓库当前状态是否可信?很多人跳过这步直接写模型,结果发现
stg_orders表里order_id字段全是NULL——不是dbt错了,是上游ELT任务昨天挂了,根本没把数据灌进来。正确做法是:在dbt run前,先手动连进仓库执行SELECT COUNT(*) FROM raw.orders LIMIT 1;,确认基础表存在且有数据。我团队强制要求所有新成员入职第一周,每天早会前必须手写三条SQL验证三个核心源表,这是比背ref()语法更重要的基本功。我的数据仓库权限模型是否匹配dbt操作?dbt默认用
CREATE TABLE AS SELECT(CTAS)方式生成模型。这意味着你的数据库用户必须拥有:对目标schema的CREATE权限、对源表的SELECT权限、对目标表的INSERT/UPDATE权限。我在某次迁移中栽过跟头:DBA只给了SELECT权限,dbt run报错Permission denied: cannot create table in schema 'analytics'。查了两小时日志,最后发现是权限问题。现在我们所有项目的profiles.yml里,user字段后面必加一行注释:# 需具备:analytics.*: CREATE, raw.*: SELECT, staging.*: INSERT,新同事入职第一天就抄这行注释到自己的配置里。
提示:别被“云数仓”概念迷惑。Snowflake、BigQuery、Redshift本质都是关系型数据库的云化形态,它们支持标准SQL、有schema、有table、有role-based access control。dbt之所以能“一套代码打天下”,正因为它只依赖这些共性能力,而非某个厂商的私有特性。你今天用DuckDB练熟的
ref("stg_users"),明天换到Snowflake,只要profiles.yml里type改成snowflake、account填对,代码一行不用改。
3. dbt Core vs. dbt Cloud:选错就像给赛车装自行车轮胎
刚接触dbt的人常问:“我该学Core还是Cloud?”这个问题本身就暴露了认知偏差——这不是“学哪个”,而是“在哪个阶段用哪个”。我把它们的关系比作汽车制造:dbt Core是发动机总成图纸和装配手册,dbt Cloud是已经组装好、上了牌照、能直接开上路的整车。
3.1 dbt Core:你的本地作战指挥室
dbt Core是开源的命令行工具,安装命令就一行:pip install dbt-<adapter_name>。它的价值不在“能做什么”,而在“让你看清每一步怎么做”。比如:
dbt compile:不执行SQL,只做Jinja渲染和依赖解析,生成target/compiled/下的纯SQL文件。这是调试Jinja逻辑的黄金命令。当你写了个复杂macro,不确定{{ loop.index }}是否生效,dbt compile后直接看生成的SQL,比猜强一万倍。dbt parse:只解析项目结构,检查models/下SQL文件语法、models/*.yml配置格式、macros/宏定义是否合法。耗时不到1秒,却是CI流水线里dbt test前必跑的“语法体检”。dbt ls --select "stg_*":列出所有匹配stg_*前缀的模型,不运行,只展示依赖关系。当你想快速确认stg_users到底被哪些模型引用,这条命令比翻代码快十倍。
我坚持让所有新人从Core起步,原因很实在:所有Cloud功能,底层都是Core在跑。Cloud界面上点一下“Run Job”,后台实际执行的就是dbt run --select stg_users+ --target prod。如果你连本地dbt run --models stg_users都跑不通,上了Cloud只会更懵——因为Cloud把错误日志包装得更友好,反而掩盖了真实问题。
注意:
dbt Core不是“免费版”,它是dbt Labs的开源根基。Cloud的付费功能(如Web IDE、Job Scheduler、SLA监控)都是在Core之上叠加的运营层。就像Linux内核和Ubuntu桌面版的关系——你非得先懂ls、cd、grep,才能用好图形界面。
3.2 dbt Cloud:团队协作的高速公路收费站
dbt Cloud的核心价值,是把原本需要手动维护的运维工作,变成可配置、可审计、可复用的服务。它解决的不是“技术能不能实现”,而是“一群人怎么安全高效地一起干活”。典型场景:
环境隔离自动化:你在Cloud里创建
dev、staging、prod三个环境,每个环境绑定独立的profiles.yml(实际是Cloud后台管理的Connection配置)。开发时dbt run自动走dev连接,提PR后CI自动触发staging环境测试,合并主干后一键部署到prod。而用Core,你得手动维护三套profiles.yml,切换时改target字段,一不小心就把测试SQL跑到了生产库。权限与审计闭环:Cloud里可以设置:只有
data_engineering组能修改models/staging/目录,analysts组只能读models/marts/,所有dbt run操作自动记录执行人、时间、SQL哈希值、影响行数。某次线上事故,我们5分钟就定位到是实习生误删了stg_customers的WHERE条件,而不是翻两天Git日志。调度与告警集成:
dbt Cloud的Job Scheduler支持Cron表达式、依赖触发(如“等Airflow的load_raw_data任务成功后再跑dbt run”)、失败重试、邮件/Slack告警。而Core要实现同样效果,得自己写Python脚本调subprocess.run(['dbt', 'run']),再塞进Airflow的BashOperator,中间任何一环断掉,就得半夜爬起来修。
实操心得:我们团队采用“Core开发 + Cloud交付”混合模式。新人本地用Core写模型、调Jinja、跑测试;代码提交后,CI流水线用Core做语法检查和单元测试;最终部署到Cloud,由Cloud统一调度、监控、告警。这样既保证开发灵活性,又守住生产稳定性。千万别用Cloud Web IDE写复杂逻辑——没有本地IDE的智能提示和Git集成,写macro时少个括号都能调试半小时。
4. dbt项目结构:不是文件夹,而是数据契约的法律文书
dbt init my_project生成的目录,远不止是一堆文件。它是你和未来自己、和队友、和下游分析师签订的一份数据契约。每个目录名、文件名、YAML字段,都在声明:“我承诺这样组织数据,这样定义逻辑,这样保障质量”。破坏这个结构,轻则模型跑不通,重则整个数据链路信任崩塌。
4.1 核心目录语义:每个名字都是责任状
models/:数据产品的生产车间。这里放的不是“SQL脚本”,而是“数据产品说明书”。每个.sql文件对应一张对外交付的表/视图,文件名就是这张表的业务名称(如stg_orders.sql),内容必须是完整的SELECT语句(禁止INSERT INTO或CREATE TABLE)。我见过最离谱的案例:有人把models/staging/下所有文件命名为a.sql、b.sql、c.sql,三个月后连他自己都记不清b.sql到底是处理订单还是用户。models/staging/:原始数据的缓冲区。这里只做三件事:重命名列(order_id AS order_id)、类型转换(CAST(created_at AS TIMESTAMP))、过滤无效记录(WHERE _fivetran_deleted = FALSE)。绝不做聚合、关联、业务逻辑计算。原则是:“让原始数据尽可能干净地透传,把脏活留给下游”。我们团队规定:staging/目录下任何模型,SQL行数不得超过50行,超过必须拆分或质疑设计。models/marts/:面向业务的交付层。这里产出的表,名字直接对应业务术语:dim_customers、fct_orders、agg_daily_revenue。它们是分析师写Dashboard、产品经理看数据的唯一可信来源。marts/下的模型可以复杂,但必须有清晰的业务归属——比如marts/marketing/下的所有模型,只服务市场部需求,财务部无权访问。seeds/:静态配置数据的保险柜。存放不会变的维表,如国家代码表、产品分类码表。用CSV文件(seeds/country_codes.csv),通过dbt seed命令加载。优势是:版本可控(Git里看得到变更)、加载快(比SQL建表快10倍)、无需维护SQL逻辑。我们把所有accepted_values测试的白名单都放在这里,比如seeds/payment_methods.csv,tests/里直接{{ ref('payment_methods') }}引用,一改全改。macros/:SQL函数的中央厨房。所有重复逻辑必须抽象成macro。比如日期处理:macros/date_utils.sql里定义{% macro get_fiscal_year(date_col) %}...{% endmacro %},全项目统一调用{{ get_fiscal_year('order_date') }}。好处是:一处修复,全局生效;且macro可单独测试(dbt run-operation get_fiscal_year --args '{"date_col": "2023-01-01"}')。
关键细节:
dbt_project.yml里的model-paths、analysis-paths等配置,本质是在重定义这套契约的物理边界。如果你把models/挪到src/models/,就必须在dbt_project.yml里显式声明model-paths: ["src/models"],否则dbt根本找不到你的模型。这不是“约定俗成”,而是dbt强制执行的契约条款。
4.2 文件命名铁律:名字即接口,缩写即隐患
- 模型文件名必须可读:
stg_orders.sql✅,so.sql❌。后者在dbt ls输出里就是一团乱码,dbt run --select so更是灾难——万一还有个so_payments.sql呢? - 目录层级即业务层级:
models/staging/salesforce/✅(明确来源系统),models/staging/sf/❌(缩写模糊,新人看不懂s f代表salesforce还是service fee?) - YAML配置文件名必须匹配:
models/staging/orders.yml必须与models/staging/orders.sql同名(不含扩展名)。dbt靠这个关联模型和其配置。我曾因把orders.yml错命名为stg_orders.yml,导致所有not_null测试失效,排查了4小时才发现是文件名不匹配。
5. profiles.yml:不是配置文件,而是dbt连接数据世界的唯一签证
profiles.yml是dbt项目里最危险、也最重要的文件。它不包含业务逻辑,却掌控着所有数据的生死。我把它比作“数字世界的签证”——没有它,dbt连数据仓库的大门都摸不到;写错一个字符,整个团队的开发流就会瘫痪。
5.1 物理位置与安全红线
profiles.yml绝不能放在项目目录里,必须放在用户主目录下的.dbt/文件夹(macOS/Linux:~/.dbt/profiles.yml,Windows:%USERPROFILE%\.dbt\profiles.yml)。这是dbt硬编码的路径,改不了。原因很现实:项目目录会Git提交,而profiles.yml里有数据库密码(或密钥)。把它放项目里,等于把公司数据库密码上传到GitHub——我们真见过这种事,后果是全员强制重置密码、审计所有数据访问日志。
提示:生产环境严禁明文密码。Snowflake用
private_key_path指向本地密钥文件;BigQuery用keyfile指向JSON密钥;DuckDB用path指向本地文件。所有敏感信息,必须通过环境变量注入:password: "{{ env_var('DBT_PASSWORD') }}",然后在CI或Cloud里配置环境变量。
5.2 结构解析:target、outputs、profile三层嵌套逻辑
# ~/.dbt/profiles.yml acme_analytics: # profile名称,必须与dbt_project.yml里profile字段一致 target: dev # 默认激活的output outputs: dev: # output名称,对应target字段 type: duckdb # 数据库类型,必须与已安装adapter匹配 path: ./dev.duckdb # DuckDB特有:本地文件路径(相对项目根目录) # 其他数据库需填:account, user, password, database, schema, warehouse等 prod: type: snowflake account: "acme-prod.us-east-1" user: "{{ env_var('SNOWFLAKE_USER') }}" password: "{{ env_var('SNOWFLAKE_PASSWORD') }}" role: "DBT_PROD_ROLE" database: "ACME_PROD" schema: "ANALYTICS" warehouse: "DBT_WH"关键点:
profile是顶层命名空间,用于区分不同项目(acme_analyticsvsacme_marketing)。dbt_project.yml里profile: "acme_analytics"必须与之完全一致。outputs是具体连接实例,每个output对应一个环境(dev/staging/prod)。target: dev指定了默认连接,dbt run不加--target就走这里。type字段是生命线:pip install dbt-duckdb后,type: duckdb才有效;装dbt-snowflake后,type: snowflake才有效。装错adapter,dbt debug直接报Runtime Error: No adapter found for duckdb。
5.3 调试神技:dbt debug不是“看看就行”,而是逐层验尸
dbt debug命令会执行四层检查,每层失败都给出精准定位:
- Profile Validity:检查
profiles.yml语法是否合法(YAML格式、缩进、冒号后空格)。常见错误:password: "abc"写成password:"abc"(缺空格),或outputs:下面忘了缩进。 - Connection Test:尝试用
outputs.dev配置连接数据库。失败时明确提示:Connection failed: unable to open database file "./dev.duckdb"(文件路径错)或Authentication failed(密码错)。 - Adapter Compatibility:确认已安装对应adapter。失败提示:
Runtime Error: No adapter found for duckdb(该装dbt-duckdb)。 - Project Config:检查
dbt_project.yml是否存在且语法正确。
实操心得:每次换环境(如从dev切到prod),必跑
dbt debug --target prod。我团队CI流水线里,dbt debug是第一个stage,失败直接阻断后续所有步骤。宁可多花10秒,也不让错误SQL污染生产数据。
6. dbt模型:不是SQL文件,而是可编译、可依赖、可测试的数据合约
models/下的.sql文件,表面看是SQL,实则是dbt编译器的输入源码。它必须满足三个硬性条件:可被Jinja渲染、可被ref()引用、可被test()验证。违反任一条件,它就不是dbt模型,只是普通SQL脚本。
6.1 模型编写黄金法则:SELECT是唯一出口
dbt模型必须以SELECT语句开头,且整个文件只能有一个SELECT(除非用Jinja条件分支)。禁止出现:
INSERT INTO ... SELECT ...❌(dbt自动生成CTAS)CREATE TABLE ... AS SELECT ...❌(dbt自动处理)WITH cte AS (...) SELECT ...✅(CTE是SELECT的一部分)
正确写法:
-- models/staging/stg_orders.sql SELECT id AS order_id, customer_id, CAST(created_at AS TIMESTAMP) AS order_timestamp, total_amount, status FROM {{ source('raw', 'orders') }} WHERE _fivetran_deleted = FALSE注意{{ source('raw', 'orders') }}:这是引用源表的标准方式,比硬编码raw.orders更安全——它会在sources.yml里校验表是否存在,且支持跨环境切换(dev环境raw指向raw_devschema,prod指向raw_prod)。
6.2 ref()与source():血缘关系的两种DNA
{{ ref('stg_orders') }}:引用同一dbt项目内的其他模型。dbt据此构建DAG,确保stg_orders先于依赖它的int_orders执行。ref()的参数必须是模型文件名(不含路径和扩展名),如stg_orders.sql→ref('stg_orders')。{{ source('raw', 'orders') }}:引用外部数据源表(即ELT工具灌入的原始表)。必须在models/sources.yml里预先声明:# models/sources.yml version: 2 sources: - name: raw database: ACME_PROD # Snowflake中database名 schema: RAW # Snowflake中schema名 tables: - name: orders description: "Raw orders from Fivetran"
关键区别:
ref()是项目内依赖,source()是项目外依赖。混淆二者是高频错误。比如在stg_orders.sql里写{{ ref('raw.orders') }}会报错,因为raw.orders不是模型名;正确写法是{{ source('raw', 'orders') }}。
6.3 模型配置:YAML不是装饰,而是执行指令集
模型行为由models/*.yml文件控制,而非SQL内注释。例如:
# models/staging/schema.yml version: 2 models: - name: stg_orders description: "Staged orders with cleaned columns and types" config: materialized: table # 生成物理表(非视图) persist_docs: relation: true # 生成表级文档 columns: true # 生成字段级文档 columns: - name: order_id description: "Unique identifier for the order" tests: - unique - not_null - name: total_amount description: "Order total in USD" tests: - relationships: to: ref('dim_currency') field: currency_code这里config.materialized: table告诉dbt:“为这个模型创建物理表”,而非默认的view。persist_docs开启后,dbt docs generate会把description注入数据库COMMENT,让SELECT * FROM INFORMATION_SCHEMA.COLUMNS也能看到中文说明。
注意:
tests必须写在columns下,不能写在models下。- not_null是简写,完整形式是- test: not_null。简写仅适用于内置测试,自定义测试必须用完整形式。
7. DAG:不是图表,而是dbt执行引擎的拓扑地图
dbt run之所以能自动确定执行顺序,全靠DAG(Directed Acyclic Graph)。它不是你画在白板上的示意图,而是dbt解析ref()和source()后,在内存中构建的实时依赖树。理解DAG,就是理解dbt如何“思考”。
7.1 DAG生成原理:ref()是唯一的路标
dbt扫描所有.sql文件,提取所有{{ ref('xxx') }}和{{ source('yyy', 'zzz') }},构建节点关系:
- 每个模型文件是一个节点
- 每个
ref('model_name')是一条有向边,从被引用模型指向当前模型 source()是外部入口节点,无入边,只有出边
例如:
-- models/marts/fct_orders.sql SELECT o.order_id, o.customer_id, c.country_code, o.total_amount FROM {{ ref('stg_orders') }} o JOIN {{ ref('dim_customers') }} c ON o.customer_id = c.customer_idDAG节点:fct_orders←stg_orders,fct_orders←dim_customers
执行顺序:先stg_orders,再dim_customers,最后fct_orders(若两者无依赖,则并行)
7.2 DAG可视化:dbt docs不是摆设,而是血缘诊断仪
dbt docs generate && dbt docs serve启动的Web界面,核心价值不是“好看”,而是交互式血缘追踪:
- 点击任意模型(如
fct_orders),右侧显示:- Upstream:所有被它
ref()的模型(stg_orders,dim_customers) - Downstream:所有
ref()它的模型(如agg_monthly_revenue) - Source Tables:它直接读取的源表(通过
source())
- Upstream:所有被它
- 点击
stg_orders的Upstream,显示raw.orders(源表),再点进去,看到Fivetran同步任务名——这就是端到端血缘。
实操技巧:当
dbt run --select fct_orders失败,先打开dbt docs,找到fct_orders,看Upstream里哪个模型标红(表示未成功构建)。90%的问题,根源在上游模型。不要一上来就查fct_orders的SQL,先查stg_orders是否跑通。
7.3 DAG陷阱:循环依赖是死结,必须手动破除
DAG禁止循环(Acyclic),即不能出现A → B → C → A。常见诱因:
- 错误的ref()链路:
int_users.sql里ref('stg_users')✅,但stg_users.sql里又ref('int_users')❌(逻辑错误) - 宏滥用:在
macros/calc_revenue.sql里ref('fct_orders'),而fct_orders.sql又调用此macro ❌
破除方法:dbt list --select +fct_orders --output json输出所有上游模型,人工检查是否有闭环。更彻底的是:在CI里加入dbt deps后执行dbt list --select +fct_orders | wc -l,设定阈值(如>50个模型),超限则告警——这往往是隐性循环的征兆。
8. Jinja模板:不是“SQL里写Python”,而是SQL的元编程语言
Jinja在dbt里不是炫技工具,而是解决SQL原生缺陷的手术刀。SQL缺乏变量、循环、条件判断,Jinja补上了这些缺口,但必须严守边界:Jinja只生成SQL,绝不执行SQL。
8.1 ref()和config():最常用也最易错的两个函数
{{ ref('model_name') }}:生成目标模型的完整限定名。在DuckDB中是"stg_orders",在Snowflake中是"ACME_PROD"."ANALYTICS"."STG_ORDERS"。它确保跨环境一致性——你不用管prod里schema叫ANALYTICS还是PROD_ANALYTICS,ref()自动适配。{% set my_schema = config.get('schema', 'analytics') %}:从模型配置中取值。配合config:在YAML里定义:# models/marts/marketing/schema.yml models: - name: agg_campaign_performance config: schema: marketing_analytics # 覆盖project-level schemaSQL中即可用
{{ my_schema }}动态生成CREATE TABLE marketing_analytics.agg_campaign_performance。
常见错误:
{{ ref('stg_orders') }}写成{{ ref(stg_orders) }}(漏引号),或{{ ref('stg_orders') }}写成{{ ref("stg_orders") }}(双引号在某些shell里会被解释)。必须单引号,且引号内是字符串字面量。
8.2 宏(Macro):SQL函数的终极形态
宏是预编译的SQL代码块,解决重复逻辑。例如标准化时间分区:
-- macros/time_partition.sql {% macro time_partition(column_name, partition_type='day') %} {% if partition_type == 'day' %} DATE({{ column_name }}) {% elif partition_type == 'month' %} DATE_TRUNC('month', {{ column_name }}) {% endif %} {% endmacro %}在模型中调用:
-- models/marts/fct_orders.sql SELECT {{ time_partition('order_timestamp', 'month') }} AS order_month, COUNT(*) AS order_count FROM {{ ref('stg_orders') }} GROUP BY 1编译后生成:
SELECT DATE_TRUNC('month', order_timestamp) AS order_month, COUNT(*) AS order_count FROM "ANALYTICS"."STG_ORDERS" GROUP BY 1关键原则:宏必须放在
macros/目录,且文件名与宏名一致(time_partition.sql→time_partition宏)。调用宏时,{% call %}用于生成DDL,{{ }}用于生成DML表达式。
9. dbt测试:不是“锦上添花”,而是数据上线前的熔断开关
dbt test不是开发完成后的附加步骤,而是每个模型提交前的强制安检。它不保证业务逻辑正确,但保证数据基础可靠:主键不重复、关键字段不为空、外键有对应值、数值在合理范围。没过测试的模型,连dbt run都不该让它执行。
9.1 四大内置测试:每个都是数据质量的基石
| 测试名 | 作用 | 典型场景 | 配置示例 |
|---|---|---|---|
unique | 检查字段值是否全局唯一 | order_id,customer_id | - unique |
not_null | 检查字段是否无NULL值 | order_timestamp,status | - not_null |
accepted_values | 检查字段值是否在预设白名单内 | status只能是'pending','shipped','delivered' | - accepted_values: {values: ['pending', 'shipped', 'delivered']} |
relationships | 检查外键是否在目标表存在 | customer_id在dim_customers表有对应记录 | - relationships: {to: ref('dim_customers'), field: customer_id} |
9.2 测试执行策略:不是“全量跑”,而是“精准打”
dbt test:运行所有测试(慢,适合CI)dbt test --models stg_orders:只跑stg_orders相关测试(快,适合开发中)dbt test --select test_type:generic:只跑通用测试(unique,not_null等)dbt test --select test_type:singular:只跑自定义SQL测试(见下文)
实操心得:我们团队规定,
git commit前必须执行dbt test --models <modified_model>。CI流水线里,dbt test是第二步(第一步是dbt debug),失败则阻断部署。曾有一次,accepted_values测试发现payment_method新增了'crypto'值,但业务方未同步更新白名单,测试立刻捕获,避免了下游报表数据异常。
9.3 自定义测试(Singular Test):应对复杂业务规则
内置测试解决通用问题,自定义测试解决特定规则。例如:订单金额不能为负数,且必须大于运费。
-- tests/order_amount_check.sql SELECT order_id, total_amount, shipping_cost FROM {{ ref('stg_orders') }} WHERE total_amount < 0 OR total_amount < shipping_cost文件名order_amount_check.sql,放在tests/目录。dbt test会自动发现并执行——返回非空结果即视为测试失败。
关键点:自定义测试文件必须是
.sql,且内容是SELECT语句。dbt将查询返回行数 > 0 视为失败(即“找到违规数据”)。这与通用测试逻辑相反(通用测试是“找到违规数据”才失败),但语义一致:测试的目的是暴露问题。
10. 典型工作流:不是线性步骤,而是PDCA循环
dbt不是“写完代码→跑一次→完事”的工具,而是嵌入数据开发全生命周期的协作协议。我们团队实践的最小可行工作流(MVP Workflow)如下:
10.1 日常开发循环(PDCA)
- Plan(计划):接到需求(如“新增用户地域维度”),在
dbt docs里查dim_customers现有字段,确认缺失country_code,规划新建stg_customers_geo.sql和dim_customers_enhanced.sql。 - Do(执行):
touch models/staging/stg_customers_geo.sql- 写SQL,
{{ source('raw', 'customers_geo') }} touch models/marts/dim_customers_enhanced.sql,{{ ref('stg_customers_geo') }}touch models/staging/schema.yml,添加stg_customers_geo的not_null测试
- Check(检查):
dbt compile --models stg_customers_geo→ 看生成SQL是否正确dbt run --models stg_customers_geo→ 确认执行成功dbt test --models stg_customers_geo→ 确认测试通过
- Act(行动):
git add、git commit -m "feat: add customers geo staging"、git push触发CI。
10.2 CI/CD流水线:自动化守门员
我们CI流水线(GitHub Actions)固定四步:
dbt debug --target dev:验证配置
