想象你在一家餐厅的后厨当主管,每天清晨,生鲜、冻品、半成品从不同供应商涌入后厨——这就是原始数据。传统做法是让厨师在仓库外先把菜全部做熟再端进餐厅(ETL),而现在流行的做法是把食材先搬进厨房(数仓),再在厨房里现场加工(ELT)。可问题来了:菜谱零散、谁先做哪道菜全凭默契、食材新鲜度无人追踪——整个后厨一片混乱。 dbt(data build tool)正是这套“后厨调度系统”。它不亲自掌勺(不移动数据),而是把每道菜的菜谱(SQL模型)集中管理,自动识别“红烧肉需要提前炖高汤”这类依赖关系,按正确顺序调度厨师开工;它还支持把菜提前备好放保温柜(物化表)、只补做新点的份量(增量更新)、记录食材每天的保鲜状态(快照)。本文就用这套“厨房经济学”,帮你零基础掌握 dbt 的核心转换功能与建模方法。

岗位关联

对 AI 从业者而言,dbt 是数据工程领域的“通用语法”。掌握它,你就拥有了把原始数据高效加工成特征表和指标层的核心能力——这是构建任何 AI 应用都绕不开的数据地基。

目录
  1. 可视化演示
  2. dbt是什么
  3. 项目结构
  4. SQL建模
  5. 物化策略
  6. 依赖管理
  7. 增量更新
  8. 数据快照
  9. 最佳实践
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

动态演示dbt数据转换核心功能与建模的核心机制,通过开始/执行/监控/完成四种模式展示关键概念与交互流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

dbt数据转换核心功能与建模:dbt核 dbt核心架构与SQL转换模型 物化策略与table、view 核心机制可视化 dbt数据转换核心功能与建模整体架构与关键组件

dbt是什么

后厨里最资深的不是颠勺的厨师,而是那个拿着进度板调度全场的“行政总厨”。他清楚地知道:哪道菜需要先炖高汤、哪道菜可以等客人点了再起锅、哪位厨师现在手上没活。这个行政总厨,就是 dbt 在数据厨房里的角色。它不搬运食材、不直接烹饪,却让整个厨房高效运转。

传统 ETL 工具好比“中央厨房”,食材在外面全部加工成真空料理包再送入餐厅;而 dbt 代表的 ELT 理念恰好相反——先把食材(原始数据)搬进数仓这个大厨房,再在厨房内根据不同菜品需求现场加工。这样一来,数据只被移动一次,之后全部靠 SQL 在仓库里就地取材,成本和延迟都大幅下降。

这个角色的诞生催生了一个新职位——分析工程师:既懂业务指标又懂数据工程,用代码化的方式管理数据转换。dbt 的核心价值也正在于此:所有模型(菜谱)纳入版本控制(Git),依赖关系自动解析,测试可以随时运行,数据团队从“作坊式”迈向“现代化流水线”。

理解了这层定位,你就明白了 dbt 为什么能成为现代数据团队的基础设施——它像行政总厨那样,把“做什么、按什么顺序做”的调度权集中起来,剩下的交给标准化的菜谱(SQL 模型)去执行。

ELT 链路:源系统→Load数仓→dbt Transform分析结果\text{ELT 链路}:\quad \text{源系统} \xrightarrow{\text{Load}} \text{数仓} \xrightarrow{\text{dbt Transform}} \text{分析结果}
这个式子讲的是数据旅程:源系统里的数据先被搬运进数仓,再由 dbt 在数仓内部完成转换加工,最终产出可供分析和建模的数据。
  1. 源系统数据旅程的起点,可能是订单库、日志文件或第三方 API。
    源系统={OLTP 数据库, 日志文件, API 数据}\text{源系统} = \{\text{OLTP 数据库},\ \text{日志文件},\ \text{API 数据}\}
  2. Load 阶段dbt 不负责这步,通常由 Fivetran、Airbyte 等工具把数据“搬运”进数仓。
    Load: 源系统→数据仓库\text{Load}: \ \text{源系统} \to \text{数据仓库}
  3. dbt Transform 阶段dbt 登场,在数仓内部执行 SQL 完成清洗、关联、聚合。
    Transform: 原始表→模型视图/物理表\text{Transform}: \ \text{原始表} \to \text{模型视图/物理表}
  4. 分析结果产出为 BI 报表、特征宽表或指标服务直接使用的数据表。
    结果={业务报表, ML 特征表, 指标 API}\text{结果} = \{\text{业务报表},\ \text{ML 特征表},\ \text{指标 API}\}

项目结构

