DuckDB Java表函数:将任意Java数据源接入SQL查询
DataHot 速览
DuckDB官方博客介绍如何在Java客户端中注册纯Java编写的表函数,将任意Java可访问的数据源(如通过SDK或SOAP访问的系统)暴露为SQL表。这让DuckDB可以作为单节点查询引擎,跨远程系统和本地文件执行异构连接,无需导出步骤。文章还对比了DuckDB内置的Parquet、PostgreSQL、MySQL等连接方式以及ODBC扩展,说明表函数适合处理这些方式覆盖不到的数据源。
为什么值得关注:数据从业者应关注:该实践提供了一种在Java生态中扩展DuckDB数据接入能力的通用方法,有助于简化多源数据集成和提升查询灵活性。
本文目录 11 节
译文
AI 逐段翻译DuckDB 中的 Java 表函数
Geertjan Wielenga, Alex Kasko 2026-08-25 | 13 分钟
TL;DR: DuckDB Java 客户端可以注册用纯 Java 编写的表函数,将任何 Java 可访问的数据源作为 SQL 表暴露出来。这使 DuckDB 成为跨远程系统和本地文件的异构连接的单节点查询引擎,无需导出步骤。
在大型组织中,数据分布在许多系统中,包括关系数据库、文档存储、消息队列、数据湖和云数据仓库。其中一些系统只能通过供应商 SDK 访问,通常用 Java 提供,或通过 SOAP 端点访问,而且许多系统位于自定义身份验证或单点登录之后,这从 JVM 客户端以外的任何方式都难以满足。将这些连接在一起的层通常是基于 JVM 的分布式查询引擎,例如 Trino,它可以运行单个查询来连接多个数据源。这些系统的 Java 客户端成熟且快速,具有流式 API,并且越来越多地使用虚拟线程进行异步工作。它们也已投入生产并通过公司安全审查。
团队越来越倾向于在这些环境中添加 DuckDB,用于快速单节点分析。由于周围的基础设施是 Java,他们使用 DuckDB Java 客户端(JDBC 驱动程序)。但查询引擎的实用性取决于其能访问的数据,而在这些环境中,许多数据只能通过 Java 客户端库访问。
将外部数据引入 DuckDB
在编写表函数以访问新数据源之前,值得询问 DuckDB 已经能够自行读取什么,答案几乎是所有内容。它直接从数据湖读取 Parquet 和其他文件。它直接连接到 PostgreSQL、MySQL 和 SQLite 等关系数据库。ODBC 扩展可访问 Oracle、SQL Server 和 DB2 等专有数据库。JSON 函数通过将类似 REST 的服务视为远程 JSON 文件来查询它们,社区扩展覆盖了更多系统。
对于这些都不覆盖的数据源,数据通常最终进入应用程序代码,在 DuckDB 结果集上手动处理。将这些数据直接引入 DuckDB 的 SQL 通常更方便,而且通常更高效,可以像任何其他表一样对其进行过滤和连接。表函数正是为此而设计的。DuckDB 一直允许您以这种方式插入自定义源,但直到现在,这意味着要用 C++ 编写函数,这实际上唯一现实的选择。
用纯 Java 扩展 DuckDB
DuckDB Java 客户端现在可以注册用 纯 Java 编写的表函数。您可以从 Java 访问的任何数据源都可以作为 SQL 表函数暴露出来,重用您的基础设施已经运行和信任的相同的流式、异步客户端库。然后,您可以将其与本地 Parquet 和 CSV 文件(包括通配符 glob)一起查询,在单个 SQL 语句中对所有数据进行连接和过滤。
直到现在,在原生代码中添加自定义源意味着构建和发布 DuckDB 扩展。这涉及 C++ 工具链、以复杂著称的构建系统,以及原生代码崩溃导致段错误从而拖垮整个 JVM 的操作风险。纯 Java 表函数是一个普通的 Maven 依赖项,在 JVM 上与应用程序的其余部分一起运行,风险要小得多。而且因为它只是一个 Java API,任何 JVM 语言都可以使用,因此相同的函数可以用 Kotlin、Scala 或 Clojure 编写。
这篇文章将逐步介绍一个 示例项目,它为 DuckDB 添加了一个 mongo_query() 函数,并用它来解释该机制。
MongoDB 是一个文档存储,具有强大的 Java API。下面的 mongo_query() 函数是一个刻意保持极简、用于说明的示例,而非生产连接器。特别是对于 MongoDB,有更好的访问方式,包括 ODBC 扩展 和 MongoDB 社区扩展。我们使用 MongoDB 作为示例,因为它是一个流行的系统,并拥有良好的 Java 客户端。同样的模式适用于从 JVM 可访问的任何数据源,无论是通过 JDBC 驱动程序、最常见的 Java 客户端之一、SOAP 服务还是专有供应商 SDK。目标
该函数应该可以从普通 SQL 调用:
FROMmongo_query('tab1','{ "col1": "foo", "col2": { "$lt": 42 } }',columns='["col1", "col2", "col3"]',hostname='localhost',port=27017,database='db1');第一个参数是 MongoDB 集合名称。第二个是 MongoDB 过滤器文档,使用原生 MongoDB 查询语法,原样传递。命名的 columns 参数指定要投影哪些字段以及结果的形状。由于过滤器是 MongoDB BSON 文档而不是完整的 SQL 查询,因此无法从查询本身读取集合名称和所需的结果列,而是作为单独的参数传递。针对已填充的集合并运行,该函数返回与过滤器匹配的文档:
┌─────────┬───────┬─────────┐
│ col1 │ col2 │ col3 │
│ varchar │ int32 │ varchar │
├─────────┼───────┼─────────┤
│ foo │ 10 │ match-a │
│ foo │ 41 │ match-b │
└─────────┴───────┴─────────┘
没有导出到 Parquet 的步骤,也没有中间表。在查询执行期间从 MongoDB 游标读取行。因为结果是普通关系,所以它可以与 DuckDB 可以读取的任何其他内容连接。在这里,它在一条语句中将远程集合与本地 CSV 文件的目录连接起来:
SELECTc.region,count(*)ASorders,sum(o.amount::DECIMAL(10,2))ASrevenueFROMmongo_query('orders','{ "status": "shipped" }',columns='["customer_id", "amount"]',database='app')ASoJOIN'customers/*.csv'AScONc.customer_id=o.customer_idGROUPBYc.region;这就是异构连接。通过其 Java 驱动访问的远程系统与本地文件的 glob 在单个节点上的单个 SQL 语句中一起查询。
Java 中的表函数
添加新数据源不再需要原生扩展。通过 DuckDB Java 客户端,使用 DuckDBFunctions.tableFunction() 构建器完全在 Java 中注册表函数。该构建器声明函数名称、其位置和命名参数及其类型,以及实现类。示例中的注册在 MongoExample.java 中完成:
DuckDBFunctions.tableFunction().withName("mongo_query").withParameter(String.class)// collection name.withParameter(String.class)// Mongo filter (JSON).withNamedParameter("columns",String.class).withNamedParameter("hostname",String.class).withNamedParameter("port",Integer.class).withNamedParameter("database",String.class).withNamedParameter("username",String.class).withNamedParameter("password",String.class).withFunction(newMongoQueryFunction()).register(connection);这完成了注册。mongo_query(...) 随后可作为该 DuckDB 连接上的表函数使用,并可用于 FROM 子句、连接、CTE 和子查询中,就像任何内置函数一样。
此示例接受username和password参数并在每次调用时打开新连接。在实际的非玩具实现中,通常更倾向于打开一次远程服务器连接并运行多个查询,这可以通过一组标量函数来实现。mongo_connect()打开连接并将其句柄作为DuckDB值返回,mongo_query()通过该句柄读取数据,而mongo_close()将其关闭。这种方法超出了本文的范围,可能成为后续文章的主题。有关类似示例,请参见ODBC扩展中的odbc_connect。
该实现是一个实现DuckDBTableFunction的类,它遵循DuckDB的原生表函数生命周期,包含三个回调:
bind在准备期间运行,读取参数、确定输出模式,并使用addResultColumn()声明每个结果列。init在执行前运行一次,并设置共享状态,例如打开游标或发出远程请求。apply被反复调用,每次填充一块输出,直到返回0。
API还暴露了initLocal回调,用于多线程执行中的每线程本地状态。本文仅涉及单线程执行,不加以使用,但可能在未来文章中涉及。该示例在MongoQueryFunction.java中实现了这些,并在MongoQueryParameters.java中定义了命名参数。
绑定:声明输出模式
bind读取调用参数并声明输出列。由于MongoDB文档没有固定模式,此示例要求调用者列出列而非从预备语句推断,并将每列声明给DuckDB:
publicMongoQueryBindDatabind(DuckDBTableFunctionBindInfoinfo)throwsException{StringcollectionName=info.getParameter(0).getString();StringqueryJson=info.getParameter(1).getString();StringcolumnsJson=info.getNamedParameter("columns").getString();// ... read hostname / port / database / credentials ...MongoClientclient=MongoClients.create(settings.build());MongoCollection<Document>collection=client.getDatabase(database).getCollection(collectionName);List<String>columns=newArrayList<>();for(BsonValuebv:BsonArray.parse(columnsJson)){Stringname=bv.asString().getValue();columns.add(name);info.addResultColumn(name,String.class);// declare it to DuckDB}Documentquery=Document.parse(queryJson);returnnewMongoQueryBindData(client,collection,columns,query);}这里有两个方面可推广到MongoDB之外。DuckDB从不解析或解释源的查询语言。过滤器基本原样传递给源的驱动,除了JSON到BSON的转换,因此源的完整语法仍可用。bind返回的对象(此处为MongoQueryBindData)随后将该状态传递到下一回调。注意,当查询被EXPLAIN时,bind也会运行,因为DuckDB需要输出模式来规划查询。
初始化:打开游标
init运行一次并准备apply将要使用的执行状态。在示例中,它发出查询并将结果游标存储在MongoQueryInitData中。它还通过setMaxThreads(1)将执行固定到单线程,因为本文不涉及多线程执行:
publicMongoQueryInitDatainit(DuckDBTableFunctionInitInfoinfo)throwsException{info.setMaxThreads(1);MongoQueryBindDatabindData=info.getBindData();FindIterable<Document>iter=bindData.collection.find(bindData.query);returnnewMongoQueryInitData(iter.cursor());}应用:将行流式传输到向量中
apply反映了DuckDB的向量化执行模型。它不是返回行,而是将行逐列写入输出数据块,直到块容量2048行,并返回生成的行数:
publiclongapply(DuckDBTableFunctionCallInfoinfo,DuckDBDataChunkWriteroutput)throwsException{MongoCursor<Document>cursor=info.getInitData().getResultCursor();longrow=0;for(;row<output.capacity()&&cursor.hasNext();row++){Documentdoc=cursor.next();for(longcol=0;col<output.columnCount();col++){copyValueFromResultSetToVector(doc,output.vector(col),row,columns.get((int)col));}}returnrow;// 0 signals "no more data"}值通过向量API写入类型化向量,使用setString、setInt、setDouble、setNull及相关方法。DuckDB反复调用apply,每次从源中提取一个2048行的向量大小批次,因此完整结果无需在任一侧的内存中物化。在示例中,BSON到向量的转换位于MongoTypes.java中。
在这里,将行写入DuckDBDataChunkWriter是读取它们的镜像。上一篇博文previous post涵盖了通过DuckDBDataChunkReaderAPI消费查询结果,其中驱动将块交给你读取。如上面的代码片段所示,表函数使用相同列式模型的写入端来生成它们。
影响
这个示例是一个MongoDB连接器,但更广泛的观点是DuckDB现在可以纯Java扩展,使丰富的Java库生态系统作为数据源触手可及:
- 无原生构建。项目不包含C++代码。它是一个Maven项目,有两个依赖:DuckDB Java客户端和MongoDB驱动。添加新源是普通的Java工作,使用团队已有的工具完成,无需接触原生工具链。
- 复用官方客户端。该示例未重新实现线路协议或查询语言。它使用供应商的Java驱动并原样传递过滤器。这同样适用于任何具有JDBC驱动或Java SDK的系统,例如REST API、消息队列或SOAP服务。
- 进程内访问。数据不会导出和重新加载。它在查询执行期间从游标读取。由于结果是普通的表函数,它可以与Parquet文件、CSV通配符或附加数据库在单个SQL语句中连接,因此DuckDB充当源上的查询层,而不是源的副本。
- 谓词在源端执行。过滤器以源自身的查询语言编写并原样传递给其驱动,因此它在源端、利用其索引执行,只有匹配的行跨越线路。选择性工作在远程完成,DuckDB仅流回需要的内容。
当前局限性
这些是当前Java表函数API的局限性:
public class MongoQueryInitData
implements AutoCloseable /* DuckDBTableFunctionState */ {
final MongoCursor<Document> cursor;
// ...
@Override
public void close() {
cursor.close();
}
}
- Java表函数尚不能打包为DuckDB扩展。DuckDB扩展是加载到DuckDB进程中的原生共享库,而Java表函数需要JVM运行。因此,该函数只能从DuckDB Java客户端使用。将JVM代码打包为可加载扩展在技术上是可行的,例如通过JNI或FFM嵌入JVM的垫片,或编译为GraalVM原生镜像,但目前均不支持。
- bind和init对象不被自动管理。在当前版本中,调用者负责从
bind和init返回的对象生命周期,例如在查询完成后关闭MongoDB客户端和游标。一个DuckDBTableFunctionState机制(由社区成员contributed贡献)为您管理该生命周期。init对象随后变为: - 尚不支持复合DuckDB类型。向量API目前覆盖标量类型,但
STRUCT、LIST和其他嵌套类型计划在未来支持。在此之前,嵌套源数据必须扁平化或序列化为标量列。
结论
对于许多分析工作负载,数据不必移动到集群,也不需要分布式查询引擎来跨系统连接。使用纯Java表函数,已在基础设施中运行的客户端库将远程数据源暴露为SQL表,DuckDB在单个节点上的单个查询中将它们与彼此以及本地文件进行连接,使用您已有的Java客户端库。
表函数API在“定义函数”中有文档说明。完整示例可在“duckdb_mongo_example”仓库中获取,DuckDB团队始终乐于在DuckDB Discord的“#java”频道讨论Java问题DuckDB Discord。
这篇内容对你有用吗?
反馈只用于改善内容筛选,不等同于收藏