Featured image of post DuckDB 宏(Macros)实战:把重复 SQL 封装成函数,效率翻 10 倍

DuckDB 宏(Macros)实战:把重复 SQL 封装成函数,效率翻 10 倍

DuckDB 宏(Macros)让你把重复的 SQL 逻辑封装成可复用的函数,统一指标口径、参数化表名、嵌套清洗流水线。本文 6 个实战场景,从入门到进阶全覆盖。

DuckDB 宏(Macros)实战:把重复 SQL 封装成函数,效率翻 10 倍

进阶技巧 | 适合:经常写重复 SQL 的数据分析师和工程师


痛点:你还在复制粘贴那 20 行 SQL 吗?

做数据分析的兄弟姐妹们,扪心自问一下:你是不是经常把一段长达 20 行的 SQL 从旧脚本里捞出来,改个表名,再塞进新查询里?然后某天业务方说"口径要改一下",你对着 15 处拷贝出来的 SQL 逐个修改,改到怀疑人生。

别慌,你不是一个人。在 SQL 的世界里,我们一直在重复造轮子。DuckDB 的 宏(Macros) 功能,就是来终结这种"复制-粘贴-改"的噩梦的。它允许你把一段复杂的 SQL 逻辑封装成一个函数,之后像调用内置函数一样调用它。

今天,我们不讲理论,直接上 6 个实战场景,看看宏如何帮你从"SQL 搬运工"进化为"SQL 架构师"。

DuckDB 宏功能架构图


场景 1:统一指标口径 —— 告别"不同人算出的 GMV 不一样"

痛点:运营部和财务部对"GMV"的定义不同(是否含税?是否含退款?),导致每日对账扯皮。你需要在所有报表中强制执行统一的计算逻辑。

代码:我们定义一个宏来计算"有效 GMV",并强制过滤掉测试订单和退款。

import duckdb

# 创建示例数据
conn = duckdb.connect()
conn.execute("""
    CREATE TABLE orders AS
    SELECT * FROM (VALUES
        (1, 'A', 100.0, 0, '2023-10-01'),
        (2, 'B', 200.0, 1, '2023-10-01'),  -- 测试单
        (3, 'A', 300.0, 0, '2023-10-02'),
        (4, 'C', 400.0, 1, '2023-10-02'),  -- 退款单
        (5, 'B', 500.0, 0, '2023-10-03')
    ) AS t(order_id, seller, amount, is_refund, order_date)
""")

# 定义宏:计算有效 GMV
conn.execute("""
    CREATE OR REPLACE MACRO effective_gmv(amount, is_refund) AS
        CASE 
            WHEN is_refund = 0 THEN amount 
            ELSE 0 
        END;
""")

# 使用宏进行每日汇总
result = conn.execute("""
    SELECT 
        order_date,
        SUM(effective_gmv(amount, is_refund)) AS daily_gmv
    FROM orders
    GROUP BY order_date
    ORDER BY order_date
""").fetchdf()

print(result)

输出

  order_date  daily_gmv
0 2023-10-01      100.0
1 2023-10-02      300.0
2 2023-10-03      500.0

💡 关键点:宏在定义时就把业务口径固化下来了。以后任何人写查询,只要调用 effective_gmv(),就不可能算错。口径变更时,只需改宏定义,所有调用处自动生效。


场景 2:表名参数化 —— 动态处理每日分区表

痛点:你的数据是按天分区的,表名形如 sales_20231001。每天跑报表都要手动拼接 SQL 字符串,极易出错且无法利用 DuckDB 的查询优化。

代码:DuckDB 宏支持表名作为参数,用 TABLE 关键字声明。

import duckdb

conn = duckdb.connect()

# 创建两张模拟的日分区表
for date in ['20231001', '20231002']:
    conn.execute(f"""
        CREATE TABLE sales_{date} AS
        SELECT * FROM (VALUES
            ('Product_A', 100 + {date[-2:]}),
            ('Product_B', 200 + {date[-2:]})
        ) AS t(product, revenue)
    """)