一间正规厨房,食材要分冰箱、冷藏柜、调料架摆放,厨师才能随手拿到所需。dbt 项目的目录结构也是同样的“厨房分区学”:每种文件有它固定的位置,井然有序。

最顶层的 dbt_project.yml 是厨房的“消防平面图”——它声明了项目的名字、数仓连接方式、默认的物化策略和目录映射规则。所有刀(模型)放在 models/ 目录——这是菜谱柜,每个 .sql 文件就是一道菜的菜谱。snapshots/ 目录存放“食材台账本”(快照),tests/ 是质检工位,macros/ 放可复用的“调味配方”(Jinja 宏)。

菜谱柜内部还要继续分区:staging/ 放“洗菜切菜”的菜谱(对源表做最基础的清洗重命名)、intermediate/ 放“半成品加工”的菜谱(业务中间逻辑)、marts/ 放“摆盘出品”的菜谱(面向业务的分析宽表)。此外,packages.yml 负责管理外部“调料包”——社区贡献的 dbt 扩展包,像引入开源库一样复用别人的菜谱。

这套分区法在实践中非常好用:新同事入职,看一眼目录结构就知道该去哪里找菜谱、改哪个文件,这正是 dbt 项目“约定优于配置”的精髓——把厨房规则写在布局里,而不是靠口头传承。

项目目录={dbt_project.yml, models/, snapshots/, tests/, macros/}\text{项目目录} = \{\text{dbt\_project.yml},\ \text{models/},\ \text{snapshots/},\ \text{tests/},\ \text{macros/}\}
dbt 项目的每个目录都有明确职责分工,就像厨房按功能分区。理解这个结构,你就掌握了 dbt 项目的“出厂布局”。
  1. dbt_project.yml项目配置文件,声明项目名、目标数仓、目录到 schema 的映射。
    配置={项目名称, profiles 映射, model 路径}\text{配置} = \{\text{项目名称},\ \text{profiles 映射},\ \text{model 路径}\}
  2. models 目录所有 SQL 模型的存放位置,dbt 自动递归读取其子目录。
    models/=⋃i模型i(.sql 文件)\text{models/} = \bigcup_{i} \text{模型}_i (\text{.sql 文件})
  3. snapshots 目录存放快照配置,记录维度表的变更历史。
    snapshots/={SCD Type 2 快照定义}\text{snapshots/} = \{\text{SCD Type 2 快照定义}\}
  4. tests 与 macros前者是质量测试用例,后者是可复用的 Jinja 宏代码。
    tests∪macros=质量保障∪逻辑复用\text{tests} \cup \text{macros} = \text{质量保障} \cup \text{逻辑复用}

SQL建模

后厨里每道菜都要有一张标准菜谱:主料、辅料、步骤、火候、出锅标准。dbt 的模型(model)就是数据菜谱,每张菜谱的核心只有一条 SELECT 语句——这就是 dbt 建模的第一性原则:你不需要写 CREATE TABLE,不需要写 INSERT/UPDATE,只需要用 SELECT 描述“这道菜长什么样”,dbt 会自动帮你完成建表和写入。

菜谱的写作推荐用 CTE(Common Table Expression)拆解步骤,就像菜谱写“第一步焯水,第二步爆香,第三步勾芡”。一段复杂的逻辑被拆成 with 清洗 as (...), 关联 as (...), 聚合 as (...),每一步都是一段独立的 SELECT,读起来一目了然。Jinja 模板则是菜谱里的“万能调料公式”:{% raw %}{{ ref('订单') }}{% endraw %} 引用其他模型,{% if is_incremental() %} 处理增量逻辑,{% raw %}{{ config(materialized='table') }}{% endraw %} 声明烹饪方式。

借助宏(macro),你还能把一段常用逻辑封装成“预制调料包”:比如统一的日期格式转换、统一的金额四舍五入规则,在多个模型里一行调用,避免每个菜谱都重复写一遍相同步骤。dbt 的 ref() 不只是简单的字符串引用,它同时建立依赖关系和自动处理环境切换——开发环境跑开发库的 stg_orders,生产环境自动切到生产库的同名模型,你不需要改一行代码。

把复杂逻辑拆成 CTE 的每一层,就像把一道大菜拆成多个可独立检验的步骤——某一步出错了,定位到那层 CTE 单独排查即可。这就是 dbt 建模方法论的核心:让复杂逻辑变得可读、可测、可复用。

