本节摘要:内置函数不够用时,自己补能力有一座三级阶梯:Python 侧一行注册的自定义函数,SQL 侧零依赖的宏,以及面向分发的完整 C++ 扩展。多数需求停在第一二级;第三级有官方模板铺路,入门关键是理解类型契约与向量化这两个内核约定。
第6.1节的货架扫一遍之后仍找不到的能力,才轮到本节。动手前先做一次分流:这个"能力"是一个表达式(能由现有函数组合出来)、一个逐行函数(输入若干值输出一个值)、还是一段需要自定义扫描、类型或优化规则的真扩展?分流错了方向,成本差一个量级——大量自定义需求其实是前两种,写完整 C++ 扩展属于杀鸡用牛刀。
Python 接口提供了注册自定义函数的通道,注解上类型,引擎就能在 SQL 里像内置函数一样调用它。适合封装 Python 生态里现成、而 SQL 里没有的逻辑——文本处理调个分词库、复杂校验调个规则引擎:
import duckdb con = duckdb.connect() # 标量函数:带类型注解,NULL 语义交给引擎处理 @duckdb.udf def discount_price(price: float, tier: int) -> float: rate = {1: 0.95, 2: 0.9, 3: 0.8}.get(tier, 1.0) return round(price * rate, 2) con.execute("SELECT discount_price(amount, 3) FROM trades_clean LIMIT 3").fetchall()
便利的代价要说透:这个函数跑在 Python 解释器里,数据要逐批跨过 Python 与引擎的边界,速度比原生 SQL 函数慢一到两个数量级。所以它的正确用法是"低频行的重逻辑"——一万行里挑几百行做复杂校验,而不是对千万行逐行调 Python。如果确实要对大数据量做逐行运算,把逻辑改写成纯 SQL 表达式、或者下沉到下一级,才是对的路。
参数化还有一层安全价值值得记:上一节第5.1节讲过占位符防注入,自定义函数同理——逻辑进了引擎,SQL 注入面就小一块。
比 Python 函数更轻的是宏:把一段表达式起个名字存进库,调用处原地展开。宏没有跨语言边界的开销,性能与手写表达式完全一致,还能带默认参数:
-- 建宏:退款金额的口径统一收口,避免散落各处的手写条件 CREATE MACRO refund_amt(a) AS CASE WHEN lower(trim(a)) IN ('refunded', 'refund') THEN 1 ELSE 0 END; -- 用宏:口径只有一处定义,改口径只动这一行 SELECT sum(amount * refund_amt(status)) AS 退款总额 FROM trades_clean;
宏的真正价值在口径治理:退款怎么算、大额怎么定义、活跃用户怎么圈——这些业务口径最怕的是同一概念在三份查询里有三种写法。宏把口径变成库里的一个对象,检查口径时查一句定义就够了。宏不行的地方也要认清:它只是表达式替换,做不了循环、递归和过程逻辑,那些是下面一级的事。
前两级覆盖不了的需求——自定义表函数(比如接入一个私有数据源)、自定义类型、给优化器加规则——才需要原生扩展。官方提供了扩展模板工程,把脚手架、构建脚本和发布配置都铺好了,入门流程可以概括成四步:
一 取模板:复制官方扩展模板工程,改名字 二 写实现:在模板给的骨架里注册你的函数、类型或扫描器 三 构建签名:走官方构建链编译,产物自动附加签名 四 装进会话:INSTALL 本地产物,LOAD 进来即用
原生扩展有两份内核契约要遵守,它们在源码层面定义了"什么是一个合格的扩展"。类型契约:注册函数时必须显式声明参数与返回类型,类型推导在计划阶段完成,运行期不再猜测——这就是为什么 SQL 里的自定义函数从不会因为传参类型不对而"静默返回错结果"。内存契约:扩展内部申请的内存要走引擎的分配器,查询被取消时引擎能统一回收,扩展泄漏不了进程内存。理解了这两份契约,再去读模板代码,每一行都有出处。
第三级还有个绕不开的副产品:向量化。内核按批次喂行给你的函数,原生扩展若只按单行实现,引擎会退化成慢路径。模板里的示例函数都带批次版实现,照着写就好——第4.3节讲的向量化原理,在这里变成了一条工程约束。
| 需求形态 | 用哪级 | 判断依据 |
|---|---|---|
| 能用现有函数组合出来 | 纯 SQL 或宏 | 表达式能写出来就别写函数 |
| 逐行的重逻辑,行数有限 | Python 自定义函数 | 千行级以下,图省事 |
| 口径统一、多处复用 | 宏 | 改口径只动一处 |
| 私有数据源、自定义类型、优化规则 | 原生扩展 | 前三级都不够时才动工 |
自定义能力的验收标准与内置函数应当一致,可惜实践中常常放水。三条最低纪律。NULL 语义先测:SQL 函数对 NULL 的传播规则是引擎按声明处理的,但函数体内部拿到空参数怎么办,得自己想清楚——注册时声明了非空参数,引擎会拦;声明允许 NULL,函数体里就要显式分支。边界值过一遍:空字符串、负数、超长文本、枚举外的取值,各喂一次看行为;Python 函数在这里最容易暴露隐藏的异常路径。性能拿真实量级测:开发时拿十行数据调试一切正常,上了百万行才发现逐行跨边界的开销不可接受——这种返工用第二节的分流规则提前就能避免。
宏的验收更简单也更严格:简单在于只测展开后的表达式对不对,严格在于口径即契约——宏一旦被多处引用,改动就是一次全量回归。给宏留一行注释说明口径的业务出处,比测试用例还保值。
个人脚本里的自定义函数随会话消失;要变成团队资产,出口有三档。第一档,把注册代码收进团队的公共工具模块,谁起会话谁导入——成本最低,适合 Python 函数。第二档,宏与视图直接建在共享库文件里,跟库一起持久化——它们本来就是库内对象,天然可共享。第三档,原生扩展走构建链分发,产物进制品库统一安装——这一档的维护成本最高,除非能力被三个以上场景反复需要,否则不值得升级到这。三档的升级信号很明确:当你在两个以上项目里第三次复制同一段注册代码,就该往上一档搬了。
| 形态 | 共享出口 | 升级信号 |
|---|---|---|
| Python 函数 | 公共工具模块 | 第三个项目开始复制粘贴 |
| 宏与视图 | 建在共享库内 | 口径变更需要通知多人时 |
| 原生扩展 | 制品库分发 | 多场景高频复用且性能敏感 |
下一节换到硬币的另一面:能力装得越多、进程读的东西越杂,信任边界就越需要 explicit 的管理。6.3节讲安全。
本节要点回顾