不装二进制也能查 40 种数据库:把 usql 编译进 DuckDB 的 Lua 里
上一篇写 dbcli 接 usql:DuckDB 里查外部数据库,靠的是在用户机器上装一个 usql 二进制,每次查询拉一个子进程。能用,但有两个绕不开的成本:一个 292MB 的二进制要分发到每台机器,每次查询 30 到 100 毫秒的进程冷启。
这篇讲另一条路:不装任何东西。把 usql 的核心编进 DuckDB 的 Lua 里,连接常驻在进程内,实测持续查询 0.03 毫秒一次(3 轮,每轮 200 次查询共 6.1 到 6.6 毫秒)。
● ● ●
为什么能省掉那个二进制
关键是一个很多人忽略的事实:usql 的每个驱动都是一个标准的 database/sql driver。
usql 是个 Go 项目,它的 drivers/ 目录下 40 多个驱动,每个在 init() 时向 Go 标准库 database/sql 注册自己。这意味着你不需要 usql 的 CLI、REPL、查询解析器那一整套——那些是给人用的。程序内嵌时,你只需要:
db, _ := drivers.Open(ctx, url, nil, nil) // 返回一个 *sql.DB
db.Query("SELECT 1")
database/sql.DB 是长命的、可复用的。一次 Open 建连接,之后 Query 多少次都不再有进程冷启。这正是"装二进制"方案每查询都在付的冷启成本,在这里被彻底消掉了。
● ● ●
架构:三层,各自该干的事
整条链路分三层,每层只做一件事:
三层链路:DuckDB 里的 SQL 调用经 Lua FFI 打到 Go 桥,再落到外部数据库
Go 桥(独立仓 alitrack/usql-bridge)。 用 cgo 导出五个函数 usql_connect / usql_query / usql_exec / usql_export / usql_close。内部维护一个 map[int]*sql.DB,连接按 int id 常驻复用。usql_connect 里调一次 db.Ping(),把冷启动前置到建连那一刻,让第一个真正的查询不背冷启。编译成 c-shared 动态库,go build -buildmode=c-shared。前四个是 JSON 通道(下面说),usql_export 是原生 Parquet 通道。
Lua FFI 桥(labs 里的 libs/db/usql.lua)。 一个 .lua 文件,ffi.load 上面那个 Go 库,把四个函数包装成 DuckDB 的表函数。
DuckDB luajit 扩展。 早就有的东西,Lua 在 DuckDB 里跑、ffi 可用这件事它已经保证了。它现在已经进了 DuckDB 的社区扩展仓库,INSTALL luajit FROM community 一句就能装上。
所以"加一个 in-process 数据库连接"的最终交付物是:一个 .so + 一个 .lua。没有新的 DuckDB 扩展,没有 292MB 的二进制。(v0.1.1 是 11MB;v0.2.0 加了原生 Parquet 输出,涨到 20.8MB——这一跳的代价后面单独说。)
● ● ●
那个 .so 从哪来
这里有个工程决策值得说。labs 这个仓库的约定是"每个库一个自包含的 .lua 文件,luajit_module(mode:='install') 从 raw.githubusercontent 拉下来即用"。纯文本,零二进制。
Go 桥是个二进制,塞进 labs 会破坏这个约定。所以它单独成仓 alitrack/usql-bridge,按 GitHub release 发工件。
覆盖全平台这件事,本机脚本干不了。main.go 里有 import "C",CGO_ENABLED=0 直接编不过;darwin 的动态库又没法在 Linux 上交叉编译,它要 Mach-O 链接器加 Apple SDK。所以构建放到 CI 的原生 runner 矩阵上,四个 runner 出五个工件:
ubuntu-latest→ linux amd64, ubuntu-24.04-arm→ linux arm64macos-14→ darwin arm64 和 amd64 两个目标 windows-latest(走 msys2 mingw)→ windows amd64
每个工件编完,立刻在它自己的平台上跑一次冒烟测试:用 Python 的 ctypes 加载这个库,连一个临时 SQLite 库,建表、写数据、查回来。ctypes 和 LuaJIT FFI 走的是同一条 C ABI,所以这一步能真的拦住"编过了但加载不了"这种情况。macOS 上那份 x86 是在 Rosetta 下真跑的,不是只看文件头。五个工件之外再发一份 SHA256SUMS。
顺带用 -ldflags "-s -w" 把符号表和调试信息剥掉,工件从 15MB 降到 11MB。
labs 里的 usql.lua 负责在运行时把 .so 找出来,解析顺序是:
- 01
spec 里的 lib字段(显式指定路径,最高优先,调试用) - 02
环境变量 USQL_BRIDGE_LIB - 03
~/.duckdb/luajit-libs/目录下的平台工件名(install 的缓存目录,按 jit.os和jit.arch拼出文件名) - 04
以上都没有, curl从 GitHub release 拉一次到缓存目录
第 4 步我一开始只写成"best-effort",后来发现它有个真问题:这条 CDN 会静默截断。 同一个 11MB 的文件,有一次实测拉了 6 分 25 秒只落地 7.6MB,重试直接 0 字节;而同一时间 raw.githubusercontent(Lua 文件走的那条)1 秒就返回。旧代码只校验"文件大于 1MB"就认,半截文件照样当成功,dlopen 一个截断的 ELF 必然失败。
改了两处:下载完按平台认头 4 字节魔数(ELF 的 \x7fELF、PE 的 MZ、Mach-O 的 CF FA ED FE),对不上就删掉重来;下载基址做成可覆盖的 USQL_BRIDGE_BASE_URL,走镜像就能绕开。用 gh-proxy 镜像实测:11MB 的工件 2.3 秒拿到,sha256 和 release 里的 SHA256SUMS 完全一致。国内网络卡在 release CDN 上的时候,指到镜像就行。
● ● ●
一个真实的坑:闭包捕获不到后面的 local
写 usql.lua 时踩了个 Lua 的坑,值得记一下。我一开始把解析 spec 用的 json 库加载函数写在了文件前部,它内部要调 cache_dir() 拼路径。但 cache_dir 是个 local function,定义在 json 加载函数后面。
Lua 的闭包只能捕获定义时已经在作用域里的 local。load_json 定义那一刻,cache_dir 这个 local 还不存在,所以它内部引用的 cache_dir 被当成全局变量去找——运行到那里时当然是 nil,一调用就炸。
修法很简单:把依赖的 local 定义放到前面。但这类"定义顺序决定闭包捕获"的问题不报错在定义处,报错在调用处,且症状是 nil 而不是"undefined",排查时容易走偏。顺带一提,这也是为什么后来我把 spec 解析整个内联成一个极简解析器(只认扁平的 {"k":"str","n":123}),省掉了对 json 库的依赖,usql.lua 真正自包含。
● ● ●
实测
环境:Go 1.26、DuckDB 1.5.5、luajit ELF 扩展、SQLite(via usql 的 moderncsqlite 纯 Go 驱动,免 CGO)。延迟数字是 3 轮实测取区间。
id=1 | |
count/sum | |
BLOB 字节 / DATE / BOOLEAN / BIGINT | 00FF000A0D010203DATE 仍是 DATE、BOOLEAN 仍是 BOOLEAN、9007199254740993 精确;10 万行竖线一个不少 |
表里「200 次持续查询」那行是核心卖点:同一个连接连跑 200 个查询,没有一次是 30 毫秒起步的进程冷启。(作为口径参照:本机 duckdb CLI 的软链指向的是 2.0 alpha,这篇所有数字都是在 ~/.duckdb/cli/1.5.5/duckdb 上跑出来的。)
● ● ●
JSON 是给人看的,不该拿来搬数据
上面那些 JSON 行,本质是显示通道。usql 自己的输出是终端表格(它的输出层是 xo/tblfmt,encoder 只有 Table / Expanded / JSON / CSV / Unaligned / HTML 那几种,没有 Parquet 也没有 Arrow),我们把同样的思路搬进进程内:一行一个 JSON 对象,人读方便,程序接也方便。
但 JSON 只有 number / string / bool / null 四种值类型,数据库那一侧的信息丢了不少。我一开始怀疑是 usql 丢类型,实测一轮发现不是它丢,是这条通道丢:driver 层一直好好带着 ColumnTypes()(声明类型 + 可空 + decimal 精度),丢在"值变文本 + DuckDB 侧 from_json 再推断回来"这两步。实测一张 10 列的表:DATE 和 DATETIME 回来是 VARCHAR,BOOLEAN 变成整数,DECIMAL 变 DOUBLE,INTEGER 被推断成 UBIGINT(符号没了)。更糟的是字节:BLOB 里的非 UTF-8 字节(比如 0xFF)在文本化时变成 U+FFFD,不可逆——数据已经错了,后面怎么处理都救不回来。
所以 v0.2.0 给桥加了一条列式通道:op=export 按 driver 声明的列类型直接建 Parquet schema,值原样写盘,返回文件路径,DuckDB 侧 read_parquet 零解析。用法就一行:
-- 表函数形态:返回文件路径
SELECT val FROM luajit_table('usql', list :=
'{"op":"export","id":1,"sql":"SELECT * FROM src","format":"parquet"}');
-- 标量形态:直接喂 read_parquet(quick_compile 生成的 usql() 宏)
SELECT * FROM read_parquet(usql('{"op":"export","id":1,"sql":"SELECT * FROM src"}'));
path 不写就落系统临时目录,连接 close 时自动删掉;写了就归调用方管。compression 默认 snappy(同一份 10 万行 × 10 列,不压缩 6.8MB,snappy 后 2.35MB)。类型直通表就是 driver 的声明类型:INTEGER/BIGINT → BIGINT、REAL/DOUBLE → DOUBLE、TEXT/VARCHAR → VARCHAR、BLOB/BYTEA → BLOB、BOOLEAN → BOOLEAN、DATE → DATE、DATETIME/TIMESTAMP → TIMESTAMP。未知类型和全 NULL 列一律兜底成 VARCHAR,不给"没有类型的列"猜数值类型——猜错就是静默错数据。已知边界:DECIMAL/NUMERIC 目前也映射成 DOUBLE,超 15 位有效数字的金额精度不保,要严格 decimal 得再走 decimal(scale,precision)。
效果对比(同一份 10 万行 × 10 种声明类型,都含驱动读取,DuckDB 1.5.5 + 社区 luajit):
| 0.38 到 0.40 秒 | ||
| 2.35MB | ||
from_json + unnest 展开 | read_parquet | |
BLOB 不可逆损坏 |
代价只有一条,说清楚:工件从 10.7MB 涨到 20.8MB(parquet-go + snappy 编解码编了进去)。这笔账我认为划算——省下的是每次查询在 JSON 通道上反复付的编解码和跨语言拷贝。
● ● ●
这一跳踩的两个坑
parquet-go 的 date 节点吃的是 epoch 天数,不是 time.Time。 我按直觉把 DATE 列映射成 *time.Time 配 date 标签,写完读回来是 5461899-03-14 (BC)。原因是它把 time.Time 的 Unix 秒直接写进了按天计数的字段里,溢出一圈。改成自己把日期换算成 int32 天数(date 逻辑类型)就对了。这类坑的恶心之处在于类型是对的、值是错的,不看值就发现不了。
同一个库文件,DuckDB 会用两种形状调它。luajit_table('usql', list := '…') 的 list 参数传进来是字符串;而 quick_compile 自动生成的 usql('…') 宏走的是 luajit_vs,传进来是一张表(那个参数按 chunk 批的值),还要求按行返回一张表。我一开始只按字符串处理,于是宏一调就报 attempt to call method 'match' (a nil value)——因为 parse_spec 收到的是 table 不是 string。现在入口按类型分流,两种形态都能用,read_parquet(usql(...)) 那一行才写得出来。
驱动覆盖目前是 SQLite 一个。不是 usql 不行——它是编译期决定的:要支持哪个库,在 Go 桥的 main.go 里加一行驱动导入(import _ github.com/xo/usql/drivers/postgres 这种),重新编译发布 .so。要 postgres 就 import drivers/postgres,全驱动就 import 全部。这是"快"和"全"之间的取舍:编进去的越多,.so 越大、编译越慢;按需编,每个库一个针对性的小 .so。
● ● ●
两条路怎么选
.so 随 release 拉 | ||
-tags most 全编进一个二进制) | ||
不冲突。上篇那个方案的价值是"一条命令覆盖 40+ 库、用户装了个通用工具",适合什么库都可能碰一下的场景。这篇的价值是"产品化地把某几个库的连接常驻在 DuckDB 进程里",适合我的数据产品那种——语义层服务端分发,客户端就该只有一个 DuckDB 进程,不该再让他装东西。
代码:Go 桥在 alitrack/usql-bridge(MIT,release 已覆盖 linux/darwin/windows 五个工件),Lua 库在 alitrack/duckdb-luajit-libs 的 libs/db/usql.lua。复现和实测输出都在仓库里。
● ● ●
参考来源
- 01
usql-bridge(Go 桥,MIT):https://github.com/alitrack/usql-bridge - 02
duckdb-luajit-libs(Lua 库集):https://github.com/alitrack/duckdb-luajit-libs - 03
DuckDB 社区扩展仓库中的 luajit:https://github.com/duckdb/community-extensions/tree/main/extensions/luajit