资讯动态

C语言实战:为DuckDB编写高性能自定义标量函数

发布时间:2026/10/13 12:36:55 来源:尧图企业网站定制
这几年嵌入式分析数据库越来越火DuckDB 算是一个绕不开的名字。单文件、零配置、跑起来比一堆传统数仓都快而且它原生提供了一套完整的扩展机制。大部分教程都在讲 Python 和 C真正从 C 语言入口去写 DuckDB 自定义标量函数的资料少得可怜。这篇文章就把这条路径完整走一遍从环境准备、API 设计思路到真实工程里的坑全用 C 语言实现一个可以直接跑的自定义标量函数示例。文章适合两类人一类是已经有一定 C 基础、想给 DuckDB 加函数但不想引入 Python 解释器开销的开发者另一类是刚接触 DuckDB、想通过 C API 理解它内部执行模型的进阶学习者。读完你不仅会写一个add_one这样的玩具函数还会明白参数绑定、NULL 语义、批量执行这些底层机制是怎么设计的——这些东西在官方文档里不会有人给你串起来讲。1. 先把几个关键概念捋清楚1.1 为什么偏偏用 C 语言写 DuckDB 扩展DuckDB 本身是 C 写的官方最推荐的扩展方式是直接用 C 写 extension因为你可以直接操作duckdb::Vector这种高层级对象写起来非常舒服。但 C 扩展的问题在于它跟 DuckDB 的 API 绑定太深不同版本之间接口变动很大而且如果你只想在项目里嵌入 DuckDB、快速注册一两个函数为了这个去搭一套 C 扩展工程实在有点重。C 语言版本的 API 是 DuckDB 对外提供的稳定 ABI也就是duckdb.h里的那套声明。它把所有内部实现都隐藏起来通过duckdb_connection、duckdb_vector这类不透明句柄交互。好处非常直接只要带上duckdb.h和libduckdb.so就能编译ABI 稳定跨版本升级时代码改动量小很多。这点对我这种需要在多个项目里复用同一套 C 函数库的人来说太重要了。另一个现实理由是性能。Python UDF 每处理一行数据都要做一次 Python 对象转换受制于 GIL 和解释器边界性能损耗非常明显。而 C 语言的标量函数直接操作 DuckDB 的向量数据结构对整批数据做循环几乎没有序列化开销。实测下来同样的i 1逻辑Python UDF 比原生 SQL 表达式慢几十倍是常态而 C UDF 只慢个两三倍完全在可接受范围。所以结论很清晰如果你需要把一套已有的 C/C 算法库接入 DuckDB或者你的函数对性能敏感、不想背着 Python 运行时用 C API 写标量函数就是最优解。1.2 标量函数、聚合函数和表函数到底有什么区别DuckDB 的扩展机制里最常见的是三类函数很多新手容易搞混我花一分钟把它们分开标量函数Scalar Function输入一行输出一行比如abs(x)、upper(s)、你自定义的add_one(x)。它是行对行的映射不改变行数。聚合函数Aggregate Function输入多行输出一行比如sum(x)、avg(x)需要额外的state状态结构来累积中间结果。表函数Table Function输入可以是任意内容输出的是整个表比如range(10)生成 10 行数据。我们这篇文章只做标量函数。标量函数的实现思路最清晰但你理解了它的执行模型后面再碰聚合和表函数会省很多事因为底层的duckdb_data_chunk、向量、validity mask 这些概念是完全共通的。值得多说一句的是 DuckDB 里的执行粒度。DuckDB 不会一行一行地调用你的函数而是把一个数据块duckdb_data_chunk一次性交给你默认一块是 2048 行新版本是 2048老版本是 1024。你需要在函数体里对整块数据做 for 循环。这个设计跟现代数据库的向量化执行引擎一脉相承好处是 CPU 缓存利用率高坏处是如果你的函数体里忘了循环整块数据你只会处理第一行结果错得莫名其妙。2. 环境准备拿到 DuckDB 开发包2.1 获取 libduckdb 和 duckdb.h写 C API 扩展并不需要从源码编译 DuckDB。官方 Release 页面会提供各个平台的 C/C 开发包压缩包里通常包含duckdb.h、duckdb.hpp、libduckdb.soLinux、duckdb.lib或duckdb.dllWindows。这里要注意的是版本一定要选对我推荐直接用当前最新的稳定版比如 0.10.x 系列下载libduckdb-linux-amd64.zip这种包即可。解压后放到一个方便引用的目录比如/home/user/duckdb-dev/ ├── include/duckdb.h └── lib/libduckdb.so如果你用的是 macOS 或者 Windows同理。Windows 下要特别注意 DLL 的运行时依赖把duckdb.dll放到和你的可执行文件相同的目录否则运行时会提示找不到动态库。这个坑我帮朋友踩过不止一次都是编译过了、运行直接崩第一反应以为是代码问题结果就是 DLL 路径没配好。2.2 建议的项目骨架和编译配置我建议把自定义函数和主程序分开文件方便以后维护和扩展。我这边的项目结构是这样duckdb_udf_demo/ ├── CMakeLists.txt # 或者 Makefile ├── udf.h # 自定义函数的头文件 ├── udf.c # 自定义函数的具体实现 └── main.c # 打开数据库、注册函数、跑 SQL编译时核心就一个要求头文件路径和库路径指对。下面给一个最小可用的 MakefileCC gcc CFLAGS -stdc99 -Wall -O2 -I/home/user/duckdb-dev/include LDFLAGS -L/home/user/duckdb-dev/lib -lduckdb -lm TARGET demo all: $(TARGET) $(TARGET): main.o udf.o $(CC) -o $(TARGET) main.o udf.o $(LDFLAGS) main.o: main.c udf.h $(CC) $(CFLAGS) -c main.c udf.o: udf.c udf.h $(CC) $(CFLAGS) -c udf.c clean: rm -f $(TARGET) *.o run: $(TARGET) LD_LIBRARY_PATH/home/user/duckdb-dev/lib ./$(TARGET)两个细节值得注意。第一-lduckdb要放在源文件目标文件后面Linux 的链接器从左往右解析符号如果库写在前面会报 undefined reference这是刚接触的人最常遇到的事。第二运行时记得设置LD_LIBRARY_PATH或者用-Wl,-rpath,/home/user/duckdb-dev/lib写死搜索路径不然程序起来就报找不到libduckdb.so。用 CMake 也完全没问题思路一样cmake_minimum_required(VERSION 3.16) project(duckdb_udf_demo C) set(CMAKE_C_STANDARD 99) find_path(DUCKDB_INCLUDE_DIR duckdb.h) find_library(DUCKDB_LIB duckdb) add_executable(demo main.c udf.c) target_include_directories(demo PRIVATE ${DUCKDB_INCLUDE_DIR}) target_link_libraries(demo PRIVATE ${DUCKDB_LIB})我更倾向直接用 Makefile原因很简单项目里就两个 C 文件Makefile 一眼能看完全部编译流程CMake 反而显得杀鸡用牛刀。但如果你是给别人交付代码CMake 更友好一些。看自己习惯。3. 手写第一个 UDF从注册到调用3.1 注册一个标量函数的五个步骤DuckDB C API 里注册标量函数的流程可以固定拆成五步理解这五步你就理解了整套机制创建函数对象duckdb_create_scalar_function(函数名)此时函数还只是一个空壳。设置 bind 回调bind 阶段负责告诉 DuckDB 函数接受什么类型的参数、返回什么类型。这个回调在函数第一次被 SQL 解析到的时候触发。设置 function 回调这是真正的计算逻辑每次处理一个duckdb_data_chunk。注册到连接调用duckdb_register_scalar_function(con, function)。释放函数对象如果后续不再使用调用duckdb_destroy_scalar_function。不过注册成功后DuckDB 内部接管了生命周期你也可以不手动释放等连接关闭时一起清理。我把这五步拆到代码里你照着写基本不会错。先看一个最简单的整数函数让输入值加一。#include stdio.h #include duckdb.h // bind 回调声明参数类型和返回类型 static void add_one_bind(duckdb_bind_info info) { // 返回类型是 BIGINT duckdb_bind_set_return_type(info, DUCKDB_TYPE_BIGINT); // 接受一个 BIGINT 参数 duckdb_bind_add_parameter(info, DUCKDB_TYPE_BIGINT); } // function 回调真正的计算逻辑 static void add_one_func(duckdb_function_info info, duckdb_data_chunk input, duckdb_vector output) { // 从输入 chunk 里拿第 0 列 duckdb_vector input_vector duckdb_data_chunk_get_vector(input, 0); // 取这一批数据的行数DuckDB 每次可能给 2048 行 idx_t row_count duckdb_data_chunk_get_size(input); int64_t *input_data (int64_t *)duckdb_vector_get_data(input_vector); int64_t *output_data (int64_t *)duckdb_vector_get_data(output); // 获取输入列的有效性掩码用于判断 NULL duckdb_validity input_validity duckdb_vector_get_validity(input_vector); for (idx_t i 0; i row_count; i) { // 如果这行是空值输出也必须是空值 if (!input_validity || duckdb_validity_row_is_valid(input_validity, i)) { output_data[i] input_data[i] 1; } else { duckdb_vector_assign_null(output, i); } } } // 注册入口 duckdb_state register_add_one(duckdb_connection con) { duckdb_scalar_function fn duckdb_create_scalar_function(add_one); duckdb_scalar_function_set_bind_method(fn, add_one_bind); duckdb_scalar_function_set_function(fn, add_one_func); return duckdb_register_scalar_function(con, fn); }这段代码的注释已经写清楚了每步的作用。这里有一个容易忽略的细节duckdb_validity_row_is_valid判断的是这一行是否为非 NULL。如果整个向量没有 NULLDuckDB 会把这种情况优化成一个 flat validityinput_validity可能是空指针。所以我在判断条件里先检查了input_validity是否为空为空就代表这一列没有 NULL直接按有效数据处理。这是很多示例代码没写的你要是不加这个判断遇到全是有效数据的列会直接段错误。3.2 main.c 完整演示打开库、注册、执行 SQL有了register_add_one接下来就是标准的 DuckDB 使用流程#include stdio.h #include duckdb.h duckdb_state register_add_one(duckdb_connection con); int main(void) { duckdb_database db; duckdb_connection con; // 打开内存数据库 if (duckdb_open(NULL, db) DuckDBError) { fprintf(stderr, 无法打开 DuckDB 数据库\n); return 1; } if (duckdb_connect(db, con) DuckDBError) { fprintf(stderr, 无法建立连接\n); duckdb_close(db); return 1; } // 注册自定义标量函数 if (register_add_one(con) DuckDBError) { fprintf(stderr, 注册 add_one 失败\n); duckdb_disconnect(con); duckdb_close(db); return 1; } // 执行 SQL 调用自定义函数 duckdb_result result; if (duckdb_query(con, SELECT add_one(41) AS result, result) DuckDBError) { fprintf(stderr, SQL 执行失败: %s\n, duckdb_result_error(result)); duckdb_disconnect(con); duckdb_close(db); return 1; } // 读取结果第一行第一列 int64_t value duckdb_value_int64(result, 0, 0); printf(add_one(41) %lld\n, (long long)value); // 清理 duckdb_destroy_result(result); duckdb_disconnect(con); duckdb_close(db); return 0; }这里duckdb_open(NULL, db)的第一个参数传 NULL 表示打开一个纯内存数据库不落盘。如果你想打开一个已经存在的 DuckDB 文件把路径字符串传进去就行比如duckdb_open(mydb.duckdb, db)。运行结果应该是add_one(41) 42到这里你已经成功用 C 语言实现并调用了一个 DuckDB 自定义标量函数。整个过程没碰任何 CAPI 也完全是面向 C 的。如果你只是需要一个简单函数上面的代码就是最小可用的模板。3.3 测试一下批量执行和大数据量情况只验证一个单值调用说明不了问题我建议你建个测试表看看批量场景下 UDF 的表现CREATE TABLE t AS SELECT range AS id FROM range(1000000); SELECT add_one(id) FROM t LIMIT 10;或者干脆在一条 SQL 里直接用range(1000000)生成百万行数据去跑聚合SELECT sum(add_one(id)) FROM range(1000000) t(id);我实测过百万行级别基本是毫秒到几百毫秒的差别。这个步骤能帮助你确认函数确实是按 chunk 批量被调用的——你可以在add_one_func里加一个printf打印row_count会发现打印的值通常是 2048而不是 1。4. 让你的 UDF 更接近真实工程4.1 参数类型和返回值类型的匹配问题很多人在写 UDF 时踩过这个坑bind 回调里声明返回 BIGINT但 function 回调里却把输出数据当成 INTEGER 来处理。在 DuckDB 的类型体系里BIGINT 是 64 位INTEGER 是 32 位两个的长度都不一样你按 32 位去写output_data[i] ...写一半就把别人数据覆盖了结果完全不可预期。我把常见的类型对应关系整理成了表格调试的时候对照着看DuckDB 类型C 指针类型字节数DUCKDB_TYPE_BOOLEANbool *1DUCKDB_TYPE_TINYINTint8_t *1DUCKDB_TYPE_SMALLINTint16_t *2DUCKDB_TYPE_INTEGERint32_t *4DUCKDB_TYPE_BIGINTint64_t *8DUCKDB_TYPE_UBIGINTuint64_t *8DUCKDB_TYPE_FLOATfloat *4DUCKDB_TYPE_DOUBLEdouble *8DUCKDB_TYPE_VARCHARduckdb_string_t *见下文每次新增函数我都建议先写一个只做透传的函数测试类型匹配别直接上复杂逻辑。比如返回类型是 DOUBLE你就先把输入原封不动写回输出查询一下结果对不对再往上叠加计算。这样出了问题你至少知道是类型层还是业务逻辑层。4.2 NULL 语义不处理就会得到垃圾值数据库函数对 NULL 的处理是一个严肃问题。默认情况下标量函数如果传入的参数是 NULLDuckDB 会调用你的函数并把值所在的位置设置成 NULL。如果你不检查输入列的 validity直接拿着input_data[i]去算那里面是未定义的数据可能是 0也可能是一块随机内存的残留。输出就是一个完全错误的非 NULL 值。处理策略一般有两种第一种像上面的add_one_func一样在函数体内手动检查每一行的 validity遇到 NULL 就给输出行也标记为 NULL。这种方式灵活适合你需要在函数体内对空值和业务值做不同处理的场景。第二种在 bind 回调里声明参数为任意类型或者干脆不声明参数让 DuckDB 跳过 NULL 检查直接传给你。这种方式少见而且只适合你知道自己在干什么的情况不推荐新手用。我建议统一采用第一种代码里养成立刻处理 validity 的习惯。写一个函数就处理一次别偷懒。等函数多了以后你会发现那些偶尔结果不对、但大部分时间正常的诡异 bug十个里面八个是 NULL 处理漏了。4.3 字符串类型为什么不能直接当 C 字符串用VARCHAR 是标量函数里最让人迷惑的类型。DuckDB 的字符串在向量里的表示不是简单的char *而是一个带长度的结构体我在 C API 里看到的结构大致是这样的typedef struct { uint32_t length; // 字符串长度 char prefix[4]; // 短字符串直接内联存储 char *data; // 长字符串的数据指针 } duckdb_string_t;关键点在于对于短字符串DuckDB 会优化地把内容直接放在结构体内部data指针可能是空的对于长字符串data才指向外部内存。所以你绝对不要直接对这个结构的data调strlen不然短字符串直接崩。正确做法是读length字段来获取长度需要内容时走专门的数据访问接口。写一个返回字符串长度的函数给你参考这个安全且常用static void str_len_func(duckdb_function_info info, duckdb_data_chunk input, duckdb_vector output) { duckdb_vector input_vector duckdb_data_chunk_get_vector(input, 0); idx_t row_count duckdb_data_chunk_get_size(input); duckdb_string_t *input_data (duckdb_string_t *)duckdb_vector_get_data(input_vector); int32_t *output_data (int32_t *)duckdb_vector_get_data(output); duckdb_validity input_validity duckdb_vector_get_validity(input_vector); for (idx_t i 0; i row_count; i) { if (!input_validity || duckdb_validity_row_is_valid(input_validity, i)) { output_data[i] (int32_t)input_data[i].length; } else { duckdb_vector_assign_null(output, i); } } }如果你要返回一个字符串而不是数字就需要用到duckdb_vector_assign_string_element它会把你的 C 字符串拷贝一份到 DuckDB 管理的向量内存中const char *text hello; duckdb_vector_assign_string_element(output, i, text, strlen(text));记住DuckDB 不接管你传入的text指针它内部会拷贝。所以你用栈上的临时字符串也完全没问题不用担心悬垂指针。4.4 性能观察UDF 和原生表达式差多少C 语言 UDF 性能虽然比 Python 好太多但相比 DuckDB 原生表达式还是有一定开销的。我建议你在真实场景里做个对照组测试。比如建一张一千万行的表分别跑SELECT sum(id 1) FROM t; -- 原生表达式 SELECT sum(add_one(id)) FROM t; -- 自定义 UDF用EXPLAIN ANALYZE可以看具体执行时间和调用次数。我实测下来的经验数据大概是原生表达式可能在 50ms 左右UDF 可能在 120ms 到 200ms。这个差距主要来自函数调用本身和向量取数据的边界检查属于正常开销。如果你的函数逻辑不太复杂这点差距完全值得因为代码可维护性高了很多。如果要进一步优化可以考虑减少每个函数调用里的重复计算。比如你在函数体里做的常量表达式提取、状态分配提到 bind 阶段用duckdb_bind_set_bind_data绑定一次函数体里通过duckdb_function_get_bind_data读取。这是一个标准的缓存技巧函数体被调用的次数远大于 bind 阶段。我后面写进阶部分会提一下这个思路。5. 实战排雷那些绕不过去的问题5.1 常见问题与对策速查表下面是我在用 C API 写 DuckDB UDF 过程中真正遇到过的、或者帮别人排查过的典型问题。整理成表格方便你对着查症状可能原因解决办法编译通过链接报 undefined reference库链接顺序不对确保-lduckdb出现在源文件或目标文件之后运行时提示找不到 libduckdb.so动态库搜索路径没设置设置LD_LIBRARY_PATH或编译时加-Wl,-rpath查询结果只有第一行正确后面全是错值函数体没有遍历整个 chunk用duckdb_data_chunk_get_size(input)拿行数for 循环处理明明传了 NULL函数输出的却是垃圾值没检查输入向量 validity用duckdb_vector_get_validity配合duckdb_validity_row_is_valid字符串处理时崩溃当普通 C 字符串用了strlen改用结构体的length字段获取内容用专用接口返回类型声明和实际写入宽度不一致bind 类型写错对照上面的类型表格检查返回 BIGINT 就写int64_t *函数注册没报错但 SQL 里找不到函数名拼写不一致或注册到了别的连接确认注册和查询用的是同一个duckdb_connection升级 DuckDB 版本后原有代码编译不过API 版本漂移锁版本看 release notesC API 一般变化不大个别函数会改名5.2 三个值得养成的工程习惯第一个习惯是封装注册函数。当你写了多个 UDF 时别在main里一个一个调用注册建议把所有注册逻辑集中到一个register_all_functions(duckdb_connection con)函数里。这样每次新增函数只需要在函数体里加一行查起来也方便。我自己的代码里几乎全是这种模式。第二个习惯是尽量在 bind 阶段校验参数。比如你写了一个函数只支持 BIGINT但用户传了 VARCHAR最理想的情况是 bind 回调里直接报错SQL 解析阶段就失败。duckdb_bind_info提供了duckdb_bind_set_error(info, 参数必须是 BIGINT)这种接口可以设置错误信息。提前在 bind 阶段拦截错误参数比你运行到一半数据算错了再排查要省太多时间。如果你依赖隐式类型转换DuckDB 通常会在调用前自动把参数转成你声明的类型。第三个习惯是写一个回归测试 SQL 脚本。我每写一个 UDF就会顺手在test.sql里加上边界测试NULL 输入、空字符串、负数、超大整数、浮点边界。然后用程序批次跑一遍确认没崩溃。UDF 一旦开始被业务 SQL 引用回归测试的价值会放大很多倍。C 语言代码本身容易出内存问题更需要拿这种黑盒测试兜底。5.3 一个进阶思路bind 阶段传参给函数体如果你的函数需要一个配置项比如某个阈值、前缀字符串不建议做成全局变量也不建议每次函数体调用都去解析一遍。标准做法是在 bind 阶段申请一块内存存好配置通过duckdb_bind_set_bind_data(info, config, free_config)绑定函数体里通过duckdb_function_get_bind_data(info)取出来。这样配置只在 SQL 计划阶段准备一次函数体直接使用性能最好。这里要注意duckdb_bind_set_bind_data的第三个参数是一个释放回调函数DuckDB 会在不再需要绑定时自动帮你清理。如果不传释放函数绑定的内存就会泄漏。我见过不少人在这个细节上栽跟头等到内存涨到几个 G 才反应过来。不管绑定的结构有多小都记得配上释放回调。6. 最后补充一点个人经验我在实际项目中把 C 语言的 DuckDB UDF 用在了内部数据清洗上处理的是从各种文本里抽取特征值、做规则转换这类活跑了上亿行数据。整个过程最大的感受就是C API 的稳定性确实比 C 扩展好太多DuckDB 版本升级时我基本只需要重新编译一次不用翻着 API 改代码。给新手一个明确的建议路径先跑通文章里add_one这个最小示例然后改造成str_len这种带字符串处理的函数再往上加 bind data 传递。三步走完你对 DuckDB 的 C API 就建立起了一个完整的认知框架。后续不管写多复杂的函数底层都是这几个接口的组合运用。另外记住一个原则UDF 是给 SQL 查不到现成表达式的场景用的能用原生 SQL 表达的逻辑就尽量用原生 SQL。DuckDB 本身内置函数已经非常丰富你写的函数应该解决的是它内置能力覆盖不到的业务场景而不是重复造轮子。最后再分享一个小技巧调试 UDF 时先在一个迷你 SQL 上跑单行测试确认值正确了再上大表。不要一上来就在千万级表上EXPLAIN ANALYZE你会被各种性能数字干扰反而看不清逻辑问题。先把正确性搞定再谈性能。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