alitrack

不装二进制也能查 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 桥,再落到外部数据库

三层链路: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 arm64
  • macos-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 找出来,解析顺序是:

  1. 01
    spec 里的 lib 字段(显式指定路径,最高优先,调试用)
  2. 02
    环境变量 USQL_BRIDGE_LIB
  3. 03~/.duckdb/luajit-libs/
     目录下的平台工件名(install 的缓存目录,按 jit.os 和 jit.arch 拼出文件名)
  4. 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 轮实测取区间。

项
结果
connect + Ping
id=1
,冷启前置到此
CREATE / INSERT / SELECT / 聚合 / 写读回 / close
全对
单次查询延迟
0.031 到 0.033ms(3 轮 × 200 次,含 FFI 跨语言 + JSON 编码)
200 次持续查询
6.1 到 6.6ms,约 0.03ms 每次,无冷启无退化
10 万行 × 10 种声明类型,走 JSON
19.2MB 文本,0.70 到 0.76 秒
10 万行 × 10 种声明类型,走原生 Parquet
0.38 到 0.40 秒,文件 2.35MB(snappy 压缩)
读回该 Parquet 文件做 count/sum
0.002 秒
保真:BLOB 字节 / DATE / BOOLEAN / BIGINT
00FF000A0D010203
 字节精确;DATE 仍是 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):

JSON 通道
原生 Parquet
桥调用一次
0.70 到 0.76 秒
0.38 到 0.40 秒
跨 FFI 的数据量
19.2MB 文本
一行路径
产物
19.2MB
2.35MB
(snappy)
DuckDB 侧
还要 from_json + unnest 展开
read_parquet
 直接读(聚合 0.002 秒)
类型与字节
靠推断,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。

● ● ●

两条路怎么选

dbcli + usql 二进制(上篇)
usql-bridge in-process(本篇)
安装
用户机器装 292MB 二进制
不装,.so 随 release 拉
查询延迟
每查询进程冷启 30-100ms
进程内常驻 0.03ms
驱动覆盖
40+(-tags most 全编进一个二进制)
编译期决定,按需
适用
低 QPS、一次性、跨 40+ 库的通用分析
固定几个库、要快、不想让用户装东西

不冲突。上篇那个方案的价值是"一条命令覆盖 40+ 库、用户装了个通用工具",适合什么库都可能碰一下的场景。这篇的价值是"产品化地把某几个库的连接常驻在 DuckDB 进程里",适合我的数据产品那种——语义层服务端分发,客户端就该只有一个 DuckDB 进程,不该再让他装东西。


代码:Go 桥在 alitrack/usql-bridge(MIT,release 已覆盖 linux/darwin/windows 五个工件),Lua 库在 alitrack/duckdb-luajit-libs 的 libs/db/usql.lua。复现和实测输出都在仓库里。

● ● ●

参考来源

  1. 01
    usql-bridge(Go 桥,MIT):https://github.com/alitrack/usql-bridge
  2. 02
    duckdb-luajit-libs(Lua 库集):https://github.com/alitrack/duckdb-luajit-libs
  3. 03
    DuckDB 社区扩展仓库中的 luajit:https://github.com/duckdb/community-extensions/tree/main/extensions/luajit