Ohhnews

分类导航

$ cd ..
DZone Java原文

双引擎,一次查询:在Neo4j中嵌入DuckDB

#duckdb#neo4j#图数据库#java#数据库集成

如果你最近关注数据库领域,就会注意到 DuckDB 出现在一些意想不到的地方。其中比较有意思的一个例子是 pg_duckdb,这是一个将 DuckDB 直接嵌入 Postgres 进程的 Postgres 扩展,让分析查询无需离开数据库就能通过 DuckDB 的引擎运行。这个想法很巧妙:数据留在原处,但引入更快的分析引擎。

这让我们开始思考:能不能在 Neo4j 上做类似的事情?

答案是不能用同样的方式。Postgres 有成熟的扩展架构,允许在底层嵌入外部查询引擎。Neo4j 没有暴露这类钩子。但 Neo4j 确实允许你用 Java 编写自定义过程,这些过程运行在 Neo4j JVM 内,并且可以直接从 Cypher 调用。另外,DuckDB 提供了一个官方 JDBC 驱动,它打包了原生二进制文件,并通过 JNI 加载。把这两件事结合起来,你会得到一种与 pg_duckdb 体验惊人接近的方案:DuckDB 在进程内运行,查询由 Cypher 发起,结果再流回图数据库。

这就是我们构建的东西。本文将从零开始完整走一遍。完整源代码已在 GitHub 上提供。

前置条件

开始之前,我们需要以下内容:

  • JDK 21——用 java -version 检查
  • Maven——用 mvn -version 检查
  • Neo4j Desktop——已创建一个本地数据库实例并准备启动

请注意,这种方式需要自管理的 Neo4j 安装。AuraDB 不支持自定义插件,因此这里 Neo4j Desktop 是合适的选择。

版本汇总

作为参考,以下是本文使用的所有组件版本:

组件版本
OpenJDK21.0.12.1
Maven3.9.6
Neo4j Desktop2.2.1
Neo4j2026.07.0
Neo4j Procedure API5.26.0
DuckDB JDBC1.1.3
JUnit Jupiter5.10.2
maven-compiler-plugin3.11.0
maven-shade-plugin3.5.2
maven-surefire-plugin3.5.4

获取代码

完整源代码在 GitHub 上。要只获取这个项目:或者按照下面的步骤从零开始。

工作原理

架构很直接。我们编写两个 Java 类,并将它们打包成一个 fat jar:

  • DuckDBConnection 管理一个单例 DuckDB 连接,其生命周期与 Neo4j 进程相同。它使用双重检查锁定来保证线程安全,并在连接被关闭时自动重连。
  • DuckDBProcedure 是 Neo4j 过程本身。它接收来自 Cypher 的 SQL 字符串,在共享的 DuckDB 连接上执行,并将结果以行流式返回,每一行都是一个 Map,Cypher 可以用点号语法解构。

DuckDB 默认以内存模式运行,这意味着每次 Neo4j 重启时它都会从干净状态开始。对于针对外部文件的分析来说这没问题,我们会在本文的总结部分讨论基于文件的持久化。

项目搭建

首先创建项目目录:

验证目录结构:

现在在项目根目录创建 pom.xml。Neo4j 依赖使用 provided 作用域——它不会被打进 jar,因为 Neo4j 在运行时提供它。DuckDB JDBC 驱动以 compile 作用域引入,因此会连同其原生二进制文件一起打包。JUnit 依赖使用 test 作用域,因此会被排除在 fat jar 之外。maven-shade-plugin 在 package 阶段构建 fat jar。

确认 Maven 能够解析依赖:

Java 类

在 src/main/java/com/example/duckdb/ 下创建 DuckDBConnection.java:

volatile 关键字确保实例在多个线程之间可见,而无需在每次读取时进行完整同步。不带路径的 jdbc:duckdb: 连接字符串会给我们一个内存中的 DuckDB 实例。

现在在同一个包中创建 DuckDBProcedure.java:

