双引擎,一次查询:在Neo4j中嵌入DuckDB
如果你最近关注数据库领域,就会注意到 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 是合适的选择。
版本汇总
作为参考,以下是本文使用的所有组件版本:
获取代码
完整源代码在 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 个用例:
- 范围查询
- CSV 读取
- Parquet 写入与读取
- JSON 读取
- 聚合返回数值而非字符串
- 空值处理
- 浮点数
- 布尔值
- 空结果集
- 错误 SQL 的报错暴露
- 内存表跨调用持久化
- 两个文件在 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 结合使用的三种方式,每种方式的集成风格不同。
总结
我们从一个简单的问题开始:能否像 pg_duckdb 将 DuckDB 嵌入 Postgres 那样,把 DuckDB 嵌入 Neo4j?答案是不能原生做到,但比你可能想象的更接近。借助两个 Java 类、一个 fat jar 和几行 Neo4j 配置,我们构建了一个可用的 duckdb.query 过程,可以直接从 Cypher 运行 DuckDB SQL。在此过程中,我们处理了 CSV、Parquet 和 JSON 文件,DuckDB 侧连接,与 Neo4j 图数据的跨引擎聚合,以及若干类型映射边界情况,现在 12 个测试的套件会自动捕获这些问题。
这个方案还有很大的扩展空间。基于文件的 DuckDB、合适的连接池、更清晰的错误处理,以及可能的可维护开源插件,都是自然的下一步。但基础已经具备。我们在这里构建的是一个概念验证——它能工作,测试也证实了这一点,但任何要进一步推进的人,都应该在将其用于生产数据之前,把健壮性、连接池和错误处理视为待解决事项。
完整源代码已在 GitHub 上提供。
DZone 贡献者表达的观点仅代表其个人。