# 定义宏:接受表名作为参数
conn.execute("""
    CREATE OR REPLACE MACRO get_daily_sales(tbl TABLE) AS TABLE
        SELECT product, SUM(revenue) AS total_revenue
        FROM tbl
        GROUP BY product;
""")

# 动态查询不同日期的表
for date in ['20231001', '20231002']:
    result = conn.execute(f"""
        SELECT * FROM get_daily_sales(sales_{date})
        ORDER BY product
    """).fetchdf()
    print(f"--- {date} ---")
    print(result)

输出

--- 20231001 ---
   product  total_revenue
0  Product_A            101
1  Product_B            201
--- 20231002 ---
   product  total_revenue
0  Product_A            102
1  Product_B            202

💡 关键点:宏支持 TABLE 参数类型,意味着你可以将整个表作为输入,宏内部进行复杂加工后返回结果集。这使得宏变成了一个真正的"函数式"查询块。


场景 3:嵌套宏 —— 构建复杂的数据清洗流水线

痛点:数据清洗步骤繁多:去空格、统一日期格式、标准化状态码。每次都写一串嵌套的 CASE WHENREGEXP_REPLACE,既难看又难维护。

代码:宏内部可以调用另一个宏,实现模块化清洗。

import duckdb

conn = duckdb.connect()
conn.execute("""
    CREATE TABLE raw_data AS
    SELECT * FROM (VALUES
        ('  Active  ', '2023/10/01', 'NY'),
        ('inactive', '10-02-2023', 'ca'),
        ('PENDING', '2023.10.03', 'TX')
    ) AS t(status, date_str, state)
""")

# 宏1:标准化状态码
conn.execute("""
    CREATE OR REPLACE MACRO clean_status(s) AS
        CASE
            WHEN LOWER(TRIM(s)) IN ('active', 'act') THEN 'ACTIVE'
            WHEN LOWER(TRIM(s)) IN ('inactive', 'inact') THEN 'INACTIVE'
            ELSE UPPER(TRIM(s))
        END;
""")

# 宏2:标准化日期(兼容多种分隔符)
conn.execute("""
    CREATE OR REPLACE MACRO clean_date(d) AS
        strptime(REGEXP_REPLACE(d, '[./]', '-'), '%Y-%m-%d');
""")

# 执行清洗
result = conn.execute("""
    SELECT 
        clean_status(status) AS clean_status,
        clean_date(date_str) AS clean_date,
        UPPER(state) AS state
    FROM raw_data
""").fetchdf()

print(result)

输出

  clean_status  clean_date state
0      ACTIVE  2023-10-01    NY
1    INACTIVE  2023-10-02    CA
2     PENDING  2023-10-03    TX

💡 关键点:嵌套宏让复杂逻辑被拆解成可单独测试的小单元。你可以单独测试 clean_status(),确认无误后再组合成清洗流水线。


场景 4:宏 + JSON 处理 —— 一行搞定复杂解析

痛点:你的数据里有个 JSON 数组字段,包含了用户的多个行为事件。你需要对数组进行过滤、变换和聚合,每次写 UNNEST + FILTER 的组合非常繁琐。

代码:宏可以封装 JSON 数组处理逻辑。

import duckdb

conn = duckdb.connect()
conn.execute("""
    CREATE TABLE user_events AS
    SELECT * FROM (VALUES
        (1, '[{"type":"view","value":5},{"type":"click","value":3}]'),
        (2, '[{"type":"click","value":2},{"type":"purchase","value":100}]'),
        (3, '[{"type":"view","value":1}]')
    ) AS t(user_id, events_json)
""")

# 定义宏:提取特定类型事件的 value 总和
conn.execute("""
    CREATE OR REPLACE MACRO sum_event_value(events_json, event_type) AS
        (
            SELECT COALESCE(SUM(e.value), 0)
            FROM json_each(events_json) AS je
            CROSS JOIN LATERAL (
                SELECT json_extract_string(je.value, '$.type') AS type,
                       json_extract_int(je.value, '$.value') AS value
            ) AS e
            WHERE e.type = event_type
        );
""")

