duckdb-luajit:Lua 里调 Go,比 Lua 原生慢 4 倍
前几天翻到两篇讲「Lua 调 Go」的文章,顺手在自己维护的 DuckDB LuaJIT 扩展里试了一把。
结论先放这:链路是通的,冷启动两三毫秒,热调用几微秒;但做简单的字符串替换,Go 比 Lua 原生慢 4 倍。
● ● ●
我在试什么
我维护一个叫 duckdb-luajit 的 DuckDB 扩展,把 Lua 编译进数据库里当 UDF 用。扩展本身很轻,1MB。LuaJIT 自带 FFI,意思是 Lua 代码里可以 ffi.load 任意动态库——扩展市场里的线性代数库(LAPACK)、优化求解器(HiGHS)都是这么挂进去的。
那 Go 呢?
Go 能编译成 C 共享库(-buildmode=c-shared),而 FFI 能加载 C 库。理论上这条路通。但理论和实测之间,往往隔着一个坑。
参考材料有两篇:
Calling Go functions from LUA,2017 年,作者踩了用 FFI 声明 Go 结构体的坑
https://titpetric.com/2017/03/13/calling-go-functions-from-lua/
Mixing Go and Lua,2024 年,speedata Publisher 作者的生产实践
https://news.speedata.de/2024/02/21/mixinggoandlua/
第二篇的建议很实用:接口签名直接用 C.char,别用 Go string。因为 Go string 在 C 眼里是个 {ptr, len} 结构体,跨语言传一趟要转换两次;用 C.char 就省了。
● ● ●
PoC:三步走
第一步,写 Go 库。 只导出两个函数:一个调 regexp.ReplaceAllString,一个排序拼接。
package main
import "C"
import (
"regexp"
"sort"
"strings"
)
//export sdReplace
func sdReplace(text *C.char, re *C.char, replace *C.char) *C.char {
reC, err := regexp.Compile(C.GoString(re))
if err != nil {
return C.CString("ERR_REGEX: " + err.Error())
}
return C.CString(reC.ReplaceAllString(C.GoString(text), C.GoString(replace)))
}
//export sdSortJoin
func sdSortJoin(items *C.char, sep *C.char) *C.char {
parts := strings.Split(C.GoString(items), ",")
sort.Strings(parts)
return C.CString(strings.Join(parts, C.GoString(sep)))
}
func main() {}
编译一行:go build -buildmode=c-shared -o replacer.so main.go。
第二步,Lua 侧声明并加载。
local ffi = require('ffi')
ffi.cdef[[
extern char *sdReplace(const char* text, const char* re, const char* replace);
extern char *sdSortJoin(const char* items, const char* sep);
extern void free(void* ptr);
]]
local lib = ffi.load('/path/to/replacer.so')
第三步,在 DuckDB 里当 UDF 调。
SELECT luajit_s('gopoc', {op: 'replace', text: 'abc123def456', re: '\d+', repl: 'N'});
-- → abcNdefN
全通。整条链路长这样:
调用链:SQL → LuaJIT → FFI → Go 共享库
● ● ●
踩到的坑:ffi.C.free 要先声明
Go 返回的是 C.CString,这块内存得在 Lua 侧 ffi.C.free(ret) 释放。我第一版没声明 free,直接报错:
missing declaration for symbol 'free'注意,报的不是「找不到符号」。LuaJIT FFI 要求 ffi.C 命名空间里任何 C 函数都先在 cdef 里声明过,free 也不例外。补一行 extern void free(void* ptr); 就好了。
● ● ●
实测数据
环境:go1.25.8,DuckDB 1.5.5,Linux x86-64。计时用 Lua 的 os.clock(),每个数字跑 3 轮取区间。
ffi.load | 1.9–3.1ms |
| 4–8µs/次 | |
\d+ 替换:Go regexp | 14.3–15.9ms |
\d+ 替换:Lua 原生 gsub | 3.6–3.8ms |
冷启动那点是意外收获。Go 共享库自带整个 runtime,我原本预计首次加载要几百毫秒。实际 Go 的 c-shared 是懒初始化,几毫秒里大部分还是 FFI 绑定的开销。
然后是微基准。10000 次 \d+ 替换,Lua 原生 gsub 比 Go regexp 快约 4 倍。
原因不复杂:gsub 是 Lua 解释器里的 C 实现,零跨语言开销;我这次的 Go 版每次调用都重新 regexp.Compile 一遍,还得过 FFI 边界。
● ● ●
把这次实测和两篇参考文章的结论对齐,判据挺清晰:
不值得:简单替换、匹配、字符串处理。Lua 原生 gsub/match 更快,还省一个 .so。
值得:Lua pattern 的能力缺口。Lua 正则没有 lookahead、lookbehind、后向引用,这些在日志解析、复杂文本抽取里是刚需;再就是 Go 标准库独有的能力,比如 XML、Unicode 处理、网络客户端。
判据:什么时候值得挂 Go 库
还有一点工程上的:真要走这条路,把 regexp.Compile 提到库加载时做一次,别每次调用都编译。这次 PoC 为了代码短是每次编译的,基准数字偏保守。
● ● ●
复现
PoC 是 5 个文件:main.go、gopoc.lua、test_go_poc.sql、bench_go_poc.sql、run.sh。扩展是 Linux ELF,得在 WSL 或 Linux 下跑。
bash run.shrun.sh 干三件事:go build 出 replacer.so、nm -D 查符号、duckdb -unsigned 跑冒烟测试和冷启动计时。整套不到 1 秒。
延伸:speedata 那篇还讲了更激进的做法——Go 函数直接实现 Lua C API 签名(func f(L C.lua_State) C.int),省掉中间层,在 Go 里直接操作 Lua 栈建表。性能上限更高,但代码开始难看了。等有真实需求再说。*
链路能通,不代表该用。这条 Go 路我只会用在 Lua 真缺能力的地方。
需要这套 PoC 文件的,留言或私信我。你要是在 DuckDB 里挂过别语言的库,或者碰到过 Lua pattern 搞不定的正则,也欢迎留言说说。
● ● ●
参考来源
- 01
Calling Go functions from LUA
https://titpetric.com/2017/03/13/calling-go-functions-from-lua/
- 01
Mixing Go and Lua — speedata Publisher
https://news.speedata.de/2024/02/21/mixinggoandlua/
- 01
duckdb-luajit(本文用的 DuckDB LuaJIT 扩展)
https://github.com/alitrack/duckdb-luajit
- 01
duckdb-luajit-libs(扩展市场,linalg/optimize 等库就在这)
https://github.com/alitrack/duckdb-luajit-libs