DuckDB 的 Luajit扩展里,我塞了个 C 编译器
先说结论
DuckDB 的 Lua 扩展(duckdb-luajit)能跑三类东西:纯 Lua 算法库直接就能用(合成音效、农历),FFI 绑定的 C 库也能用(比如把 libtcc 编译器请进来),甚至能在 SQL 里现场编译并执行 C 代码。三层能力一条查询走完。
我实测了三条链路,顺带发现并修了 6 个上游 bug:DuckDB 农历日历有 357 天算错(issue 已提),tcclua 库 5 个 bug、sfxr 音效库 1 个 bug(PR/修复已整理)。
● ● ●
起因:农历日历的 357 天
先说为什么我会去折腾这个。DuckDB 从 1.4 起内置了农历日历支持(SET Calendar='chinese'),但我在验证时发现它算得不对:
SET Calendar='chinese';
SET TimeZone='UTC';
-- 1917-03-23 应该是闰二月初一,DuckDB 报三月初一
SELECT date_part(['month','day'], '1917-03-23 12:00:00+00'::TIMESTAMPTZ);
-- 输出 {'month': 3, 'day': 1},正确应是闰二月-- 2018-11-07 应该是九月三十,DuckDB 报十月初一
SELECT date_part(['month','day'], '2018-11-07 12:00:00+00'::TIMESTAMPTZ);
-- 输出 {'month': 10, 'day': 1},正确应是九月三十
我把 1900–2100 年逐日扫描了一遍,用三个独立实现交叉验证:sxtwl(寿星天文历)、lunar-python、astronomy-engine(天文引擎,新月时刻精确到秒)。结果:357 天、12 个时段出错。
两类错误:
- ●闰月漏判(3 个时段):1917 闰二月、1922 闰五月、1987 闰六月被当成普通月份,整个闰月消失。对比之下 2033 闰十一月是对的——说明是特定年份的判定边界错误。
- ●朔日边界差一天(9 个时段):1954、1955、1999、2012、2018、2027、2030、2057、2070。新月(朔)发生在北京午夜 ±10 分钟以内时,DuckDB 提前一天起了新月份。比如 2018 年 11 月的新月在北京时间 11 月 8 日 00:02:42,DuckDB 却把 11 月 7 日就当成了十月初一。
查了上游:ICU 官方 tracker 有两条已确认未修的同类 issue——ICU-13195(2018 年报,1970–2100 年间约 269 个日期转换错误)、ICU-22230(2022 年报,1890 年闰月错位约 100 天)。DuckDB 1.5.5 用的是真 ICU(扩展二进制里 104 个 icu_66 符号,零自研),所以是继承了 ICU 的 bug;main 分支换成自研实现后,又复刻了同样的错误。
我给 DuckDB 提了 issue(github.com/duckdb/duckdb/issues/24581),最小复现 SQL、11 行三方对照表、天文裁决都在里面。有一个 case 我没放进主证据:2057 年 9 月的朔恰好卡在北京时间午夜 00:00:00,连 sxtwl 和 lunar-python 两个参考库自己都打架(一个说初一在 9 月 28,一个说 9 月 29),天文引擎支持后者。这种争议 case 不适合当铁证,我单独标了出来。
● ● ●
第一层:纯 Lua 库,dofile 就能用
农历 bug 让我想到一件事:既然 DuckDB 自带的农历是错的,那我在 duckdb-luajit 里用 Lua 自己写一个查表法农历(基于香港天文台数据,8 个锚点日期验证),不依赖 ICU——Lua 农历反而成了正确替代方案:
SELECT luajit_s('lunar', '2018-11-07'); -- 九月三十,正确
SELECT luajit_s('lunar', '1917-03-23'); -- 闰二月初一,正确
这暴露了 duckdb-luajit 的一个通用能力:纯 Lua 写的算法库,dofile 加载就能在 SQL 里当 UDF 用。不需要编译、不需要 C 库、不需要改扩展源码。为了验证这个能力不是只有农历一个特例,我又找了一个纯 Lua 库来试——这次是更好玩的:音效合成。
sfxr(github.com/nucular/sfxrlua)是复古游戏音效合成器(8-bit 风格"biu~"“哒哒哒”)的 Lua 移植版,1536 行纯 Lua,零 FFI 依赖。我把它的单文件 dofile 进 duckdb-luajit,三条 SQL 合成出三个合法 WAV 文件:
SELECT luajit_s('sfxr_demo', 'laser,42'); -- 激光音效 0.09s
SELECT luajit_s('sfxr_demo', 'explosion,7'); -- 爆炸音效 0.04s
SELECT luajit_s('sfxr_demo', 'jump,99'); -- 跳跃音效 0.22s
输出是标准 RIFF/WAVE 文件(16-bit PCM,22050Hz,file 命令验证过),任何播放器都能放。严格说 DuckDB 自己不发声——它负责合成音效数据并导出文件,播放交给播放器。但对数据分析来说这反而正好:批量生成、参数化变体(同一个爆炸音效,seed 换一下就是 100 个变体存进表里)。
顺手还发现 sfxr 的 exportWAV 有个 bug:函数体内 6 处引用了不存在的变量 freq/bits(参数实际叫 rate/depth),所以这个库的原版 WAV 导出根本跑不了。修掉后验证 WAV 头全对。
● ● ●
第二层:FFI 接 C 库,libtcc 进了 SQL
纯 Lua 够用,但 C 生态是更大的宝库。duckdb-luajit 的 trusted 模式开了完整 FFI——任何导出 C 符号的库都能 ffi.load 进来。我挑了最狠的验证对象:libtcc(Tiny C Compiler 的库接口)——一个能把 C 源码现场编译成机器码的库。
tcclua(github.com/nucular/tcclua)是 libtcc 的 LuaJIT FFI 绑定,实测整条链路:
LOAD '/path/to/luajit.duckdb_extension';
SET allow_unsigned_extensions = true;
-- 现场编译 C 版 fib,执行,返回 12586269025
SELECT luajit_s('tcc_fib', 50);
实际效果:SQL → Lua UDF → FFI → libtcc → 机器码 → 执行,一条查询走完。C 代码可以每次调用传入字符串——这就是动态代码生成。实测编译一次 C 函数只要 0.5 毫秒左右。
● ● ●
fib 三路对比:SQL vs Lua vs C
同一个 fib(50) 算法,三种执行路径(结果都是 12586269025):
| 路径 | 单点 fib(50) | 批量 10 万行 |
|---|---|---|
| 纯 SQL 递归 CTE | 5.05 ms | 不支持 |
| Lua UDF(LuaJIT JIT) | 0.15 ms | 12.65 ms |
| tcclua(libtcc 编译) | 0.67 ms(含编译) | 22.59 ms(复用编译结果) |
三个意外:
- 1.SQL 递归 CTE 最慢,比 Lua 慢 33 倍——每层物化+去重的开销是硬伤。递归 CTE 在 DuckDB 里真不适合当循环用。
- 2.Lua 和 C 打平,Lua 甚至略快——duckdb-luajit 里的 Lua 是 LuaJIT,fib 循环本身就被 JIT 编译成了机器码,逼近原生速度;而 TCC 路径多了函数指针转换和字符串往返的开销。
- 3.TCC 编译一次 0.5ms,复用后每次调用约 0.15ms——所以 TCC 的价值不在「比 Lua 快」,而在「能现场生成/修改 C 代码」。
● ● ●
六个 bug,两个修复
三层能力测下来,每个层级都踩到了上游 bug:
tcclua 5 个 bug(已修,PR 在 github.com/nucular/tcclua/pull/3):
- 1.
tcc.load()里clib = pcall(ffi.load(...))—— pcall 返回的是 (ok, 句柄),代码只拿了布尔值 ok 当库句柄,导致tcc.clib.tcc_new()报「cannot convert 'bool' to 'struct TCCState *'」。 - 2.
tcc.home_path = tccdir—— tccdir 是未定义变量,传入的 home_path 被静默丢弃。 - 3.
ffi.metatype("TCCState", ...)每次 load 都无条件调用——LuaJIT 的 metatype 一旦设置就变成 protected,第二次 load 直接崩「cannot change a protected metatable」。用 pcall 包住,让 tcc.load() 幂等。 - 4.
tcc.new()里引用tcc.tccdir(未定义)和参数addpaths(拼写错误,签名是 add_paths)——home_path 初始化分支从来没执行过。
sfxr 1 个 bug(已修):exportWAV 内部 6 处引用不存在的 freq/bits。
还有两个使用层面的发现(不算库 bug,但容易踩):
- ●TCC 每行新建 state + 编译 = 批量崩溃。10 万行每行编译一次,N≥500 就开始段错误——资源泄漏累积。正解是编译一次、复用多次。
- ●复用的 C 函数指针不能跨线程传。duckdb-luajit 的标量 UDF 是 per-thread Lua 状态,模块级缓存的编译结果在第二个线程调用时报「number expected, got cdata」。存储过程场景直接每次编译(0.5ms 可接受),或按线程缓存。
● ● ●
这意味着什么
三层能力,从近到远:
三层能力架构图
第一层:纯 Lua 算法库零门槛接入。 1536 行的音效合成器,dofile 一行就能在 SQL 里用。农历查表、音效合成、任何纯 Lua 算法——不需要编译,不需要 C 库。
第二层:FFI 让整个 C 生态可及。 任何导出 C 符号的库都能接。libtcc 只是最狠的验证——编译器本身都能被请进来。
第三层:现场编译 C = 动态代码生成。 不只是调库,是 SQL 里生成机器码。数值方法、模板化算法、运行时才知道的参数,一次编译多次执行。Lua 的 JIT 已经让「Lua 慢」成了伪命题——纯计算场景 Lua 和 C 打平。
库的边界 bug 是常态,交叉验证是唯一解药。 农历这个案例里,DuckDB 错、ICU 错(两条官方 issue 未修)、两个独立农历库一致、天文引擎裁决——没有四方交叉验证,单靠一个参考库根本不敢下结论。tcclua 的 5 个 bug、sfxr 的 1 个 bug 也是同理:不真跑一遍,你不会知道 pcall 的返回值被用错、WAV 导出引用着不存在的变量。
● ● ●
结尾
今天的完整链路:发现 DuckDB 农历 357 天错误 → 提 issue #24581 → 验证纯 Lua 音效合成(修 sfxr 1 bug)→ 实测 SQL 里编译 C(修 tcclua 5 bug 发 PR #3)→ fib 三路对比。所有数字都是本机实测,复现 SQL 都在 issue 里。
最后说一句关于「SQL 里编译 C」的现状:社区已经有人做了专门的事——ducktinycc 扩展(github.com/sounkou-bioinfo/DuckTinyCC,已进 DuckDB 社区扩展目录),通过 TinyCC 进程内 JIT 编译 C UDF,支持 struct/union/enum 代码生成和复合类型桥接,比我在 Lua 层做的 tcclua 验证完整得多。如果你要的是生产级「SQL 里写 C」而且你不在 Windows 上,直接用 ducktinycc。
但注意 ducktinycc 的 excluded_platforms 明确排除了 windows_amd64 和 windows_arm64——Windows 用户用不了它。而 duckdb-luajit 支持标准 Windows 构建(只排除 mingw/rtools 工具链变体)。所以「在 DuckDB 里跑 C」这条路,Windows 上目前只有 Lua 桥接这条:duckdb-luajit + LuaJIT FFI + libtcc(TinyCC 本身跨平台,Windows 有原生构建)。如果你想在 Windows 的 DuckDB 里跑 C,duckdb-luajit 是能用的那条路。
两条路我都实测过(ducktinycc 的 API 我读过官方文档,tcclua 是实测跑通),各有各的位置:要完整代码生成选 ducktinycc,要 Windows 支持或要接入 Lua 生态(农历、音效、任何纯 Lua 算法)选 duckdb-luajit。
如果你也在 DuckDB 里折腾过 Lua 扩展或者农历,评论区聊聊你遇到的最离谱的边界 bug?跑通这三层能力的配方我已经整理好,需要的可以看 PR 描述或直接问我。
DuckDB issue: github.com/duckdb/duckdb/issues/24581
tcclua PR: github.com/nucular/tcclua/pull/3
ducktinycc: github.com/sounkou-bioinfo/DuckTinyCC
sfxrlua: github.com/nucular/sfxrlua