# 查询每个用户的点击总价值
result = conn.execute("""
    SELECT 
        user_id,
        sum_event_value(events_json, 'click') AS total_click_value,
        sum_event_value(events_json, 'view') AS total_view_value
    FROM user_events
    ORDER BY user_id
""").fetchdf()

print(result)

输出

   user_id  total_click_value  total_view_value
0        1                  3                 5
1        2                  2                 0
2        3                  0                 1

💡 关键点:宏的参数可以是任意表达式,包括字符串。它把复杂的 JSON 解析逻辑封装成黑盒,你的主查询变得极其简洁。


场景 5:宏返回多列 —— 一键生成衍生特征

痛点:在特征工程中,你经常需要根据原始字段计算多个衍生字段(如 RFM 分析中的 Recency、Frequency、Monetary)。每次都要写多个 CASE WHEN 并重复引用原始列。

代码:宏可以直接返回一个表结构(多列),通过 TABLE 关键字实现。

import duckdb

conn = duckdb.connect()
conn.execute("""
    CREATE TABLE customers AS
    SELECT * FROM (VALUES
        (1, 5, 300.0),
        (2, 10, 1500.0),
        (3, 2, 80.0)
    ) AS t(cust_id, order_count, total_spent)
""")

# 定义宏:根据消费行为返回客户等级和标签(多列)
conn.execute("""
    CREATE OR REPLACE MACRO customer_segment(count, spent) AS TABLE
    SELECT 
        CASE 
            WHEN spent >= 1000 THEN 'VIP'
            WHEN spent >= 100 THEN 'Standard'
            ELSE 'New'
        END AS tier,
        CASE 
            WHEN count >= 10 THEN 'Frequent'
            ELSE 'Occasional'
        END AS frequency_label
    WHERE 1=1;
""")

# 使用宏并展开多列
result = conn.execute("""
    SELECT 
        cust_id,
        cs.tier,
        cs.frequency_label
    FROM customers,
    LATERAL customer_segment(order_count, total_spent) AS cs
    ORDER BY cust_id
""").fetchdf()

print(result)

输出

   cust_id      tier frequency_label
0        1  Standard      Occasional
1        2       VIP       Frequent
2        3       New      Occasional

💡 关键点:返回 TABLE 的宏可以像关系表一样被 JOINLATERAL 引用。这让特征工程代码变得模块化,新增特征只需扩展宏即可。


场景 6:宏作为代码生成器 —— 批量生成周报 SQL

痛点:每周一你要生成一份包含 20 个不同维度指标的报告。手动编写会漏掉指标或写错公式,而且格式千篇一律。

代码:利用宏 + Python 动态生成并执行 SQL。

import duckdb

conn = duckdb.connect()
conn.execute("""
    CREATE TABLE sales AS
    SELECT * FROM (VALUES
        ('2023-10-01', 'North', 'Electronics', 1000.0),
        ('2023-10-01', 'South', 'Clothing', 500.0),
        ('2023-10-02', 'North', 'Electronics', 1500.0),
        ('2023-10-02', 'South', 'Clothing', 800.0)
    ) AS t(sale_date, region, category, amount)
""")

# 定义宏:计算某指标占总销售额比例
conn.execute("""
    CREATE OR REPLACE MACRO pct_of_total(part, total) AS
        CASE 
            WHEN total > 0 THEN ROUND(100.0 * part / total, 2)
            ELSE 0 
        END;
""")

# 动态生成多维度统计 SQL
dimensions = ['region', 'category']
base_query = """
    SELECT 
        '{dim}' AS dimension_type,
        {dim} AS dimension_value,
        SUM(amount) AS total_amount,
        pct_of_total(SUM(amount), (SELECT SUM(amount) FROM sales)) AS pct_total
    FROM sales
    GROUP BY {dim}
"""

# 拼接所有维度查询并执行
all_queries = " UNION ALL ".join([
    base_query.format(dim=d) for d in dimensions
])

result = conn.execute(all_queries).fetchdf()
print(result)

输出

  dimension_type dimension_value  total_amount  pct_total
0         region           North        2500.0      65.79
1         region           South        1300.0      34.21
2       category     Electronics        2500.0      65.79
3       category        Clothing        1300.0      34.21

