DeepSeek总结的DuckDB Java 驱动程序中的分块查询结果
来源:https://duckdb.org/2026/08/21/chunked-query-results-java-driver
DuckDB Java 驱动程序中的分块查询结果
Geertjan Wielenga, Alex Kasko
2026-08-21 | 7 分钟阅读
TL;DR: DuckDB Java 驱动程序现在可以将查询结果作为按需获取的列式数据块序列返回,避免了 JDBC 逐行处理 ResultSet 及其单值开销。
DuckDB 是一个列式、向量化的数据库。引擎内部的每个运算符都处理数据块(data chunk):即每次处理 2048 行的列向量批次。这是 DuckDB 性能卓越的重要原因之一:引擎将解释开销分摊到数千个值上,而不是为每个值单独支付一次开销。
另一方面,JDBC 设计于 1997 年,其核心思想截然不同。ResultSet API 一次只返回一行(通过 next()),一次只返回一个值(通过 getInt(1)、getString(2) 等)。它是一个稳定、熟悉的 API,在整个 Java 生态系统中得到支持,但它强制要求数据采用 DuckDB 内部从未使用过的形式。
DuckDB Java 驱动程序必须在这两者之间架起桥梁。它嵌入了原生 DuckDB 库,并通过 JNI 与之通信。当引擎已经生成了一个包含 2048 行的列式数据块时,JDBC 规范要求驱动程序在你读取它之前将其切分为行和单元格。如果你的应用程序打算将这些值直接放回列式形式(例如数组、Arrow 缓冲区或机器学习特征矩阵),那么数据先被拆分成行,然后又重新组装成列,这浪费了双方的工作。
Java 驱动程序的 1.5.3.0 版本提供了一种替代方案。
提示
我们已经全面更新了 Java 客户端文档,现在通过专用页面涵盖了驱动程序的完整 API 接口:定义连接、运行查询、处理结果(包括 Arrow 方法和流式结果)、使用 Appender 和 Batch Writer 导入数据,以及定义函数。本文讨论的分块查询结果在“分块结果”(Chunked Results)部分有详细说明。
逐行读取结果
典型的 JDBC 读取循环如下所示:
try (ResultSet rs = stmt.executeQuery("SELECT a, b FROM measurements")) {
while (rs.next()) {
long a = rs.getLong(1);
double b = rs.getDouble(2);
// ...
}
}
调用 next() 会推进游标,然后调用 get* 方法在当前原生数据块中定位该值,并将其转换为 Java 表示。对于某些非基本类型,这种转换每次取值都需要跨越 JNI 边界,而一些转换则更为复杂,例如根据调用者提供的 java.util.Calendar(JDBC 规范在此处仍强制要求使用的 java.time 之前的类)来获取时间戳。这些步骤单独来看并不昂贵,但当它们乘以数百万行和若干列时,开销就累积起来了。这正是向量化执行旨在避免的单值解释开销。
该循环还存在固定的开销。为了让单个连接能同时保持多个结果集打开,结果流式传输默认是关闭的,这意味着除非你设置 jdbc_stream_results(大多数用户从未设置过此选项),否则查询的整个结果都会被读入内存。驱动程序还会组装完整的结果元数据集。这每个结果只发生一次,而不是每行一次,因此与行循环相比成本很低,但也不是零开销,而且大多数查询从不查看它。
ResultSet 路径是你的 ORM 和生态系统中其他部分已经使用的标准,对于绝大多数查询(即返回数百或数千行的查询)来说,它是正确的选择。分块 API 适用于另一种情况:查询返回大量数据,并且你控制管道的两端,逐行读取没有任何优势。
使用 DuckDBChunkedResult 读取数据块
Java 驱动程序现在通过 C API 提供的 duckdb_fetch_chunk 相同机制,直接暴露引擎的原生数据块流。查询结果变成按需获取的数据块序列,你可以从每个块中批量读取列向量,就像引擎生成它们的方式一样,从而避免了 JDBC 规范要求的每行开销。
综合起来:
try (DuckDBConnection conn = DriverManager
.getConnection("jdbc:duckdb:")
.unwrap(DuckDBConnection.class);
DuckDBPreparedStatement ps = conn.prepare(
"SELECT l_orderkey, l_linenumber, l_shipmode "
+ "FROM 's3://my-bucket-name/lineitems.parquet'")) {
try (DuckDBChunkedResult res = ps.query()) {
// 前进到下一个数据块,成功时返回 true
while (res.nextChunk()) {
// 从结果中获取当前数据块
DuckDBDataChunkReader chunk = res.chunk();
// 向量通过基于 0 的列索引访问
DuckDBReadableVector orderKeys = chunk.vector(0);
DuckDBReadableVector lineNumbers = chunk.vector(1);
DuckDBReadableVector shipModes = chunk.vector(2);
// 使用适合其类型的 getter 读取每列
for (long row = 0; row < chunk.rowCount(); row++) {
long orderKey = orderKeys.getLong(row);
int lineNumber = lineNumbers.getInt(row);
String shipMode = shipModes.getString(row);
System.out.println(orderKey + " " + lineNumber + " " + shipMode);
}
}
}
}
这种方法有几个显著的特点:
-
惰性(Laziness)。
nextChunk()一次从引擎拉取一个数据块。完整结果永远不会在原生端或 Java 端物化,因此你可以流式处理远大于堆内存的结果,就像引擎本身生成它们的方式一样。 -
列式访问(Columnar access)。在数据块内部,你按向量进行操作。如果你的目标也是列式的(例如
long[]、一个Arrow VectorSchemaRoot或一个 Parquet 写入器),你可以在紧凑的单态循环中复制值,而不是在每一行上切换列。 -
足够的元数据以便分发(Enough metadata to dispatch)。每个结果仍然报告其列数和列类型,因此你可以为每个向量选择合适的
get*调用,而无需在数据旁边携带单独的模式描述。 -
如果你写过 UDF,这个 API 会很熟悉。数据块内容通过
DuckDBDataChunkReaderAPI 访问,该 API 与驱动程序用于用户自定义函数读取输入向量的 API 相同。因此,在 UDF 内部读取函数参数的相同代码,也可以在 UDF 外部读取查询结果。 -
基于 0 的索引(Zero-based indexing)。数据块的列和行都从 0 开始,与 C API 和 UDF 接口匹配。
在向量内部,你可以自由选择适合你代码的迭代方式,包括 Java Stream。不过,对单个数据块使用并行流通常没有帮助:2048 个值太小了,fork/join 协调的开销通常超过其带来的收益。如果你需要并行性,应该跨数据块应用,而不是在单个数据块内部。
当前限制
这是该 API 的首次迭代,在采用之前,你应该了解以下几点限制:
-
目前仅支持基本数据类型。标量类型已受支持。复合类型(LIST、STRUCT)尚无法通过分块接口读取。对其的支持计划在未来版本中提供。
-
仅支持预编译语句(PreparedStatement)。
query()方法目前存在于DuckDBPreparedStatement上,还没有方便的query(String)重载。先准备语句只需增加一行代码。 -
目前读取器覆盖范围较小。当前读取器覆盖了基本类型,未来版本计划扩大覆盖范围。一个已计划的功能是直接以 UTF-8
byte[]形式读取 VARCHAR 值,这样文本密集型工作负载就可以跳过每个值都实例化一个 Java String 的步骤。
如果这些限制中任何一项影响了你关心的用例,请提交 issue。你的反馈将驱动下一次迭代的优先级。
结论
对于大多数应用程序来说,JDBC ResultSet 仍然是正确的默认选择。但引擎以列式数据块生成其结果,而 DuckDBChunkedResult 让你的 Java 代码能够以相同的形式读取它们:直接且惰性,在 JNI 边界几乎没有开销。
该特性由 duckdb-java#682 提供,可在 Maven Central 上当前的 duckdb_jdbc 发布版本中获取。在你最大的结果集上试用它,并告诉我们它的性能表现。欢迎提交 issue 和 pull request,DuckDB 团队也随时欢迎你在 DuckDB Discord 的 #java 频道讨论 Java 相关问题。
更多推荐


所有评论(0)