Result=SELECT聚合(SELECT关联(SELECT清洗(源表)))\displaystyle \text{Result} = \text{SELECT}_{\text{聚合}}\left( \text{SELECT}_{\text{关联}}\left( \text{SELECT}_{\text{清洗}}(\text{源表}) \right) \right)
这条式子表示:模型构建像分层做菜,最内层是清洗源表,往外一层做关联,最外层做聚合汇总,每层一个 CTE,层层嵌套清晰可读。
  1. 清洗层(内层 SELECT)对源表做类型转换、去重、列重命名。
    清洗=σ条件(源表)\displaystyle \text{清洗} = \sigma_{\text{条件}}(\text{源表})
  2. 关联层(中层 SELECT)通过 JOIN 把多张表按业务键拼接。
    关联=订单⋈订单号客户\text{关联} = \text{订单} \bowtie_{\text{订单号}} \text{客户}
  3. 聚合层(外层 SELECT)按维度分组计算指标,如 SUM、COUNT、AVG。
    聚合=π维度, Σ(指标)(关联)\displaystyle \text{聚合} = \pi_{\text{维度},\ \Sigma(\text{指标})}(\text{关联})
  4. Result 产出最终结果以物化策略写入数仓,供下游模型或 BI 使用。
    Result=物化(聚合层结果)\text{Result} = \text{物化}(\text{聚合层结果})

物化策略

同一道红烧肉,可以提前批量烧好放保温柜里(客人来了直接盛);也可以等客人点了再下锅(新鲜热乎但要等);还可以只备好肉和调料,有人点才现烧。物化策略(materialization)就是“这道数据菜如何准备”的四种模式。

table 是“保温柜模式”:模型执行时全量重算一张物理表,查询直接读表、速度极快,代价是每次构建都要把整道菜从零做一遍,适合数据量适中(如百万行内)、查询频繁的中间表和结果表。view 是“现点现炒模式”:模型只保存一段 SQL,每次查询都实时计算,不占存储空间、永远新鲜,但大查询很慢,适合开发调试和底层轻量模型。incremental 是“常备菜补新模式”:首次运行建全表,之后每次只计算新增数据再合并进来,是大数据量模型的性能救星。ephemeral 是“内部配菜模式”:模型不落库,其他模型引它时其 SQL 被直接内联进去,适合只被复用一两次的中间逻辑。

\text{Incremental 策略} = O(\text{首跑全量}) + O(\Delta \text{每次})