💡 关键点:宏在此场景中扮演了"公共函数库"的角色。你可以用 Python 循环动态生成 SQL,而宏确保每个查询中的计算逻辑保持一致。


对比表:宏 vs 传统方式

场景传统方式宏方式优势
统一指标口径每处 SQL 重复写 CASE WHEN定义一次宏,全局调用口径一致,修改只需一处
动态表名字符串拼接 SQLTABLE 参数宏类型安全,优化器可介入
数据清洗嵌套 CASE WHEN + REGEXP多个小宏组合可测试,可复用
JSON 解析每次写 UNNEST + LATERAL封装成宏主查询简洁
特征工程重复 CASE WHEN 写多列TABLE 宏返回多列模块化扩展
批量报告手动复制 20 个查询宏 + 循环生成零遗漏,零错误

🔥 避坑指南(5 条)

  1. 宏不是性能优化工具:宏在解析时会被展开成底层 SQL,不会加速查询。它提升的是开发效率可维护性,性能优化请用索引或物化视图。

  2. 注意作用域与命名冲突:宏内引用的列名如果与外层查询冲突,可能导致意外结果。建议宏内部使用 tbl. 前缀或使用 AS 别名明确区分。

  3. 宏内不能直接引用 Python 变量:如果需要在宏中使用 Python 侧动态值,请用 conn.execute 进行字符串格式化,但要注意 SQL 注入风险,使用参数化查询更安全。

  4. 不要过度嵌套宏:超过 3 层的宏嵌套会让调试变得困难。当宏逻辑过于复杂时,考虑将其拆分为多个简单宏或改用 Python 函数处理。

  5. 宏不支持窗口函数作为参数:如果试图将 ROW_NUMBER() 等窗口函数直接传给宏,可能会报错。请先在子查询中计算好结果,再传递给宏。


🎯 核心心法总结

宏的本质是 “查询即代码” —— 把重复的 SQL 模式提升为一等公民。

  • 一致性:业务口径在宏中定义一次,全局复用,杜绝"各算各的"。
  • 可测试性:每个宏都是独立单元,可以单独验证,组合时信心倍增。
  • 可组合性:宏可以嵌套、接受表参数、返回多列,构建出强大的 SQL 函数库。

当你发现自己第三次复制同一段 SQL 时,停下来,把它封装成宏。你的代码库会感谢你,你的同事会感谢你,未来的你更会感谢你。


💰 变现建议

学会 DuckDB 宏之后,你可以将这些技能转化为实际收入:

  1. 企业咨询:帮企业统一数据口径,建立宏库。收费标准:¥5,000-15,000/项目。一个中型企业通常有 10-30 个核心指标需要统一。

  2. 数据产品模板:将常用的宏(如 GMV 计算、用户分层、RFM 分析)打包成可复用的模板,在 Gumroad 或爱发电出售。定价:¥99-299/套。

  3. 培训课程:制作"DuckDB 宏进阶"系列课程,在 Udemy 或网易云课堂发布。预计 500-2000 人购买,收入 ¥5,000-50,000。

  4. SaaS 工具:开发一个基于 DuckDB 宏的"指标管理平台",让业务人员可以通过 UI 配置宏参数,自动生成报表。订阅制 ¥99-499/月。

  5. ** freelance 平台**:在 Upwork 或程序员客栈接单,帮客户搭建 DuckDB 宏库和数据管道。时薪 ¥200-500。

关键提示:宏的价值不在于技术本身,而在于它解决的痛点——一致性可维护性。向客户推销时,强调"一个口径定义,全局生效"和"修改一处,全部更新",这比任何性能指标都更有说服力。


📖 详细图文教程见 duckdblab.org

💡 更多 DuckDB 实战技巧 → duckdblab.org

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials

使用 Hugo 构建
主题 StackJimmy 设计

⚠️ 本站为独立社区项目,与 DuckDB 基金会及 DuckDB 官方项目无任何从属、背书或赞助关系。

"DuckDB" 是 DuckDB 基金会的注册商标,本站仅以事实描述方式使用该名称。

本站内容仅供教育与社区推广用途,不构成任何商业服务。