在 DuckDB 里执行 Wasm:把 Rust/Go 编译进 SQL
duckdb-luajit 的 JS 篇实测了 QuickJS 内嵌(SQL 里跑 JavaScript,单次 57.9 μs)。
这篇是第二块拼图:SQL 里执行 WebAssembly——QuickJS 只解决 JS,Wasm 解决「任意语言」:Rust、Go、C 编译成 wasm 模块,直接在 DuckDB 查询里调用,还自带沙箱隔离。
● ● ●
为什么是 Wasm
SQL 里想复用一段已有逻辑,通常三条路:重写成 SQL/Lua(成本高)、起子进程(慢 3 个数量级)、内嵌解释器。Wasm 是第四条:语言无关 + 沙箱安全——编译期确定边界(模块只能访问显式导入的函数和内存),运行时隔离,不可能碰宿主进程的文件系统(除非你显式接入 WASI)。
而且生态是现成的:Rust 的 wasm32-wasip1 target、Go 的 GOOS=wasip1、C 的 wasi-sdk,编译出来都是同一个 .wasm 格式。
● ● ●
引擎选型:wasm3(嵌入式优先)
主流选项里,wasmtime(字节码联盟)是 JIT 引擎、性能最好,但它的 Linux 发布包只有 CLI,不带 C API 库——要用得自己 cargo 编译(10 分钟起步)。wasm3 是为嵌入式设计的解释器:单文件源码、213 KB 的 .so、cmake 秒编,和上一篇 QuickJS(1.1 MB、源码秒编)定位完全对称——轻量内嵌引擎。
选型结论:duckdb-luajit 的 ffi 语境下,wasm3 是"装上就能跑"的那个。
● ● ●
实现:三层,30 行
第一层:Rust 编译 wasm 模块(no_std 零依赖,594 KB):
#![no_std]
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 { a + b }
#[no_mangle]
pub extern "C" fn fib(n: i32) -> i32 { if n < 2 { n } else { fib(n-1) + fib(n-2) } }
rustc --target wasm32-wasip1 -O -C panic=abort --crate-type cdylib -o math.wasm math.rs
第二层:30 行 C 桥(wasm3 的 m3_LoadModule 等 API 走 FFI 有 ABI 细节,薄桥消化掉,只暴露一个函数):
const char *wasm3_call(const char *path, const char *func,
const int32_t *args, int32_t nargs, int32_t *out);
第三层:SQL 3 行注册 UDF:
SELECT * FROM luajit_module(mode := 'compile', sql_name := 'wadd',
source := 'return function(x) ... ffi.load("libwasm3_bridge.so") ... end');
SELECT luajit_s('wadd', 'x'); -- 5
● ● ●
实测用例
① 加法:wasm3_call("math.wasm", "add", {2,3}) → 5
② 递归斐波那契(wasm 内计算):fib(10) → 55
③ 质数判断:is_prime(10) → 0,is_prime(17) → 1
所有计算发生在 wasm 沙箱里,DuckDB 只拿到结果字符串。
● ● ●
社区对比:ducklink(wasmtime 范式)
DuckDB 社区里其实有 wasm 执行扩展——ducklink(tegmentum,MIT,本周社区扩展库可查)。它走的是完全不同的路线:把 wasmtime(Cranelift JIT)+ WASM Component Model 嵌入 DuckDB,用 duckdb:extension WIT 合约定义整个 DuckDB 扩展(标量/表/聚合函数、在线目录、系统视图),组件构建一次到处运行。定位是「DuckDB 扩展开发范式重构」——用 wasm 写扩展本身,而不是在 SQL 里调用 wasm 函数。
同机实测(DuckDB v1.5.5,ducklink v4.6.1 源码自编译,10 万次真实调用、列参数防折叠,ABA 路由号校验函数):
| 方案 | 引擎 | 10 万次 | 单次 |
|---|---|---|---|
| ducklink | wasmtime JIT + Component Model | 3.71 s | ~37 μs |
| 本文 wasm3 桥 | wasm3 解释器 | 0.50 s | ~5 μs |
wasm3 桥快约 7 倍。差异来源:ducklink 每行调用走 wasmtime + component canonical ABI(WIT 值编解码、实例边界),本文桥是最简 i32 直通。但 ducklink 的能力面大得多——任意 DuckDB 类型、表/聚合函数、在线组件目录;本文桥目前只做标量 i32。两条路不是替代关系:ducklink 是给「想用 wasm 写整个扩展」的人,本文桥是给「SQL 里想调用一段 wasm 计算」的人。
(实测注记:ducklink v5.0.0 主机已升级 WIT contract 5,但组件生态还在 contract 4——v4.6.1 是当前能加载现成组件的版本,且目标 DuckDB 恰为 v1.5.5,编译时把 libduckdb-sys 对齐到 1.10505.0 即可。)
● ● ●
性能:约 5 μs / 次
10 万次调用(每次读模块文件 + 加载 + 执行 + 释放)全部成功,总耗时 0.5 秒,单次约 5 μs——比 QuickJS 桥的 57.9 μs 还快一个数量级(wasm3 的 runtime 创建比 QuickJS 轻,模块解析走预编译)。优化空间依然明确:复用 runtime 缓存模块可再快。
● ● ●
边界与选型建议
- 桥目前支持 i32 参数/返回(0–3 个参数),i64/f64 和内存共享是扩展方向;
- 大模块别用 wasm3:实测 Go 编译的 1.68 MB 模块在 wasm3 上加载即崩(模块太大、特性太新),wasm3 适合中小模块,大模块换 wasmtime/wasmer;
- wasm 模块无 DOM/系统调用(除非接入 WASI),这是特性不是缺陷——SQL 里的代码就该无副作用。
● ● ●
系列小结
至此 duckdb-luajit 的 SQL 脚本能力拼图齐了:Lua UDF(原生)+ JavaScript(QuickJS 内嵌)+ WebAssembly(wasm3 沙箱)。三种语言、三个引擎,全部 ffi.load 一个 .so 解决,加起来不到 100 行桥代码。
实测环境:WSL2 Ubuntu,duckdb v1.5.5,duckdb-luajit v0.30,wasm3(嵌入式解释器),Rust no_std wasm32-wasip1。所有数字为本地实测。