我用 Lua 实现了DuckDB 存储过程
DuckDB 什么都快,就是没有存储过程。官方文档说得直白:procedures in DuckDB are implemented as table functions——拿 CALL 调表函数就是它全部的家底。
CREATE MACRO 能写参数化 SQL,但只能是一个表达式。想写循环、条件、多语句,没门。社区呼声最高的 feature request 之一挂着,DuckPL(PL/pgSQL 风格的过程语言)2026 年 1 月在开发者会议上亮了相,但还没落地。
等官方,不如自己先上一个。我给自己的 DuckDB 扩展 duckdb-luajit 加了两条 SQL 回查通道,拼起来就是一个能用的存储过程:SQL 里写 Lua,Lua 里跑 SQL。
● ● ●
机制:第二连接
扩展加载的时候用 duckdb_connect 开了一条独立连接(luajit_module.c 里一行 duckdb_connect(*db, &g_conn))。Lua 侧有两个全局函数:
_duckdb_call(sql)—— 执行 DDL/DML,返回 "ok"_duckdb_query(sql)—— 执行查询,返回{col=value, ...}的行表
duckdb-luajit 存储过程机制
因为是第二连接,Lua 过程里跑 SQL 不会跟调用方的查询死锁。扩展里连锁都是递归的,_duckdb_call → duckdb_query → luajit UDF 可以再进入,不会炸。
● ● ●
配方一:过程内建表、写数据
多参数用 STRUCT 打包传进去,这是最稳的姿势:
LOAD '/path/to/luajit.duckdb_extension';-- compile:只注册函数,不做自动探测
SELECT message FROM luajit_module(mode := 'compile', sql_name := 'sp_add',
source := 'return function(p) return _duckdb_call("INSERT INTO sp_orders VALUES (" .. p.id .. ", ''" .. p.item .. "'', " .. p.qty .. ")") end');-- 手动宏:luajit_s 收单参 ANY
CREATE MACRO sp_add(p) AS luajit_s('sp_add', p);SELECT sp_add({id:1, item:'laptop', qty:2}); -- ok
SELECT sp_add({id:2, item:'mouse', qty:5}); -- ok
SELECT * FROM sp_orders;
-- 1 | laptop | 2
-- 2 | mouse | 5
为什么不直接用 quick_compile?它注册前会拿探测参数真调一次函数。有副作用的函数会被探测调用污染数据——我踩过:一个 INSERT 过程被探测调用执行了 sp_add(0, 0, 0),表里多了一行 0|0|0,排查了半天。
● ● ●
配方二:过程内查询回填
无参过程,调用时塞个 dummy 参数绕过 luajit_s 的双参签名:
SELECT message FROM luajit_module(mode := 'compile', sql_name := 'sp_summary',
source := 'return function() local r = _duckdb_query("SELECT count(*) AS n, sum(qty) AS total FROM sp_orders") return tostring(r[1].n) .. " orders / " .. tostring(r[1].total) .. " items" end');CREATE MACRO sp_summary() AS luajit_s('sp_summary', 0);
SELECT sp_summary();
-- 3 orders / 8 items
count(*) 是 BIGINT,sum(qty) 是 BIGINT——读回来都是对的。这里藏过一个真 bug:结果桥把所有整数列都按 int64 读,INTEGER 列会读出垃圾值。我表里 id INTEGER 存 5,读回来是 2147483648005(混进了相邻行的字节)。v0.30 已修,按列的真实物理宽度分派读取,sqllogictest 4/4 通过。
● ● ●
配方三:参数化过程 + 控制流
SQL macro 写不了的东西,Lua 里都是常识操作:
SELECT message FROM luajit_module(mode := 'quick_compile', sql_name := 'sp_collatz',
source := 'return function(n) local steps = 0 while n > 1 do if n % 2 == 0 then n = n / 2 else n = 3 * n + 1 end steps = steps + 1 end return steps end');SELECT sp_collatz(27);
-- 111
● ● ●
配方四:过程返回结果集
luajit_table 表函数直接调注册名。行是扁平字符串,SQL 侧拆列:
SELECT message FROM luajit_module(mode := 'compile', sql_name := 'sp_list',
source := 'return function() local r = _duckdb_query("SELECT id, item, qty FROM sp_orders ORDER BY id") local out = {} for i = 1, #r do out[i] = tostring(r[i].id) .. "|" .. r[i].item .. "|" .. tostring(r[i].qty) end return out end');SELECT * FROM luajit_table('sp_list') AS t(line);
-- 1|laptop|2
-- 2|mouse|5
-- 3|keyboard|1
● ● ●
跟传统存储过程的差距
| 维度 | T-SQL / PLpgSQL | 这套 Lua 方案 |
|---|---|---|
| 过程体 | SQL 方言 | Lua(控制流、闭包、FFI 调 C 库都行) |
| 查库 | 原生 SPI | _duckdb_query 第二连接回查 |
| 返回结果集 | 多结果集 / OUT 参数 | 单结果集(luajit_table 流式) |
| 事务 | 内建 | 走 DuckDB 事务,第二连接写受乐观锁约束 |
| 持久化 | 库内对象 | save/load 文件 + 宏重注册 |
| 权限 / 依赖追踪 | 有 | 没有(嵌入式单机场景影响小) |
扩展本体约 1MB,零运行时依赖,LOAD 一条语句。
DuckPL 落地后,想要 PL/pgSQL 语法的人自然会过去。Lua 这条线的价值在别处:FFI 直接调系统 C 库,require 拉现成的 Lua 库,签名 API 数据源——这些是 SQL 过程语言永远不会有的东西。存储过程只是它顺手解锁的一个形态。
代码:github.com/alitrack/duckdb-luajit
Lua 库仓库:github.com/alitrack/duckdb-luajit-libs
你会在 DuckDB 里用 Lua 写过程吗?还是继续等 DuckPL?
● ● ●
参考来源
- 01DuckDB CALL 语句文档(procedures 即 table functions):duckdb.org/docs/current/sql/statements/call
- 02DuckPL: A Procedural Language in DuckDB(2026-01 开发者会议):duckdb.org/library/duckpl-a-procedural-language-in-duckdb/
- 03GitHub Discussion #13092「Stored Procedures or .sql file execution」:github.com/duckdb/duckdb/discussions/13092
- 04duckdb-luajit 扩展:github.com/alitrack/duckdb-luajit
- 05duckdb-luajit-libs Lua 库仓库:github.com/alitrack/duckdb-luajit-libs