Java 连接 Neo4j 实战:驱动配置、CQL 操作与事务处理全解析 最近在整理知识图谱相关的东西越做越觉得图数据库这步绕不过去。之前拿 Neo4j Browser 写 CQL 练手很爽但浏览器终归是人工查数工具业务系统里要让 Java 代码真正“连上图库”把 CQL 变成可调用的 API 能力这才是落地的一步。这篇是“Neo4j 学习”系列的第 3 篇专门讲 Java 连接 Neo4j 并执行 CQL 的完整链路驱动选型、连接配置、建点建关系、参数化查询、事务管理最后整理几个我实际踩过的坑。适合已经把 CQL 基础语法过了一遍、正准备把图库接进 Java 项目的同学参考。1. 连接前的准备版本选型与驱动依赖动手写代码之前先把路数理清楚。Neo4j 给 Java 开发者提供的接入方式不止一种最常见的是官方 Java Driver也就是走 Bolt 二进制协议的驱动另外还有一种 REST API 方式通过 HTTP 接口把 CQL 发过去。我直接说结论生产项目用官方 Java DriverREST 方式只适合临时调试或跨语言应急。对比项官方 Java DriverBoltREST APIHTTP通信协议Bolt 二进制协议HTTP JSON性能高长连接复用开销低相对较低每次请求都有 HTTP 开销事务支持原生支持编程式事务完整支持有限实现复杂类型映射自动映射 Java 类型需要手动解析 JSON适用场景业务系统、高并发、复杂事务脚本调试、跨语言集成选官方驱动还有个重要理由驱动内置连接池和负载均衡策略对单机版用户来说最直观的好处就是连接复用不用每次操作都重新建连接。我之前见过有人用 REST 方式写了个批量导入逻辑数据量一上来就慢得离谱后来换 Bolt 驱动同样逻辑速度提升非常明显。1.1 版本对应关系别在第一步就翻车Neo4j 服务端版本和 Java Driver 版本有兼容关系网上经常有人因为版本不对报各种看不懂的异常。我目前以 Neo4j 4.x 和 5.x 为主4.x 对应 Java Driver 4.x5.x 对应 Java Driver 5.x。驱动 5.x 可以连接 4.4 以上的服务端但反过来不保证兼容。Java 版本方面Driver 4.x 要求 Java 8Driver 5.x 要求 Java 11Neo4j 5.x 服务端本身跑在 Java 17 上。建议直接用 Java 17 Neo4j 5.x Driver 5.x这是当前最省心的组合。顺带提醒一句Neo4j 3.x 的驱动包名还是org.neo4j.driver.v1API 设计也比较老。如果你看的是老教程照着写很可能在 Maven 依赖和 import 上就要卡一阵。我后文统一以 4.x/5.x 的稳定 API 为准。1.2 Maven 坐标与 Gradle 写法官方驱动发布在 Maven 中央仓库直接加依赖就行。Maven 的pom.xml里加这一段dependency groupIdorg.neo4j.driver/groupId artifactIdneo4j-java-driver/artifactId version5.19.0/version /dependencyGradle 的话就是implementation org.neo4j.driver:neo4j-java-driver:5.19.0注意 artifact 是neo4j-java-driver不是neo4j。后者是服务端本身的包一般不该出现在业务项目依赖里。我见过有人把这个搞混然后拉了几十 MB 的服务端代码下来还跑不起来纯粹是给自己找麻烦。依赖加好后先别急着写连接代码。强烈建议先确认 Neo4j 服务是活的。本地默认是bolt://localhost:7687浏览器管理界面默认是http://localhost:7474。如果浏览器能打开但程序连不上大概率是 Bolt 端口没开或防火墙拦了这个后面细说。2. 建立连接Driver、Session 与连接配置连接 Neo4j 的代码流程其实很固定创建 Driver - 打开 Session - 执行 CQL - 解析 Result - 关闭。真正有讲究的是这几个对象各自的生命周期和职责。2.1 Driver 是全局单例Session 是轻量操作单位Driver 对应一组 Bolt 连接是线程安全且重量级的对象。正确用法是全局只创建一个 Driver 实例整个应用程序共用。Session 是逻辑会话代表一次与图库的交互上下文它不是线程安全的应该按需创建、用后即关。我见过有人把 Session 也做成全局单例结果高并发下频繁报错或卡死因为 Session 内部状态被多线程搅乱了。创建 Driver 的标准写法如下import org.neo4j.driver.AuthTokens; import org.neo4j.driver.Config; import org.neo4j.driver.Driver; import org.neo4j.driver.DriverFactory; public class Neo4jConnectionFactory { public static Driver createDriver() { Config config Config.builder() .withMaxConnectionPoolSize(100) .withConnectionTimeout(java.time.Duration.ofSeconds(10)) .withoutEncryption() // 本地调试建议关掉加密省心 .build(); return new DriverFactory().newInstance( bolt://localhost:7687, AuthTokens.basic(neo4j, your-password), config ); } }AuthTokens.basic就是账号密码认证。如果你用的是 Neo4j 默认账号neo4j首次登录会要求改密码改完之后密码就是这里要填的东西。还有一个细节如果服务端本机测试时没开认证配置文件里关闭了dbms.security.auth_enabled驱动这段要改成AuthTokens.none()不然会一直报认证错误。2.2 第一版跑通的案例三行代码查一个返回值拿到 Driver 后先跑一个最简单的查询验证链路通不通import org.neo4j.driver.Record; import org.neo4j.driver.Result; import org.neo4j.driver.Session; import org.neo4j.driver.SessionConfig; public class QuickStart { public static void main(String[] args) { Driver driver Neo4jConnectionFactory.createDriver(); try (Session session driver.session(SessionConfig.builder().withDatabase(neo4j).build())) { Result result session.run(RETURN 1 AS answer); Record record result.single(); System.out.println(answer record.get(answer).asLong()); } finally { driver.close(); } } }这段代码有几个点值得说明SessionConfig.builder().withDatabase(neo4j)指定目标数据库。4.4 之后 Neo4j 支持多数据库默认库就叫neo4j。如果省略这一步驱动走的是默认数据库但显式写出来更规范。session.run()执行的是自动提交事务模式适合单条 CQL 快速操作。如果后面涉及多步操作或需要回滚这个方法就不够用了。record.get(answer)按列名取值类型转换用asLong()、asString()这类方法。注意 Neo4j 里的整数在 Java 驱动中默认映射为Long不是 Integer这个细节很容易踩坑。跑通这个最简案例说明驱动、依赖、连接配置都没问题。接下来就可以放心写真正的 CQL 了。3. CQL 实操从建点、建关系到查询更新连接只是热身真正干活全靠 CQL。我以一个“成员技能图谱”的小案例来串讲节点类型是 Person 和 Skill关系是 LEARNS学习偶尔还需要 FOLLOWS关注这类关系。命名上用甲乙丙丁这种代称方便测试。3.1 建点CREATE 和 MERGE 的区别要刻进脑子里最基础的建点语句是CREATE (p:Person {name: 甲})这条语句每次执行都会新建一个节点。问题来了如果连续执行两次库里会出现两个同名节点这在很多业务场景下是致命的。比如用户注册场景你肯定希望同一个人只有一个节点。这时候要用 MERGEMERGE (p:Person {name: 甲})MERGE 是“有则匹配无则创建”的原子操作相当于 Cypher 世界的 Upsert。但注意MERGE 只有在存在唯一约束或索引时才能保证并发下的绝对唯一。否则两个请求同时进来还是可能双双创建。所以建点前最好先建约束。Neo4j 5.x 的建约束语法是CREATE CONSTRAINT person_name_unique FOR (p:Person) REQUIRE p.name IS UNIQUE;建索引的语法类似CREATE INDEX person_name_index FOR (p:Person) ON (p.name);索引和约束不一样约束保证唯一性索引加速查询。如果你的业务允许重名就建索引如果每个 Person 节点必须有唯一标识就建约束。一般我会用id或手机号这类业务唯一键来建约束name 反而容易重。3.2 建关系先 MATCH 再 CREATE节点建好后关系才是图库的灵魂。给甲和乙建立“关注”关系MATCH (a:Person {name: 甲}), (b:Person {name: 乙}) MERGE (a)-[:FOLLOWS]-(b)这里有个非常容易错的地方MATCH后面用逗号分隔两个模式而不是写两次MATCH。逗号分隔表示一次匹配两个变量写两个MATCH会产生笛卡尔积或匹配顺序问题实际效果可能和你预期完全不同。MERGE 建关系的好处是幂等重复执行不会生成两条关系。CREATE 则会每次都新建一条这在某些需要保留多条关系的场景比如交易流水反而是对的。用哪个取决于业务语义没有绝对好坏。给 Person 节点挂技能关系时我更常用 UNWIND 批量处理MATCH (p:Person {name: 甲}) UNWIND [Java, Neo4j, Spring] AS skillName MERGE (s:Skill {name: skillName}) MERGE (p)-[:LEARNS]-(s)UNWIND 把列表展开成多行每行都执行后面的 MERGE。这种写法在 Java 驱动里配合参数列表使用非常香后面讲批量写入时再说。3.3 查询MATCH、WHERE、RETURN 组合拳图库查询的爽点在于多跳关系天然支持。比如查“甲学的所有技能”MATCH (p:Person {name: 甲})-[:LEARNS]-(s:Skill) RETURN s.name AS skillName查“和甲学过同样技能的人”MATCH (p:Person {name: 甲})-[:LEARNS]-(s:Skill)-[:LEARNS]-(other:Person) WHERE other.name p.name RETURN DISTINCT other.name AS peer这里-表示反向关系WHERE other.name p.name是为了排除自己。DISTINCT去重很关键因为两个人可能共同学了多门技能不去重会返回多行。涉及属性过滤时WHERE 后面可以写范围条件MATCH (p:Person)-[:LEARNS]-(s:Skill) WHERE s.level 5 RETURN p.name AS name, collect(s.name) AS skills ORDER BY namecollect()是 Cypher 的聚合函数作用是把多行合并成一个列表。上面这个查询返回的结果里skills 就是一个数组Java 驱动里可以asList()取出来。这个在拼接口返回时特别省事。3.4 更新和删除SET、REMOVE、DELETE 的边界更新节点属性用 SETMATCH (p:Person {name: 甲}) SET p.age 28, p.level 3DELETE 只能删节点如果节点上还有关系会报错。标准姿势是 DETACH DELETEMATCH (p:Person {name: 甲}) DETACH DELETE pDETACH 会先把节点上所有关系删掉再删节点。我早期写删除逻辑时吃过亏只 DELETE 不 DETACH然后报错“Cannot delete node...”排查半天才明白关系还挂在上面。另外REMOVE 用来删属性MATCH (p:Person {name: 甲}) REMOVE p.age这些语法都是实打实在项目里会遇到的建议全部在 Neo4j Browser 里先跑一遍确认返回结果符合预期再写进 Java 代码。3.5 结合案例Java 里跑一个完整的建图和查询现在把前面的 CQL 串进 Java。第一步建点建关系因为要连续执行多条 CQL用自动提交 Session 一条条 run 也能跑但更规范的做法是放进事务里这个后面会细讲。先看一眼直白写法public class SkillGraphDemo { public static void main(String[] args) { Driver driver Neo4jConnectionFactory.createDriver(); try (Session session driver.session(SessionConfig.builder().withDatabase(neo4j).build())) { session.run(MERGE (p:Person {name: 甲})); session.run(MERGE (p:Person {name: 乙})); session.run(MERGE (s1:Skill {name: Java})); session.run(MERGE (s2:Skill {name: Neo4j})); session.run(MATCH (a:Person {name: 甲}), (b:Person {name: 乙}) MERGE (a)-[:FOLLOWS]-(b)); session.run(MATCH (p:Person {name: 甲}), (s:Skill {name: Java}) MERGE (p)-[:LEARNS]-(s)); session.run(MATCH (p:Person {name: 甲}), (s:Skill {name: Neo4j}) MERGE (p)-[:LEARNS]-(s)); } finally { driver.close(); } } }虽然能跑但这段代码很啰嗦每次都要单独 run 一条。真正生产代码里我会用事务 参数化的方式把多条 CQL 包起来可读性和效率都更好。4. 参数化查询与注入防护很多从 SQL 转过来的同学写 CQL 时第一反应是字符串拼接String name 甲; String cql MATCH (p:Person {name: name }) RETURN p; session.run(cql);这样写不是完全不能用但问题很大。一是可读性差二是容易出语法错误三是存在注入风险。CQL 和 SQL 一样支持参数绑定。驱动层的参数化查询不仅安全还能让服务端更好地复用查询计划性能也更稳。4.1 参数化写法一Map 传参MapString, Object params Map.of(name, 甲); Result result session.run( MATCH (p:Person {name: $name}) RETURN p, params );CQL 里用$name占位Java 侧用 Map 把name值传进去。注意 Map 的 key 要和$后面的名字一致。涉及多个条件也可以这么写MapString, Object params Map.of( minLevel, 5, skillName, Java ); Result result session.run( MATCH (p:Person)-[:LEARNS]-(s:Skill) WHERE s.level $minLevel AND s.name $skillName RETURN p.name AS name, params );Map.of最多支持 10 个键值对参数再多就得用new HashMap。在实际开发中一个方法传 3~5 个参数是常态Map.of完全够用。4.2 参数化写法二配合 UNWIND 批量操作批量创建关系时参数化配合 UNWIND 是利器ListString skills List.of(Java, Neo4j, Spring, Kafka); session.executeWrite(tx - { tx.run( MATCH (p:Person {name: 甲}) UNWIND $skills AS skillName MERGE (s:Skill {name: skillName}) MERGE (p)-[:LEARNS]-(s), Map.of(skills, skills) ); return null; });这里整个 List 作为参数传给$skillsUNWIND 会在 Cypher 内部展开。比在 Java 里 for 循环逐条执行高效得多网络往返次数从 N 次降到 1 次。我实测过万条级别的数据循环插入要几分钟UNWIND 批量操作几秒搞定。4.3 什么能参数化什么不能参数值可以用在属性值、WHERE 条件、LIMIT/OFFSET 数值、索引名需注意等地方。但节点标签、关系类型、属性名不能直接参数化。比如下面这种写法是错的MATCH (p:$label {name: 甲}) RETURN p$label不能当作标签名使用。原因在于 Cypher 的查询计划编译阶段就需要确定标签和关系类型参数绑定是运行期才发生的。如果确实需要动态标签或动态关系类型只能在 Java 侧做白名单校验后拼接或者引入 APOC 的存储过程。我强烈建议在代码里维护一个白名单集合校验通过再拼接别让用户输入直接进 CQL。白名单校验的简单示例private static final SetString ALLOWED_LABELS Set.of(Person, Skill, Company); public static void createNode(String label, MapString, Object props) { if (!ALLOWED_LABELS.contains(label)) { throw new IllegalArgumentException(非法标签 label); } String cql MERGE (n: label propertyClause(props) ); // 执行 cql }4.4 一个真实的注入场景你可能觉得图库注入离自己很远我举个例子你就明白了。假设有这样一个查询String userInput request.getParameter(name); String cql MATCH (p:Person {name: userInput }) RETURN p;如果userInput被构造为}) DETACH DELETE (n拼出来的 CQL 变成MATCH (p:Person {name: }) DETACH DELETE (n) RETURN p这还只是随手试恶意的话可以不断追加子句来删库。所以别把信任押在“CQL 没人会注入”上无脑参数化是底线。所有从外部传入的值一律走$param。5. 事务与性能从自动提交到批量写入谈完安全和参数化接下来是工程化绕不开的事务管理。Neo4j 的 Java Driver 对事务的支持非常完善但用不好就会留下隐患。5.1 三种事务模式按场景选第一是自动提交事务也就是session.run()。每条 CQL 自己一开一关隐式事务。简单查询用它最省事但多条 CQL 想保证原子性时不能用它中途失败前面成功的也不会回滚。第二是编程式事务最常见的是这种函数式写法session.executeWrite(tx - { tx.run(MERGE (p:Person {name: 甲})); tx.run(MERGE (p:Person {name: 乙})); tx.run(MATCH (a:Person {name: 甲}), (b:Person {name: 乙}) MERGE (a)-[:FOLLOWS]-(b)); return 42; });executeWrite会帮你管理事务边界回调正常返回就提交抛异常就自动回滚。返回值可以是任何类型比如查询的聚合结果非常灵活。对应读取操作是executeRead语义上告诉驱动走读路由。第三是手动事务适合跨多个 Session 操作或需要更精细控制的场景try (Transaction tx session.beginTransaction()) { tx.run(MERGE (p:Person {name: 甲})); tx.commit(); } catch (Exception e) { // 这里需要自行处理回滚 }说实话大多数业务用executeWrite就够了手动事务只有在控制粒度要求极高时才需要。注意事务不能开太长长时间占用连接会耗尽连接池导致其他请求排队。5.2 事务回调里别做重 IO有个隐藏性能杀手在executeWrite的 Lambda 回调里做远程 HTTP 调用、查其他数据库等耗时操作。当时看着没毛病实则事务一直开着连接被占着图库侧的锁也迟迟不释放。高并发下一来二去就把连接池打满。事务里只放和本次写入相关的最小操作其他事挪到事务外面。5.3 批量写入的最佳姿势前面提到 UNWIND 批量插入这里给出一个更完整的 Java 实现public long bulkCreatePeople(ListString names) { Driver driver Neo4jConnectionFactory.createDriver(); try (Session session driver.session(SessionConfig.builder().withDatabase(neo4j).build())) { return session.executeWrite(tx - { Result result tx.run( UNWIND $names AS name MERGE (p:Person {name: name}) RETURN count(p) AS total, Map.of(names, names) ); return result.single().get(total).asLong(); }); } }传入的names可以是一千条、一万条Cypher 还是在服务端一次性处理完。回传的count(p)帮你确认到底建了多少个节点。实际执行时要注意批次大小。我建议单批控制在 5000 ~ 10000 条之间太大容易触发服务端内存压力太小又体现不出批量优势。如果数据量几十万可以分批次调用这个方法每次传一个合理范围内的子集。5.4 连接池参数的经验值配置连接池时withMaxConnectionPoolSize默认是 100。如果你的应用并发不高没必要调太高连接太多反而增加服务端负担并发上来了再逐步调整。withConnectionTimeout设 10 秒比较合适太短容易在服务端繁忙时误报超时太长又会让线程长时间阻塞等待。还有一个容易忽略的参数是连接空闲回收时间。驱动默认会定期清理空闲连接一般不用动。你只需要关注连接池大小和超时设置成对应用的实际压力即可。6. 避坑记录常见异常与排查思路最后这部分是实战中真金白银换来的经验。遇到报错别慌按图索骥大部分问题都逃不出下面这些类别。6.1 常见异常速查表异常或报错信息常见原因处理方式ServiceUnavailableException服务没起、端口不通、Bolt 地址写错先确认bolt://localhost:7687端口通不通再查防火墙AuthenticationException账号密码错、密码过期用neo4j账号登录浏览器改密码或检查配置ClientException: Cannot delete node...节点还有关系直接 DELETE改用DETACH DELETEClassCastExcepton / Long vs Integer驱动把整数映射为 Long强行asInt()用asLong()或asObject()后判断类型Transaction rolled back事务回调内抛了异常看具体异常栈修复 CQL 或业务逻辑查询变慢但没报错缺少索引全表扫节点加索引用 EXPLAIN 看查询计划6.2 连接失败的最高频原因我最常遇到的是本机连接失败。现象是浏览器能打开 Neo4j 管理页面但 Java 程序连不上抛ServiceUnavailableException。原因是浏览器走的是 7474 HTTP 端口驱动走的是 7687 Bolt 端口。两者不是一回事。确认服务是否监听 Bolt 端口可以用命令lsof -i :7687macOS/Linux或netstat -ano | findstr 7687Windows来查。如果发现 Bolt 端口没监听优先检查 Neo4j 配置文件里的监听地址是不是默认值。6.3 类型映射陷阱Neo4j 的整数在 Java 驱动里是 Long这是新手最容易忽略的问题。比如属性存了age: 28读取时record.get(age).asInt()会直接报类型转换异常因为内部其实是 Long。老老实实用asLong()如果需要 Integer 再做Math.toIntExact()。类似地Float 和 Double 也有这种映射差异建议读取时统一用asObject()看一下实际类型再决定怎么转。代码里写死类型转换一旦数据结构调整就是隐性炸弹。6.4 MERGE 并发下的唯一性保障之前说过MERGE 不是万能的。如果只写了 MERGE没有唯一约束两个并发请求同时创建一个新用户节点理论上是可能重复建的。要彻底防住一定要建约束CREATE CONSTRAINT person_id_unique FOR (p:Person) REQUIRE p.id IS UNIQUE;有约束兜底后并发 MERGE 时只有一个请求能成功另一个会得到临时异常业务层抓一下重试即可。这个场景在用户注册、账号导入时特别常见。6.5 索引缺失导致的全表扫描数据量少时查得飞快数据量上来后突然变慢多半是缺索引。Cypher 里对标签属性做过滤时如果没有索引服务端只能把该标签下所有节点扫一遍。在 Neo4j Browser 里给慢查询加上EXPLAIN前缀就能看到执行计划里是不是有NodeByLabelScan那基本就是扫描全标签节点了。解决办法是给高频过滤的属性建索引。有一点要说明图库的关系遍历性能很强但节点属性过滤还是需要索引支持这和关系型数据库的思维有共通之处别把图数据库想成什么索引都不要的“银弹”。6.6 Session 资源泄漏Java Driver 里 Session 和 Result 都实现了AutoCloseable一定要用 try-with-resources 或在 finally 里关闭。如果只创建不关闭最终会把连接池占满程序表现为越跑越慢、逐渐卡死。排查方法很简单看看代码里有没有driver.session()但没匹配session.close()的地方。关于 Result 还有一个细节result.stream()是惰性求值流关闭前连接会被占用。如果你在流里做了耗时操作记得把整个流处理完尽快释放。实际经验是能用result.list()或result.single()就尽量别用流式拉取除非结果集真的很大。最后分享一点实际体会。我最初连 Neo4j 时先把所有 CQL 在浏览器里验证一遍再往 Java 里塞这个习惯帮我少走了很多弯路。因为 CQL 执行计划和数据形态在浏览器里一目了然一旦在 Java 里出问题至少能确定是 CQL 写错还是驱动调用写错。另一个建议是动手写 Java 代码之前先把图模型画清楚有哪些节点标签、哪些关系类型、哪些属性需要建索引。模型定得稳后面写代码基本就是套模板模型乱改标签和关系一变动索引、约束、代码查询全得跟着返工。做图数据库项目最贵的成本永远是返工而不是写代码本身。