关于 DuckDBProcedure,有几点值得注意:

  • 我们使用 stmt.execute(sql) 而不是 executeQuery(),因为像 COPY 这样的 DuckDB 写操作不返回 ResultSet。布尔返回值告诉我们该走哪条路径。
  • mapValue 方法显式处理类型映射。DuckDB 的 HUGEINT 类型——用于聚合求和——没有标准的 java.sql.Types 映射,因此我们直接检查类型名称。每次读取数值后都会调用 rs.wasNull(),以避免为空列返回 0。
  • 我们在 DuckDBConnection.class 上同步,以保证并发 Cypher 调用对共享连接是线程安全的。

编写测试

我们首先尝试了 neo4j-harness,它会在 JUnit 中启动一个轻量级嵌入式 Neo4j 实例——这与 Neo4j Procedure Template 中使用的方法相同。在我们的环境中,我们发现嵌入式服务器在测试运行期间就绪所需的时间比预期更长,这促使我们采用了一个更简单的替代方案。

解决办法是完全跳过 neo4j-harness,直接通过 JDBC 测试 DuckDB 逻辑。由于 DuckDBProcedure 只是对 JDBC 连接的一层薄封装,我们完全可以在不引入 Neo4j 的情况下,测试相同的类型映射、空值处理和文件读取逻辑。这些测试在 4 秒内运行完成,并且完全自包含。

首先,添加测试目录结构,并创建一个 src/test/resources 文件夹来存放测试数据文件:

测试数据文件覆盖了我们想要验证的所有类型和边界情况:

Maven 会自动将 src/test/resources 中的所有内容复制到 target/test-classes,因此测试类可以通过类加载器解析文件路径,而无需硬编码任何绝对路径。位于 src/test/java/com/example/duckdb/DuckDBProcedureTest.java 的测试类覆盖 12 个用例:

  1. 范围查询
  2. CSV 读取
  3. Parquet 写入与读取
  4. JSON 读取
  5. 聚合返回数值而非字符串
  6. 空值处理
  7. 浮点数
  8. 布尔值
  9. 空结果集
  10. 错误 SQL 的报错暴露
  11. 内存表跨调用持久化
  12. 两个文件在 DuckDB 侧 JOIN

每个测试都对应我们在 Neo4j 中手动验证过的某项内容。文件路径以可移植的方式解析,因此无论项目克隆到哪里,测试都能工作:

Parquet 测试是唯一的例外——因为 DuckDB 生成 Parquet 文件,而不是读取一个已存在的文件,所以它会写入一个 JUnit @TempDir,该目录在测试运行后会自动清理。

运行完整测试套件:

全部 12 个测试应在几秒内通过。运行 mvn package 也会在构建 fat jar 之前运行测试,因此 DuckDB 逻辑中的任何回归都会在 jar 部署之前被发现。

构建与安装

构建 fat jar:

shade 插件关于 MANIFEST.MF 的任何警告都是无害的。jar 会生成在 target/neo4j-duckdb-procedure-1.0.0.jar。将其复制到 Neo4j Desktop 的 plugins 文件夹。你可以在 Neo4j Desktop 中数据库实例的三点菜单下找到该路径,然后选择 Open Folder > Plugins。

然后复制 jar:

打开与 plugins 同级的 conf 文件夹中的 neo4j.conf,并添加:

重启数据库。使用左侧导航窗格中的 Query 确认该过程已注册:

你应该会看到 duckdb.query。如果重启后该过程仍未出现,还要在 neo4j.conf 中添加以下内容:

某些 Neo4j 配置要求过程在启动时加载之前必须被显式加入允许列表。

实际使用

这里使用的测试数据文件也可在项目仓库的 src/test/resources 中找到。我们先从简单的开始。DuckDB 有一个内置的 range 函数,无需任何外部数据即可使用:

应该会返回五行,确认 DuckDB 正在 Neo4j 内运行。注意,DuckDB 的 range() 函数不包含上界,因此 range(1, 6) 返回 1 到 5。

现在我们来读取一个 CSV 文件。在 /tmp/test.csv 创建一个测试文件:

从 Cypher 查询它:

DuckDB 读取并解析 CSV;Neo4j 接收结果。我们还可以将 CSV 写出为 Parquet 文件:

