
















dbt macro(Jinja 宏)本质上是写在 dbt 项目里的 Jinja 函数,用来生成可复用的 SQL 片段。它解决的主要问题不是"让 SQL 看起来更高级",而是把重复出现的 SQL 逻辑抽出来,放到一个统一的位置维护。
本文将依次介绍 macro 是什么、为什么要用、在 Data Vault 项目中的实战示例,以及使用时的最佳实践与边界。
macro 是用 Jinja 写的可复用代码片段,通常放在 dbt 项目的 macros/ 目录下。它可以像函数一样被模型、测试、其他 macro 调用。
我们可以把 macro 理解成 dbt 里的"SQL 生成函数":
一个简单示例:
-- macros/calculate_average.sql
{% macro calculate_average(column_name) %}
AVG({{ column_name }})
{% endmacro %}
在模型中使用:
-- models/my_model.sql
SELECT
{{ calculate_average('price') }} AS avg_price,
category
FROM {{ ref('products') }}
GROUP BY category
这里的关键点是:模型里不直接写 AVG(price),而是调用 calculate_average('price')。这样做的意义在于,如果平均值计算逻辑以后发生变化,只需要改 macro。
如果一个 dbt 项目里有很多模型都在做类似的过滤、聚合、校验、join 或动态 SQL 拼接,那么 macro 就很适合介入。它的价值主要体现在以下几个方面。
很多数据转换项目会反复写相同逻辑,例如过滤无效记录、处理状态字段、生成标准化日期字段、做特定聚合。macro 可以把这些逻辑抽出来,避免每个模型里都复制一份。
这里有一个常用的判断标准——三次法则(Rule of Three):如果同一段 SQL 被复制了三次,就应该考虑它是不是适合变成 macro。
macro 不只是省代码,还能降低规则不一致的风险。比如"有效订单"的判断条件,如果分散在十几个模型里,很容易有的模型漏掉条件,或者有人改了一个地方但忘了改其他地方。
把这类规则放进 macro 后,所有模型调用同一份逻辑,团队更容易保持一致。
复杂 SQL 经常难读,不是因为 SQL 本身写错了,而是因为一个模型里同时塞进了太多细节。macro 可以把细节藏到有名字的函数里,让模型文件更像是在表达业务意图。
例如模型里看到:
{{ filter_by_status(var('order_status', 'completed')) }}
比直接读一长段动态 WHERE 条件更容易理解它的目的。
macro 可以根据参数、变量或配置生成不同 SQL。这一点很适合处理环境差异、字段差异、不同表之间的重复 join 模式。
例如根据变量生成过滤条件:
-- macros/filter_by_status.sql
{% macro filter_by_status(status) %}
WHERE status = '{{ status }}'
{% endmacro %}
在模型里调用:
-- models/orders.sql
SELECT *
FROM {{ ref('orders') }}
{{ filter_by_status(var('order_status', 'completed')) }}
这里 var('order_status', 'completed') 的意思是:读取变量 order_status,如果没有传入,就默认使用 completed。
注意:上面的示例假设
status是项目内部可信的变量值。如果参数来自外部不可信输入,需要对值进行转义或使用参数化查询,避免 SQL 注入风险。
macro 不只用于模型,也可以用于 dbt tests。对于重复的数据质量校验逻辑,可以写成 macro,让多个测试复用同一套规则。
这对团队项目很有价值,因为数据质量规则往往比普通 SQL 更需要一致性。
Data Vault 项目是一个很适合使用 dbt macro 的场景,因为 hashkey 和 hashdiff 的计算逻辑通常会在多个 Hub、Link、Satellite 模型中反复出现,而且这些逻辑必须保持一致。
在 Data Vault 建模里,可以这样理解:
hashkey 通常用于根据业务键生成稳定的代理键,例如客户编号、订单编号、产品编号。hashdiff 通常用于根据描述性字段生成哈希值,用来判断 Satellite 中一行数据的属性是否发生变化。如果每个模型都手写一遍 concat、coalesce、upper、ltrim、rtrim、hashbytes 之类的逻辑,很容易出现字段顺序不一致、空值处理不一致、大小写处理不一致的问题。把它们抽成 macro 后,可以把哈希规则集中维护。
在 SQL Server 环境里,不能直接使用 md5() 或 cast(... as string)(其他数据库如 PostgreSQL / BigQuery 可以直接用)。一个简化版的 hashkey macro 可以这样写:
-- macros/generate_hashkey.sql
{% macro generate_hashkey(columns) %}
convert(
varchar(32),
hashbytes(
'MD5',
concat(
{%- for column in columns -%}
coalesce(upper(ltrim(rtrim(cast({{ column }} as varchar(8000))))), '')
{%- if not loop.last -%}, '||', {%- endif -%}
{%- endfor -%},
''
)
),
2
)
{% endmacro %}
这段 macro 里有几个 SQL Server 相关点:
hashbytes('MD5', ...) 用来计算 MD5 哈希。convert(varchar(32), ..., 2) 把 varbinary 结果转成 32 位十六进制字符串。cast(... as varchar(8000)) 替代其他数据库里的 cast(... as string)。ltrim(rtrim(...)) 比 trim(...) 更兼容旧版本 SQL Server。'||' 分隔,降低不同字段组合后产生歧义的概率。concat(..., '') 最后补一个空字符串,是因为 SQL Server 的 concat 函数至少需要两个参数,这样单字段 hashkey 也能正常运行。如果项目使用 SQL Server 2016 及以上版本,也可以根据字段长度和实际数据量,把 varchar(8000) 调整为 varchar(max)。在 Data Vault 项目里,更重要的是所有 Hub、Link、Satellite 使用同一套标准化规则。
在 Hub 模型中使用:
-- models/hub_customer.sql
SELECT
{{ generate_hashkey(["customer_id"]) }} AS customer_hk,
customer_id AS customer_bk,
current_timestamp AS load_datetime,
'crm' AS record_source
FROM {{ ref('stg_customer') }}
如果业务键由多个字段组成,也可以传入多个字段:
{{ generate_hashkey(["country_code", "customer_id"]) }} AS customer_hk
hashdiff 的 macro 可以使用类似思路,只是它通常面向 Satellite 的描述性字段:
-- macros/generate_hashdiff.sql
{% macro generate_hashdiff(columns) %}
convert(
varchar(32),
hashbytes(
'MD5',
concat(
{%- for column in columns -%}
coalesce(upper(ltrim(rtrim(cast({{ column }} as varchar(8000))))), '')
{%- if not loop.last -%}, '||', {%- endif -%}
{%- endfor -%},
''
)
),
2
)
{% endmacro %}
在 Satellite 模型中使用:
-- models/sat_customer_details.sql
SELECT
{{ generate_hashkey(["customer_id"]) }} AS customer_hk,
{{ generate_hashdiff([
"customer_name",
"email",
"phone",
"customer_status"
]) }} AS customer_hashdiff,
customer_name,
email,
phone,
customer_status,
current_timestamp AS load_datetime,
'crm' AS record_source
FROM {{ ref('stg_customer') }}
这个例子对于理解 macro 很有帮助,因为它展示了 macro 的真正价值:不是为了少写几行 SQL,而是为了保证一套关键规则在整个 Data Vault 项目里始终一致。
生产项目中还可以继续优化,例如把 MD5 改成 SHA2_256,或者把 hashkey 和 hashdiff 合并成一个更通用的 generate_hash macro。但无论怎么封装,都要保证字段顺序、空值替代、大小写、去空格和分隔符规则稳定不变。
一个 SHA2_256 版本的核心写法如下:
convert(
varchar(64),
hashbytes('SHA2_256', concat(...)),
2
)
相比 MD5,SHA2_256 的哈希结果更长,转成十六进制字符串后通常是 64 位。
如果在 dbt 项目中使用 macro,可以从以下场景开始判断:
| 场景 | 是否适合 macro | 原因 |
|---|---|---|
| 多个模型都要过滤无效订单 | 适合 | 规则重复,且需要统一 |
| 多个模型都要计算同一个业务指标 | 适合 | 指标口径应集中维护 |
| 只有一个模型里使用的一段 SQL | 不一定 | 抽出来可能增加理解成本 |
| 每个表 join 逻辑类似,只是表名和 key 不同 | 适合 | 参数化后能减少重复 |
| SQL 很复杂但只出现一次 | 谨慎 | macro 可能隐藏复杂度 |
先从小而稳定的重复逻辑开始抽 macro,不要一上来就把大段模型全部模板化。
一个 macro 最好只做一件清楚的事。比如 calculate_average 只负责生成平均值表达式,filter_by_status 只负责生成状态过滤条件。
如果一个 macro 同时负责过滤、聚合、join、字段重命名,后续会很难测试和复用。
macro 的名字应该直接表达它会生成什么逻辑。好的名字能让模型文件更容易读。
例如:
filter_by_status 比 status_macro 更清楚calculate_average 比 calc 更清楚join_tables 虽然直观,但在真实项目里可能还需要更具体,例如 join_orders_to_customersmacro 可能接收空值、意外参数、不存在的字段名,或者在不同数据库方言下表现不同。处理好边界情况,对生产项目很重要。
需要特别留意:
macro 的目标是降低重复和维护成本,而不是追求抽象本身。过度抽象会让 SQL 变得难以追踪,尤其是当模型里只剩下一堆 macro 调用时,读者必须不断跳转到 macros/ 目录才能理解真实逻辑。
可以用两个问题判断是否应该写 macro:
如果两个答案都是"是",macro 通常值得写。如果只是为了让代码更短,但逻辑并不复用,那可能不值得。
dbt macro 是 dbt 项目中的可复用 SQL 生成工具,适合抽象重复、稳定、需要统一维护的逻辑。用得好可以让项目更一致、更容易维护,用得过度则可能把复杂度藏起来。在 Data Vault 这类对哈希规则一致性要求很高的场景中,macro 尤其能发挥价值。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。