Ohhnews

分类导航

$ cd ..
foojay原文

在GraalVM原生镜像中运行DuckDB的JDBC驱动程序

#duckdb#graalvm#jdbc#java#原生镜像

DuckDB 是一款进程内分析型数据库。你只需向 Java 项目添加一个 JAR 包,就能获得一个可直接读取 CSV、Parquet 和 JSON 文件的 SQL 引擎,且无需运行任何服务器。GraalVM Native Image 则能将 Java 程序提前编译成独立的可执行文件,启动时间仅需毫秒级,目标机器上也不需要 JVM。将两者结合起来,你就能得到一个像 Go 或 Rust 程序那样以单个二进制文件分发、底层却使用 Java 库的数据工具。

在 duckdb-java 仓库中有一个未关闭的 issue,#180,自 2025 年 3 月以来一直有人在问这种方法是否可行。早期尝试以 UnsatisfiedLinkError 失败,维护者也指出,以当时驱动程序的状况,提前编译不太可能成功。这篇文章将说明,使用当前版本这种做法确实可行,解释以前失败的原因,并提供一个你几分钟内就能构建的 hello world 示例。

Hello World 示例

将以下内容保存为 Hello.java

import java.sql.*;

public class Hello {
    public static void main(String[] a) throws Exception {
        Class.forName("org.duckdb.DuckDBDriver");
        try (Connection c = DriverManager.getConnection("jdbc:duckdb:");
             Statement s = c.createStatement();
             ResultSet r = s.executeQuery("SELECT 42 AS answer, version() AS v")) {
            while (r.next()) System.out.println(r.getInt(1) + " " + r.getString(2));
        }
    }
}

显式的 Class.forName 很重要。JDBC 通常会通过 ServiceLoader 发现驱动程序,而这种查找并不总是能被 Native Image 的静态分析所识别。直接指定类名可以避免这个问题。

从 Maven Central 将驱动 JAR 包 duckdb_jdbc-1.5.5.0.jar 下载到同一目录。然后,在 PATH 中配置好 GraalVM 后执行:

sdk use java 25.0.2-graalce
JAR=duckdb_jdbc-1.5.5.0.jar
javac -cp $JAR Hello.java

mkdir -p META-INF/native-image/hello
java --enable-native-access=ALL-UNNAMED \
  -agentlib:native-image-agent=config-output-dir=META-INF/native-image/hello \
  -cp $JAR:. Hello

native-image --no-fallback --enable-native-access=ALL-UNNAMED \
  -cp $JAR:. -H:ConfigurationFileDirectories=META-INF/native-image/hello -o hello Hello
./hello

输出结果:

42 v1.5.5

步骤是:编译,在追踪代理下运行一次,使用记录的元数据构建原生镜像,然后运行二进制文件。在 Apple Silicon Mac 上构建大约需要 20 秒。可执行文件约为 120 MB,其中 108 MB 是 DuckDB 引擎本身。

代理记录了哪些内容

驱动程序是建立在 C++ 库之上的薄薄一层 Java 代码,双方通过 JNI 通信。Native Image 需要提前知道 C++ 代码会反向访问哪些 Java 类和字段,因为未声明的任何内容都会从镜像中移除。追踪代理会观察一次真实运行过程,并将这些信息写入列表。

打开 META-INF/native-image/hello/reachability-metadata.json,查看 jni 部分。在这个驱动版本中,它包含 org.duckdb.* 下的大约 18 个条目:结果集元数据类、日期和时间戳类型、vector 和 struct 类、标量和表函数包装器,以及其他一些类型。这些就是当引擎从 C++ 侧将结果交还给 Java 时所构造的类型。

文件末尾附近有一个资源条目:

{ "glob": "libduckdb_java.so_osx_universal" }

驱动 JAR 包中包含每个平台各一个的原生库:

 60780968  libduckdb_java.so_linux_amd64
108682352  libduckdb_java.so_osx_universal
 35089408  libduckdb_java.so_windows_amd64
 53584160  libduckdb_java.so_linux_arm64

代理记录的是它实际使用的那一个,Native Image 会将该文件作为资源嵌入可执行文件。如果你要在与追踪时不同的平台上构建,请将 glob 替换为对应的名称。如果使用能匹配全部四个平台的通配符,每个二进制文件都会额外增加约 150 MB,因此建议使用具体名称。