再将其读回来:

DuckDB 处理 JSON 同样轻松。创建 /tmp/test.json:

并读取它:

我们还可以在结果到达 Cypher 之前,完全在 DuckDB 内部连接两个文件。创建 /tmp/test_timezones.csv:

然后将其与人口数据连接:

连接完全在 DuckDB 内部完成。Neo4j 只接收最终结果集。

现在是最有意思的演示:在单个查询中结合 DuckDB 分析与 Neo4j 图数据。首先,创建一些图节点:

现在按城市名称将 DuckDB 文件数据与 Neo4j 图数据连接:

再按大洲聚合——人口数字来自 DuckDB,大洲分组来自图:

这就是两个引擎共同贡献一个结果:DuckDB 处理文件分析,Neo4j 处理图遍历和聚合。

经验教训

构建过程并非一帆风顺,这些磕磕碰碰值得记录。

  • Maven POM 中的 XML 注释除了结尾的 --> 之外,任何位置都不能包含双连字符。这是 XML 解析的一个怪异之处,很容易被忽略。
  • DuckDB 的 HUGEINT 类型——用于 SUM 等聚合函数的结果——在标准 java.sql.Types 常量中没有对应项。我们最初的类型映射 switch 语句静默地落入了字符串默认分支,将数值以字符串返回。修复方法是在进入 switch 之前,按名称检查 ResultSetMetaData.getColumnTypeName()。
  • 像 COPY 这样的写操作不能走 executeQuery(),因为它们不返回 ResultSet。改用 stmt.execute() 并检查布尔返回值,可以干净地处理读和写两条路径。
  • JDBC 中的空数值从 getInt() 和 getLong() 返回的是 0,而不是 null。你必须在每次数值读取后立即调用 rs.wasNull(),以区分真正的零和缺失值。

方案对比

还值得注意的是,APOC(Awesome Procedures on Cypher)Extended 的 apoc.load.jdbc 过程通过其 JDBC 驱动支持 DuckDB,而 apoc.jdbc.analytics 更进一步,允许你将图数据导出到临时 DuckDB 表中进行分析查询。这些是互补的方法,值得与本文构建的自定义过程一起探索。下表比较了将 DuckDB 与 Neo4j 结合使用的三种方式,每种方式的集成风格不同。

方式工作原理方向持久化状态要求
自定义过程(本文)DuckDB 作为单例连接在 Neo4j JVM 内进程内运行文件与 SQL 进入 Neo4j是,跨 Cypher 调用自定义 Java 插件
APOC apoc.load.jdbcDuckDB 被视为外部 JDBC 数据源;驱动 jar 放入 plugins 文件夹文件与 SQL 进入 Neo4j否APOC Extended + DuckDB JDBC jar
APOC apoc.jdbc.analytics图数据导出到临时 DuckDB 表;针对它运行分析 SQL图数据进入 DuckDB 进行分析否APOC Extended + DuckDB JDBC jar

总结

我们从一个简单的问题开始:能否像 pg_duckdb 将 DuckDB 嵌入 Postgres 那样,把 DuckDB 嵌入 Neo4j?答案是不能原生做到,但比你可能想象的更接近。借助两个 Java 类、一个 fat jar 和几行 Neo4j 配置,我们构建了一个可用的 duckdb.query 过程,可以直接从 Cypher 运行 DuckDB SQL。在此过程中,我们处理了 CSV、Parquet 和 JSON 文件,DuckDB 侧连接,与 Neo4j 图数据的跨引擎聚合,以及若干类型映射边界情况,现在 12 个测试的套件会自动捕获这些问题。

这个方案还有很大的扩展空间。基于文件的 DuckDB、合适的连接池、更清晰的错误处理,以及可能的可维护开源插件,都是自然的下一步。但基础已经具备。我们在这里构建的是一个概念验证——它能工作,测试也证实了这一点,但任何要进一步推进的人,都应该在将其用于生产数据之前,把健壮性、连接池和错误处理视为待解决事项。

完整源代码已在 GitHub 上提供。

DZone 贡献者表达的观点仅代表其个人。