T构建成本≈{O(全量数据),table / 首次运行O(Δ新增数据),incremental 后续运行O(不构建),view / ephemeral\displaystyle T_{\text{构建成本}} \approx \begin{cases} O(\text{全量数据}), & \text{table / 首次运行} \\[4pt] O(\Delta \text{新增数据}), & \text{incremental 后续运行} \\[4pt] O(\text{不构建}), & \text{view / ephemeral} \end{cases}
这是个定性的复杂度对比:table 与 incremental 首跑都要处理全量数据,代价高;incremental 后续只处理增量数据,代价低;view 和 ephemeral 根本不构建物理表,只保存逻辑或内联 SQL。
  1. table 全量重算每次 dbt run 都 DROP 后重建,成本随数据量线性增长。
    Table 策略=O(全量)\text{Table 策略} = O(\text{全量})
  2. view 实时计算不存储结果,把 SELECT 保存为视图,查询时即时执行。
    View 策略=O(查询时计算)\text{View 策略} = O(\text{查询时计算})
  3. incremental 增量合并首次建表耗时 O(全量),后续只计算新到数据耗时 O(Δ)。

依赖管理

G = (V,\ E),\quad E = \{(u, v) \mid v \text{ 中调用 } ref(u)\}

\text{执行顺序} = \text{topological\_sort}(G)

> 第一行:图 G 由节点集 V(所有模型)和边集 E(ref 依赖)构成;第二行:dbt 按图的拓扑排序确定执行顺序,保证每个模型跑之前它的上游已经就绪。1. 节点集 V:每个 dbt 模型对应图中的一个节点。$$\displaystyle V = \{\text{model}_1, \text{model}_2, \ldots, \text{model}_n\}$$2. 边集 E:模型 A 中写了 ref(B),就产生一条从 B 指向 A 的边。$$\displaystyle E = \{(B, A) \mid A \text{ 调用 } ref(B)\}$$3. 拓扑排序:把图中的节点排成一个线性序列,边的方向总指向序列后方。$$\displaystyle L = \text{topo\_sort}(G),\ \forall (u,v) \in E:\ u \text{ 排在 } v \text{ 前}$$4. 执行调度:dbt 按序列 L 依次执行模型,同层无依赖可并行。$$\text{run} = \text{exec}(L)$$有了依赖图这张"开工单",后厨就不会出现"菜做好了却发现没买菜"的尴尬。更重要的是,DAG 是 dbt 文档、测试、增量更新的共同地基——它让整个数据管道可以被整体理解、被智能调度。

增量更新

每天营业结束,后厨不会把没用完的食材全扔了重新采购——那太浪费了。更聪明的做法是:昨天备好的菜继续用,今天只需要补充新采购的食材、补做新增的份量。dbt 的增量更新(incremental)正是这套“只做增量”的思路。

实现增量更新的模型,需要在 config 中声明 materialized='incremental',在 SQL 中用 {% if is_incremental() %} 包裹过滤条件。首次运行时,is_incremental() 返回 False,dbt 执行全量计算建表;后续运行时返回 True,只对新增时间段的数据做处理。经典的写法是用时间戳过滤:WHERE created_at > (SELECT MAX(created_at) FROM 目标表)——即“只处理上次算完之后新来的数据”。

增量更新还要考虑去重和更新。如果源流水允许修改(比如订单状态从“待支付”变成“已支付”),单纯追加新行会导致老数据永远是旧的。此时需要配置 unique_key,让 dbt 在底层生成 MERGE 语句:匹配到相同主键就更新旧行,匹配不到就插入新行。但要注意:增量模型默认感知不到源表里被删除的记录——如果数据被物理删除,增量表里它还会“赖着不走”,这时需要额外的软删除标记或定期全量重建。

增量更新把“每天全量重算几亿行”变成了“每天只算几千行新增”,节省的时间是数量级的。它像后厨的常备菜制度:既有现成菜品的速度,又不会因为每天推倒重来而浪费食材——但前提是记得处理“改单”(更新)和“退货”(删除),否则账目就会失真。

Δ={r∈Source∣r.updated_at>tlast_run}\Delta = \{r \in \text{Source} \mid r.\text{updated\_at} > t_{\text{last\_run}}\}
Targetnew=Targetold∪merge on keytransform(Δ)\text{Target}_{\text{new}} = \text{Target}_{\text{old}} \cup_{\text{merge on } key} \text{transform}(\Delta)
第一条:增量数据 Δ 是所有更新时间戳大于上次运行时间的记录;第二条:新目标表等于旧数据通过主键 merge 合并上变换后的增量数据,实现"新数据插入、旧数据更新"。
  1. 增量条件过滤通过时间戳筛选出“上次跑完之后”的新记录集合 Δ。
    Δ={r∣r.updated_at>tlast_run}\displaystyle \Delta = \{r \mid r.\text{updated\_at} > t_{\text{last\_run}}\}
  2. 变换处理对 Δ 应用与全量相同的数据清洗、关联、聚合逻辑。
    stg_new=transform(Δ)\displaystyle \text{stg\_new} = \text{transform}(\Delta)
  3. 主键合并dbt 生成 MERGE 语句,按 unique_key 决定 INSERT 还是 UPDATE。
    Targetnew=MERGE(Targetold, stg_new; on key)\displaystyle \text{Target}_{\text{new}} = \text{MERGE}(\text{Target}_{\text{old}},\ \text{stg\_new};\ \text{on } key)
  4. 游标推进更新 last_run 时间戳,下次运行时基于新的边界继续增量。
    tlast_run←max⁡(r.updated_at)\displaystyle t_{\text{last\_run}} \gets \max(r.\text{updated\_at})

数据快照

食材价格每天在变,后厨想追溯“上周三的牛肉进价是多少”,如果只记当前价格就无从查起。dbt 的快照(snapshot)就是数据世界里的“进货台账”——它记录每条数据每一段历史时期的样子,让你能还原任意时间点的状态。

快照基于SCD Type 2(慢变维度类型二)原理:当源表中的一条记录发生变化时,快照表不会覆盖旧记录,而是把旧记录的 valid_to 设为变更时间(“关门”),同时插入一条新记录,其 valid_from 为变更时间(“开门”)、valid_to 为 NULL(表示当前有效)。这样一条业务记录的全部历史版本都躺在快照表里,任意时刻的状态通过时间区间就能查出来。

使用快照很简单:在 snapshots/ 目录建一个 .sql 文件,用 {% snapshot 快照名 %} 定义目标表、唯一键和检查变更的列。dbt 每次运行 dbt snapshot 命令时,会比较源表中指定列的值,发现变化就自动执行“关旧开新”。这个机制非常适合追踪商品价格变化、用户等级变化、订单状态流转等“维度的历史足迹”。

快照表就是数据的“时光机”:分析“调价前后销售额对比”、重建“用户上个月所处等级”这类问题,一条 SQL 按时间区间关联即可。它和增量更新是绝配——增量让大表跑得快,快照让历史查得回。

[r.\text{valid\_from},\ r.\text{valid\_to}),\quadr.\text{valid\_to} =\begin{cases}t_{\text{变更}}, & \text{该版本已被后续版本替换} \\\text{NULL}, & \text{该版本为当前有效版本}\end{cases}
每条快照记录都有一个左闭右开的有效期区间:valid_to 为空表示"现在还在生效";如果记录后来被修改了,旧版本的 valid_to 就是被替换的时刻。
  1. 快照记录结构每条记录额外携带 valid_from 和 valid_to 两个时间列。
    row={业务字段, valid_from, valid_to}\displaystyle \text{row} = \{\text{业务字段},\ \text{valid\_from},\ \text{valid\_to}\}
  2. 首次快照第一次运行时把源表全量写入,设置 valid_from 为当前时间。
    valid_from=now, valid_to=NULL\text{valid\_from} = \text{now},\ \text{valid\_to} = \text{NULL}
  3. 检测变更对比唯一键相同的记录,业务列取值是否发生变化。
    changed=(vnew≠vold)\displaystyle \text{changed} = (v_{\text{new}} \neq v_{\text{old}})
  4. 版本操作旧版本关闭(valid_to 置为 now),新版本开启(valid_from 为 now)。
    旧记录: valid_to←now;新记录:valid_from←now\text{旧记录}:\ valid\_to \gets \text{now};\quad \text{新记录}: valid\_from \gets \text{now}

最佳实践

一间米其林后厨能长期稳定出品,靠的绝不是某个厨师的个人手感,而是标准化的流程、严格的质检和成文的规范。dbt 的最佳实践,也是把这套“后厨管理哲学”落成制度。

分层建模是第一条铁律:staging 层只做轻量清洗和标准化(类型转换、列重命名、去重),intermediate 层实现核心业务逻辑(多表关联、指标预聚合),marts 层面向分析场景输出最终宽表。分层让每一层职责单一,下游报错时可以顺藤摸瓜快速定位。测试验证是第二道防线:not_null(主键不能为空)、unique(唯一值)、accepted_values(字段取值枚举)、relationships(外键关联完整性)四类内置测试覆盖了主要质量场景,还可以用 tests/ 目录自定义更复杂的 SQL 测试。文档生成让菜谱自动可读:dbt docs generate 生成全项目文档站,包含每个模型的字段说明、血缘关系图(lineage)和测试结果。CI 集成则是把 dbt test 放进代码合并的强制关卡——每次 Pull Request 都自动跑测试,问题代码根本进不了主分支。

这套最佳实践带给团队的收益是指数级的:新同事能快速接手任何模型,业务方能在文档站自助查指标口径,错误在进入生产前就被测试拦截。就像米其林后厨的“出品检查单”——每道菜出锅前都要过秤、摆盘、复核,稳定输出是靠制度而非运气。

P(数据可靠)=∏i=1n(1−错误率i)≈1−∑i=1n错误率i\displaystyle P(\text{数据可靠}) = \prod_{i=1}^{n} (1 - \text{错误率}_i) \approx 1 - \sum_{i=1}^{n} \text{错误率}_i
这是质量叠加效应:整条管道的可靠度是所有环节可靠度的乘积。每个环节 1% 的错误会让十环管道累计近 10% 的不可靠概率——所以每一层都要防。
  1. 每层测试兜底staging/marts 每层都配测试,错误率被逐层拦截。
    错误率i=P(模型i 产出的脏数据)\displaystyle \text{错误率}_i = P(\text{模型}_i \text{ 产出的脏数据})
  2. 串联乘积效应多环节可靠性相乘,单一环节失控会拖垮全局。
    P总=∏i=1nPi\displaystyle P_{\text{总}} = \prod_{i=1}^{n} P_i
  3. 小概率近似展开当各环节错误率都很低时,总错误率近似为各环节错误率之和。
    P总≈1−∑i=1nϵi,ϵi≪1\displaystyle P_{\text{总}} \approx 1 - \sum_{i=1}^{n} \epsilon_i,\quad \epsilon_i \ll 1
  4. 文档与 CI 护航文档让团队理解模型、CI 保证每次变更经过验证。
    治理=分层+测试+文档+CI\text{治理} = \text{分层} + \text{测试} + \text{文档} + \text{CI}

AI 应用场景

机器学习特征加工管道

阶段:数据准备与特征工程

解决的问题:特征工程最耗时的不是模型调参,而是把散落在各业务库的原始日志加工成模型可用的特征宽表。传统做法是写一堆临时 Python 脚本或存储过程,逻辑散落、无版本控制、依赖关系混乱,改一个特征要花半天排查。dbt 将特征加工拆成 staging → intermediate → marts 三层,每个特征一个模型,通过 ref() 声明依赖,天然形成可追溯的特征血缘图。

具体案例:某消费金融公司的风控团队用 dbt 构建贷前特征平台——用 incremental 模型每日增量聚合用户的交易流水特征(近 7 天/30 天消费频次、金额波动等),配合快照记录额度调整历史,最终产出 200+ 特征的宽表供 XGBoost 模型离线训练和在线打分。特征迭代只需新增一个 model 并跑 dbt run,半天即可上线新特征。

指标体系口径统一

阶段:BI 指标服务层

解决的问题:业务方问“用户数”和"GMV",数仓里可能有五套口径的 SQL 在跑,各说各话。dbt 的 marts 层把核心指标定义为唯一的模型,如 marts/gmv_daily.sql,所有下游统一引用这个模型,口径天然收敛。配合 dbt 测试做 uniqueness 和 not_null 校验,保证指标数据永远可信。

具体案例:一家电商公司用 dbt 重建了指标层后,把原先分散在 8 个报表里的 GMV 计算逻辑合并为 3 个 marts 模型,并在文档站生成指标口径说明。此后业务侧的日活、GMV、转化率三大指标全部引用同一数据源,跨部门对数的争论彻底消失。

Python 演示

python72 行
from datetime import datetime, timedelta  class MiniDBT:    """迷你版 dbt:演示分层转换与增量更新思想"""     def __init__(self):        self.raw_orders = []       # 源数据层:原始订单        self.cleaned_rows = []     # staging 层:清洗后记录        self.product_stats = {}    # marts 层:商品聚合统计        self.last_run = datetime(2024, 1, 1)     def generate_raw(self, days=20):        """模拟生成 days 天的原始订单数据,模拟源系统"""        products = ['咖啡', '奶茶', '蛋糕']        for i in range(days * 10):            day = datetime(2024, 1, 1) + timedelta(days=i % days)            self.raw_orders.append({                'order_date': day,                'product': products[i % 3],                'amount': 25.0 + (i * 3) % 20,                'status': 'paid' if i % 5 != 0 else 'cancelled'            })     def transform_row(self, row):        """staging 层:清洗单条记录(剔除取消单、格式化金额)"""        if row['status'] != 'paid':            return None        return {            'order_date': row['order_date'],            'product': row['product'].strip(),            'amount': round(float(row['amount']), 2)        }     def run_incremental(self):        """增量更新:只处理 last_run 之后的新订单,模拟 is_incremental()"""        new_orders = [            row for row in self.raw_orders            if row['order_date'] > self.last_run        ]        added = 0        for order in new_orders:            clean = self.transform_row(order)            if clean is None:                continue            self.cleaned_rows.append(clean)            key = clean['product']            if key not in self.product_stats:                self.product_stats[key] = {'orders': 0, 'revenue': 0.0}            self.product_stats[key]['orders'] += 1            self.product_stats[key]['revenue'] += clean['amount']            added += 1        if new_orders:            self.last_run = max(row['order_date'] for row in new_orders)        return added     def generate_report(self):        """marts 层:输出最终聚合报表"""        print('商品  |  订单数  |  总营收')        print('-' * 30)        for product, stat in self.product_stats.items():            print(f"{product} | {stat['orders']} 笔 | ¥{stat['revenue']:.2f}")  if __name__ == '__main__':    dbt = MiniDBT()    dbt.generate_raw()    first = dbt.run_incremental()    print(f'首轮增量处理: {first} 笔订单')    second = dbt.run_incremental()    print(f'第二轮增量处理: {second} 笔订单(无新数据)')    dbt.generate_report()
目的:用 50 行 Python 模拟 dbt 的两大核心概念——分层转换(raw → staging → marts)和增量更新(仅处理时间戳之后的新数据),读完即理解 dbt 的运行本质。
  1. self.raw_orders = []模拟“源数据层”,存放从业务系统同步来的原始订单。
  2. def generate_raw(self, days=20)模拟源系统每天产生 10 条新订单,含日期、商品、金额、状态字段。
  3. def transform_row(self, row)staging 层逻辑:过滤掉 cancelled 的订单、去除商品名空格、金额四舍五入,返回 None 表示该行被清洗剔除。
  4. if row['order_date'] > self.last_run增量更新的核心:只取上次运行之后的新订单,等价于 dbt 中 is_incremental() 配合时间戳过滤。
  5. self.last_run = max(...)推进游标:每次跑完把 last_run 更新为最新订单时间,下一次增量以此为界,与 dbt 中 MAX(updated_at) 异曲同工。
  6. self.product_stats[key]['orders'] += 1marts 层聚合:按商品维度累计订单数和营收,对应 SQL 中的 GROUP BY 加 SUM/COUNT。

常见误区

误区dbt 跟 DataStage、Informatica 一样,负责把数据从业务库抽取出来,清洗后再加载到数据仓库——它是一个 ETL 工具。

正解dbt 是 ELT 工具,它明确不负责抽取和加载。数据先由 Fivetran/Airbyte 等工具搬到数仓(Load),dbt 只做数仓内部的转换(Transform 的 T)。它不移动数据、不连接生产库,只执行 SQL 把数仓里的表变成新的表/视图。用厨房比喻说:搬食材的库管(EL)和炒菜的厨师(dbt)是两个人,dbt 只在厨房里干活。

为何常见:传统数仓时代 ETL 工具大包大揽,人们习惯用一个工具走完整个流程;而 dbt 诞生于云数仓时代,计算能力集中在仓库内,T 单独拆出来更灵活。

误区既然模型最终要产出物理表,那我得写 CREATE TABLE、INSERT INTO 这些完整 SQL 吧?存储过程怎么写我就怎么写。

正解dbt 模型的核心只需要一条 SELECT。dbt 根据你配置的物化策略,自动生成 CREATE TABLE AS、CREATE VIEW AS 或 MERGE 语句并执行。你只管描述“结果长什么样”,不用关心“怎么建出来的”——就像点菜只管说“要红烧肉”,不用管厨师怎么烧。

为何常见:长期写存储过程和手写 DDL 的思维惯性,让人难以相信“一条 SELECT 就够了”。这正是 dbt 的设计哲学:声明式建模,而非命令式。

误区view 每次查询都要现算,性能肯定差,所以所有模型都应该用 table,一劳永逸。

正解table 有代价——每次 dbt run 都要全量重算,数据量大时非常耗时。view 不占存储、永远查的是最新数据且构建成本为零,非常适合开发调试期和底层小表。incremental 则是大表场景的折中。物化策略没有绝对好坏,只有“当前场景合不合适”。

为何常见:性能至上思维让人只盯着“查询快不快”,忽略了“构建贵不贵”和“数据鲜不鲜”的权衡。

误区用了 incremental 后,源表里被删除的记录应该也会从目标表消失,因为它是“同步”的。

正解incremental 的本质是“新增和更新”,默认不感知删除。源表删除的行会永远残留在目标表中,除非你配置软删除列并处理,或者定期做全量重建。它更像“每天补新货”而不是“每日盘点对账”。

为何常见:人们容易把批处理增量误当成实时同步,忽略了它只在新增集合 Δ 上操作的事实。

误区快照就是给表做个副本存起来,防止数据被误删了可以恢复。

正解dbt snapshot 是 SCD Type 2 慢变维度实现,它的目标是追踪维度属性(如商品价格、用户等级)的历史变化过程,让你能回答“3 月 5 日这个商品卖多少钱”这类时间旅行问题。它不是灾备工具,而是一种数据建模策略。

为何常见:“快照”这个中文词天然让人联想到数据库存储备份,而忽略了 dbt 语境下它指的是“历史版本管理”。

面试题

Q1. dbt 在 ELT 架构中承担哪一环?

单选题 · easy

  • A.从业务库抽取数据
  • B.将数据加载进数仓
  • C.数仓内做数据转换← 正确答案
  • D.对结果做报表展示

答案:C。dbt 全称 data build tool,定位是 ELT 中的"T"——数据已经在数仓里,dbt 通过执行 SQL 完成转换。抽取(E)和加载(L)由 Fivetran/Airbyte 等工具承担。就像行政总厨只管做菜,买菜和搬货是别人的事。

Q2. dbt 模型文件的核心内容是什么?

单选题 · easy

  • A.一段 INSERT 语句
  • B.一条 SELECT 查询← 正确答案
  • C.整段 CREATE TABLE
  • D.一组 Python 脚本

答案:B。dbt 模型的本质是“一条 SELECT + 头部配置”。dbt 根据物化策略自动生成建表和写库语句,模型作者只负责描述结果。选 C 是传统 ETL 思维——写菜谱的人不需要亲自烧火。

Q3. 下列哪些属于 dbt 支持的物化策略?

多选题 · mid

  • A.table 实体表策略← 正确答案
  • B.view 视图策略← 正确答案
  • C.index 索引策略
  • D.incremental 增量策略← 正确答案

答案:ABD。dbt 提供四种物化策略:table(物理表)、view(视图)、incremental(增量表)、ephemeral(临时内联)。index 是数据库索引概念,不属于 dbt 物化策略——索引是数据库的查询加速手段,而物化策略回答的是“模型结果以什么形态存储”。

Q4. ref() 函数的核心作用是什么?

单选题 · mid

  • A.刷新数据结果缓存
  • B.声明模型间的依赖← 正确答案
  • C.直接引用外部数据库
  • D.删除掉过期历史数据

答案:B。ref('模型名') 声明当前模型依赖另一个 dbt 模型,同时自动处理环境切换。dbt 基于所有 ref() 构建 DAG,按拓扑序执行。这不是缓存刷新也不是删数操作——它是“菜谱之间的引用”,驱动后厨确定开工顺序。

Q5. 以下哪种场景最适合用 view 物化策略?

单选题 · mid

  • A.数据量极大且查询频繁
  • B.开发期频繁调整逻辑← 正确答案
  • C.需要增量更新的历史大表
  • D.跨库同步大批量数据

答案:B。view 不占存储、构建零成本、永远实时,非常适合开发调试期频繁改 SQL 的场景——改完 dbt run 立即生效,不用等全量重算。数据量大且查询频繁适合 incremental,历史大表适合增量更新,跨库同步不是 dbt 的职责。

Q6. 关于 dbt 增量更新的说法,正确的有哪些?

多选题 · mid

  • A.通过时间戳筛选新增数据← 正确答案
  • B.配置 unique_key 支持更新← 正确答案
  • C.每次全量重建结果表
  • D.自动感知源表删除记录

答案:AB。增量更新通过 is_incremental() 配合时间戳过滤只算新增数据;unique_key 让 dbt 生成 MERGE 实现“有则更新、无则插入”。它不会全量重建,也默认感知不到源表删除——想处理删除需要软删除标记或定期全量重建。

Q7. incremental 物化策略在底层执行时使用什么操作?

单选题 · hard

  • A.直接插入全部的数据
  • B.MERGE 增量合并更新← 正确答案
  • C.TRUNCATE 后全部重建
  • D.重建视图方式来刷新

答案:B。dbt 的 incremental 策略在配置 unique_key 后,底层生成 MERGE 语句(部分数仓为 INSERT OVERWRITE):相同主键更新、新主键插入。TRUNCATE+重建是 table 策略的行为,直接插入全部数据则失去了增量的意义。

Q8. dbt 快照(snapshot)实现的是哪种维度建模策略?

单选题 · hard

  • A.SCD Type 1 覆盖更新
  • B.SCD Type 2 保留历史← 正确答案
  • C.SCD Type 3 存两版本
  • D.SCD Type 4 拆分历史表

答案:B。dbt snapshot 基于 SCD Type 2:记录变更时旧记录关闭 valid_to、新记录开启新版本,完整保留所有历史状态。Type 1 是直接覆盖不保留历史,Type 3 只保留“当前+上一次”两版,Type 4 是历史与当前分表——都不是 dbt 快照的行为。

Q9. 采用 dbt 分层建模带来的优势有哪些?

多选题 · hard

  • A.各层职责清晰可查← 正确答案
  • B.减少重复计算逻辑← 正确答案
  • C.完全消除数据冗余
  • D.支持按层独立重跑← 正确答案

答案:ABD。staging→intermediate→marts 分层让每层职责单一、问题定位快;公共逻辑下沉到 intermediate 层避免每个 marts 模型重复计算;某层出错只需重跑该层及下游。但它无法“完全消除”冗余——刻意保留的中间层本质就是一种冗余,用空间和存储换可维护性。

Q10. dbt 按什么顺序执行有依赖关系的模型?

单选题 · easy

  • A.按模型文件名排序
  • B.按模型创建时间排序
  • C.按依赖 DAG 拓扑序← 正确答案
  • D.随机顺序并行执行

答案:C。dbt 通过扫描所有 ref() 调用构建有向无环图(DAG),然后做拓扑排序得到执行序列,保证被依赖的模型先运行。文件名、创建时间都与执行顺序无关;“随机并行”更不可能——那会让上游还没算完下游就开跑。