之前为什么失败

原始 issue 报告中的错误包含了原因:

java.lang.UnsatisfiedLinkError: Can't load library: <...>/build/debug/libduckdb_java.so_osx_universal

报告者是在 Ubuntu 上构建的,但路径中却包含 build/debug,这是驱动程序开发检出目录中的一个文件夹。这个路径并非来自报告者的机器。它是在驱动程序 JAR 包编译时计算出来,然后被固化到原生镜像中的。

背后的机制是类初始化时机。当 Native Image 构建程序时,它会快照堆,并针对每个类决定是在构建期间运行静态初始化器并存储结果,还是将该初始化器留到二进制文件启动时再运行。org.duckdb.DuckDBNative 有一个静态初始化器,负责定位原生库,如果需要则将其从 JAR 包解压到临时目录,并通过绝对路径调用 System.load

GraalVM for JDK 17 在其分析认为安全时,会在构建期间执行此类初始化器。因此路径字符串是在构建机器上计算出来并固化到二进制文件中的。当二进制文件在其他机器上运行时,System.load 收到的是一个不存在的路径。

从 GraalVM for JDK 22 开始,默认行为发生了反转。除非构建过程能证明初始化器没有副作用,否则应用类会在运行时初始化。解压文件和加载库都属于副作用,因此 DuckDBNative 现在会在可执行文件启动时、在用户机器上进行初始化,就像在普通 JVM 上一样。

上面的脚本中无需提及这一点,因为默认行为已经处理好了。如果使用较旧的 GraalVM,你需要在构建时添加一个参数:

native-image --initialize-at-run-time=org.duckdb.DuckDBNative ...

该参数在 GraalVM 25 上没有任何效果,但能明确表达意图,如果构建工作会由使用不同版本发布版的人员维护,这会很有用。

驱动程序方面还有两项其他变化。Pull request #450 让库加载更加灵活,因此驱动程序可以先按名称从文件系统加载库,再回退到从 JAR 包中解压。另外,驱动程序在 Java 侧从未使用过反射,这也是 JNI 列表很短、其余元数据为空的原因。

一个更大的示例

Hello world 能确认构建成功,但并不能展示真实使用场景。我用同样的方法为财务团队构建了一个对账工具:它读取一天内的订单、付款和发货信息,数据格式包括 JSON lines、CSV 和 Parquet,运行若干条 SQL 检查,并输出一个异常文件。这是一个 Maven 项目,使用了 picocli 处理命令行,SQL 语句放在资源文件中,并使用前面提到的同一套可达性元数据。原生二进制文件响应 --help 只需 39 毫秒,对一万条订单执行完整检查不到 1 秒。

该项目中的一个测量结果会影响你对启动时间的规划。JVM 版本执行检查需要 1.36 秒,原生版本需要 0.95 秒。两者的差距比 --help 对比所显示的更小,因为驱动每次打开连接时都会将其 108 MB 的库解压到一个临时目录,这些磁盘写入占据了主导。对于夜间任务或交互式工具来说,这无关紧要。对于每分钟被调用数百次的场景,可以考虑从二进制文件旁边的固定路径加载库,新版驱动程序已支持这种做法。

该 issue 的现状

如今,驱动程序无需修改任何源码即可在原生镜像中运行。剩下的主要是便利性问题。JNI 元数据很小,并且各平台间完全相同,因此驱动程序可以将其随 JAR 包一起放在 META-INF/native-image/org.duckdb/duckdb_jdbc/ 目录下,Native Image 就会自动识别。提供按平台区分的分类器构件,或者提供一种文档化的方式来将库放在可执行文件旁边,都将消除最后一个手动步骤和体积开销。我已经将 hello world 发布到了该 issue 中,这样维护者就有一个可以加入 CI 的脚本。

如果 JVM 启动时间或安装 JDK 的需求曾阻止你在 Java 命令行工具中使用 DuckDB,那么这些限制如今已不再适用。


已使用 duckdb_jdbc 1.5.5.0 和 GraalVM Community Edition 25.0.2 在 macOS(Apple Silicon)和 Linux x64 上测试通过。

本文首发于 foojayRunning DuckDB's JDBC Driver in a GraalVM Native Image