alitrack

我用 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 存储过程机制

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?

● ● ●

参考来源

  1. 01DuckDB CALL 语句文档(procedures 即 table functions):duckdb.org/docs/current/sql/statements/call
  2. 02DuckPL: A Procedural Language in DuckDB(2026-01 开发者会议):duckdb.org/library/duckpl-a-procedural-language-in-duckdb/
  3. 03GitHub Discussion #13092「Stored Procedures or .sql file execution」:github.com/duckdb/duckdb/discussions/13092
  4. 04duckdb-luajit 扩展:github.com/alitrack/duckdb-luajit
  5. 05duckdb-luajit-libs Lua 库仓库:github.com/alitrack/duckdb-luajit-libs