这次继续写 Neo4j 学习系列的第二篇。上一篇已经装好 Neo4j、跑通基本 Cypher 的读者,接下来最关心的就是把 Neo4j 接到 SpringBoot 工程里。SpringBoot 整合 Neo4j 这一步,说简单是因为官方 starter 已经帮你处理了连接池、事务和仓储映射;说不简单是因为图模型和关系型表的思维方式差异很大,光是一个实体注解、一条关系查询,都能把人卡在原地半天。这篇文章我会用“用户—电影—演员”的经典场景,完整走一遍 SpringBoot 整合 Neo4j 的过程,包括版本选择、Maven 依赖、Docker 启动、实体注解、Repository 接口、从一个节点出发查询多条关系的 Cypher 写法,以及我在真实项目里踩过的坑。适合刚接触 Neo4j 的 Java 后端同学,也适合已经在用但想系统梳理一遍的人。
1. 为什么这次用“电影评分”当样例:从业务出发看图模型
1.1 关系表查询的瓶颈在哪
如果你对 Neo4j 的第一印象是“这玩意能跑 SQL 吗”,那说明你还没跳出关系型数据库的思维。关系型数据库处理“实体—关系”时,最常用的办法是建中间表。比如用户评电影,需要一张 user_movie 表,userId、movieId、rating、comment;演员演电影,又要一张 actor_movie 表。
业务简单的时候没问题,一旦查询变成“我认识的人喜欢的导演还拍过什么电影,这些电影里我室友也看过且评分很高”,SQL 就开始爆炸了。你要关联 user_follow、user_movie、movie_director、director_movie、user_friend 一堆表,嵌套十几层 JOIN。查询量上来之后,优化器也救不了你。
我在实际项目里就遇到过类似需求:想找“某个用户的好友看过、且这个用户还没看的高分电影”。用 MySQL 写出来,SQL 长到要同事帮我 review 三遍。后来把社交和评分关系挪到 Neo4j,同样的逻辑只用三行 MATCH。
1.2 图模型适合哪些场景
图数据库适合关系链路深、关系种类多、查询模式和路径强相关的场景。最常见的几个:
- 社交网络:好友、关注、拉黑、二度人脉;
- 推荐系统:用户喜欢的内容、相似用户喜欢的内容、内容之间的关联;
- 权限和角色继承:角色 A 继承角色 B,用户关联组织,组织关联资源;
- 知识图谱:实体间的分类、属性、引用、相似关系;
- 反欺诈:账号、设备、手机号、IP 之间的多度关联。
如果业务只是简单单条记录查询,比如查订单按订单号、查用户按手机号,那就没必要硬上 Neo4j。图数据库不是万金油,它解决的是关系型查询的“深链”痛点,不是替代所有数据库。
1.3 本次样例的数据模型约定
为了贴合最常见的入门场景,我用“用户评分电影、演员出演电影”这三类节点做示例:
- 节点:User、Movie、Person
- 关系:User -[RATED { stars, comment }]-> Movie
- 关系:Person -[ACTED_IN]-> Movie
这个模型其实就相当于你平时设计 ER 图的图形化版本。你把表变成了节点,外键变成了关系,中间表的额外字段变成了关系属性。用图数据库表达之后,最明显的变化是:查询不再是“找一张表 JOIN 另一张表”,而是“从某个节点出发,沿着关系一直走”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境与工程:版本选型、Docker 启动和连接配置
2.1 Spring Boot 与 Neo4j 的版本搭配
整合前最重要的不是写代码,是确认版本。Spring Boot 的不同大版本,对应的 Neo4j 连接配置和 Spring Data Neo4j 版本都不一样。
我目前推荐的新项目组合是:
- Spring Boot 3.2.x
- Spring Data Neo4j 7.x(由 starter 自动引入)
- Neo4j 5.x 社区版或企业版
- Neo4j Java Driver 5.x
新项目建议尽量选这套。如果你是还在维护 Spring Boot 2.7.x 的老项目,则需要对应 Spring Data Neo4j 6.x、Neo4j Java Driver 4.4.x、Neo4j 4.4 数据库,否则驱动和数据库协议版本不匹配,启动时会有各种奇怪的异常。
所以在 pom.xml 里引入依赖时,不需要手动写 Spring Data Neo4j 的版本,直接用 Spring Boot 的 starter 管理:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-neo4j</artifactId>
</dependency>
如果你是 Spring Boot 2.7.x,父工程版本是 2.7.18,同样引入这个 artifactId,BOM 会自动帮你选中合适的版本。
2.2 用 Docker 起 Neo4j 5.x
我习惯直接用 Docker 起本地开发环境。别在生产环境这么干,但本地联调非常省事:
bash复制docker run -d \
--name neo4j \
-p 7474:7474 \
-p 7687:7687 \
-e NEO4J_AUTH=neo4j/Test123456 \
-e NEO4J_server_memory_heap_max__size=1G \
-e NEO4J_server_memory_pagecache_size=512M \
-v $PWD/neo4j/data:/data \
-v $PWD/neo4j/logs:/logs \
neo4j:5.17.0
7474 是浏览器管理界面,7687 是 Bolt 协议端口,Java 驱动连接时用的是 7687。NEO4J_AUTH 里的密码不能设太简单,Neo4j 5.x 对默认密码强度有要求,建议用类似 Test123456 这种组合。
启动后先打开 http://localhost:7474,用 neo4j/Test123456 登录,随便跑一条 RETURN 1 验证数据库没问题,再回来写 SpringBoot 代码。
2.3 application.yml 配置与 Spring Boot 2.x 的区别
Spring Boot 3.x 的 Neo4j 配置前缀是 spring.neo4j.*:
yaml复制spring:
neo4j:
uri: bolt://127.0.0.1:7687
authentication:
username: neo4j
password: Test123456
pool:
max-connection-pool-size: 50
connection-acquisition-timeout: 30s
这里有个非常坑的细节:Spring Boot 2.x 用的配置前缀是 spring.data.neo4j.*,而且用户名和密码是平铺的,不是嵌套 authentication。如果你在 2.x 项目里照抄 3.x 配置,会发现 Neo4j 连接参数根本没有生效,程序还是会尝试连接 localhost:7687,一旦密码不对就报认证失败。
Spring Boot 2.x 的正确写法是:
yaml复制spring:
data:
neo4j:
uri: bolt://127.0.0.1:7687
username: neo4j
password: Test123456
两种写法都不难,但很容易被网上的博客带偏。看博客时一定要先看对方用的 Spring Boot 大版本,再决定抄哪段配置。
3. 实体映射:把“节点”“关系”“属性”变成 Spring 对象
3.1 节点实体:@Node 和 @Property
Spring Data Neo4j 里,一个节点实体对应一张“虚拟表”,但比 JPA 实体简单得多。我只是把核心字段写出来,getter/setter 用 Lombok 或者其他工具自行补齐。
Movie 节点:
java复制package com.example.moviegraph.model;
import org.springframework.data.neo4j.core.schema.*;
@Node("Movie")
public class Movie {
@Id
@GeneratedValue
private Long id;
@Property("title")
private String title;
@Property("release_year")
private Integer releaseYear;
public Movie() {
}
public Movie(String title, Integer releaseYear) {
this.title = title;
this.releaseYear = releaseYear;
}
// getters/setters...
}
@Node("Movie") 指定节点标签。如果你不写值,默认用类名转成标签,但显式写出来更安全,避免类重构时标签漂移。
@Id + @GeneratedValue 是让数据库自动生成内部 ID。注意这里不是关系型数据库的 @GeneratedValue(strategy=...),它是 Neo4j 自己的内部元素 ID,不适合当作业务 ID 长期保存,因为 Neo4j 重建或迁移后内部 ID 可能变化。
@Property("release_year") 是映射属性名的,Java 字段用驼峰,数据库属性名用下划线,这属于团队规范,建议从一开始就统一。
User 节点:
java复制@Node("User")
public class User {
@Id
@GeneratedValue
private Long id;
private String name;
@Relationship(type = "RATED", direction = Relationship.Direction.OUTGOING)
private List<RatingRel> ratings;
public User() {
}
public User(String name) {
this.name = name;
}
// getters/setters...
}
Person 节点,演员:
java复制@Node("Person")
public class Person {
@Id
@GeneratedValue
private Long id;
private String name;
@Relationship(type = "ACTED_IN", direction = Relationship.Direction.OUTGOING)
private List<Movie> actedIn;
public Person() {
}
public Person(String name) {
this.name = name;
}
// getters/setters...
}
3.2 带评分属性的关系:@RelationshipProperties
用户评分电影这个关系,不只是“有关系”,还带 stars 和 comment。这种关系属性在 Spring Data Neo4j 里要用 @RelationshipProperties 来建模:
java复制package com.example.moviegraph.model;
import org.springframework.data.neo4j.core.schema.*;
@RelationshipProperties
public class RatingRel {
@RelationshipId
private Long id;
private Integer stars;
private String comment;
@TargetNode
private Movie movie;
public RatingRel() {
}
public RatingRel(Integer stars, String comment, Movie movie) {
this.stars = stars;
this.comment = comment;
this.movie = movie;
}
// getters/setters...
}
这个类不是节点,是关系实体。@RelationshipId 类似节点实体的 @Id,@TargetNode 指定这个关系指向的目标节点。
建模完成后,你不需要在 Movie 节点上再写一个反向的 List<RatingRel>。实际项目里我建议只在需要主动维护关系的一端维护字段,另一端通过 Cypher 实时查询。如果两端都维护,save 时很容易出现重复创建关系的问题,排查起来非常头疼。
3.3 Repository 接口与内置 CRUD
Spring Data Neo4j 的 Repository 写法和 JPA 几乎一样,继承 Neo4jRepository<T, ID> 就能获得基础的 CRUD:
java复制package com.example.moviegraph.repository;
import com.example.moviegraph.model.User;
import org.springframework.data.neo4j.repository.Neo4jRepository;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
public interface UserRepository extends Neo4jRepository<User, Long> {
Optional<User> findByName(String name);
@Query("MATCH (u:User)-[:RATED]->(:Movie {title: $title}) RETURN u")
List<User> findByRatedMovieTitle(@Param("title") String title);
}
findByName 这种派生查询,Spring Data Neo4j 会自动推断属性名并生成 Cypher。但这里有个小陷阱:如果你没在 pom 里开启 -parameters 编译参数,方法参数名在某些情况下拿不到,可以用 @Param("name") String name 显式指定。稳妥起见,自定义参数都加 @Param,省得换环境就挂。
MovieRepository 也类似:
java复制public interface MovieRepository extends Neo4jRepository<Movie, Long> {
Optional<Movie> findByTitle(@Param("title") String title);
@Query("MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WHERE p.name = $name RETURN m")
List<Movie> findMoviesByActorName(@Param("name") String name);
}
Repository 的好处是,简单的节点查询你几乎不用手写 Cypher。但要处理复杂多跳路径,还是老老实实用 @Query 写原生 Cypher 更直观。
4. 查询实战:从一个节点出发,如何查多条关系路径
4.1 一个用户到底和哪些数据有关联
刚接触 Neo4j 的人,最常问的一句话就是“从一个节点出发,怎么查多条关系”。关系型数据库里,你可能会写多个 WHERE 条件、多个 JOIN;Neo4j 里最自然的方式,就是让同一个节点同时匹配多条扩展路径。
比如想知道张三评过哪些电影、关注过哪些用户,最直接的是多条 MATCH:
cypher复制MATCH (u:User {name: '张三'})-[:RATED]->(m:Movie)
RETURN m.title AS movieTitle
这是一跳查询。如果还想同时看到他关注的人,可以再加一条 MATCH:
cypher复制MATCH (u:User {name: '张三'})-[:FOLLOWS]->(f:User)
RETURN f.name AS followName
但你会遇到一个问题:第一条 MATCH 如果没有结果,整个查询可能返回空。想要“一条查询拿到多个方向的扩展关系”,我会用 OPTIONAL MATCH,让每个方向独立扩展:
cypher复制MATCH (u:User {name: '张三'})
OPTIONAL MATCH (u)-[:RATED]->(ratedMovie:Movie)
OPTIONAL MATCH (u)-[:FOLLOWS]->(followUser:User)
RETURN u.name AS userName,
collect(DISTINCT ratedMovie.title) AS ratedMovies,
collect(DISTINCT followUser.name) AS followNames
关键词是 collect(DISTINCT ...),它能把多条路径上查到的结果合并成一个列表,避免因为路径组合产生重复行。
4.2 “看过同一部电影的人还会喜欢什么”多跳查询
这算是“从一个节点出发,查询多条关系路径”的典型例子。需求拆解一下:
- 先找张三评分过的电影;
- 再找也评分过这些电影的其他用户;
- 再找这些用户评分过的其他电影;
- 排除张三已经看过的电影,统计推荐热度。
Cypher 一条语句搞定:
cypher复制MATCH (me:User {name: '张三'})-[:RATED]->(movie:Movie)
WITH me, collect(DISTINCT movie) AS watchedMovies
MATCH (movie)<-[:RATED]-(other:User)
WHERE other <> me
MATCH (other)-[:RATED]->(rec:Movie)
WHERE NOT rec IN watchedMovies
RETURN rec.title AS recommendTitle,
count(*) AS weight
ORDER BY weight DESC
LIMIT 10
很多初学者第一次看到这个查询会懵:怎么这么多 MATCH?这就是图和关系型最大的不同。每一行 MATCH 都像在图上“走一步”,前一步的结果作为后一步的起点。
WITH 在这里很重要。它负责把中间结果暂存,同时配合 collect 把电影集合保存下来,后面用 NOT rec IN watchedMovies 排除用户已经看过的电影。
把这段 Cypher 放进 Repository 方法也很方便:
java复制@Query("MATCH (me:User {name: $name})-[:RATED]->(movie:Movie) " +
"WITH me, collect(DISTINCT movie) AS watchedMovies " +
"MATCH (movie)<-[:RATED]-(other:User) " +
"WHERE other <> me " +
"MATCH (other)-[:RATED]->(rec:Movie) " +
"WHERE NOT rec IN watchedMovies " +
"RETURN rec.title AS recommendTitle, count(*) AS weight " +
"ORDER BY weight DESC LIMIT 10")
List<Map<String, Object>> recommendMovies(@Param("name") String name);
4.3 返回集处理:用 Map、实体还是汇总结果
上面的 Repository 方法返回 List<Map<String, Object>>,属于最通用的做法。查询里 AS 指定的别名会变成 Map 的 key,比如 recommendTitle、weight。
如果你更想要强类型结果,有两个选择:
- 返回节点实体:适合查询结果本身就是一个完整节点,比如
RETURN m然后方法返回List<Movie>; - 返回 DTO/投影:适合只取少数几个字段,但 Spring Data Neo4j 对投影接口的支持在不同版本有细微差异,用之前最好在本地跑一遍。
我在实际项目里的偏好是:跨实体聚合查询一律返回 Map,简单节点查询返回实体。刚开始不要过度封装,先把数据从 Neo4j 里正确捞出来,后面再根据前端需求做 DTO 组装。
4.4 查关系类型:还想知道“节点之间有哪些关系”
从一个节点出发,除了查邻居,有时候还想知道所有出边关系类型。用 Cypher 的 type(r) 和 labels(n) 就能看到:
cypher复制MATCH (u:User {name: '张三'})-[r]->(target)
RETURN type(r) AS relationType,
labels(target) AS targetLabels,
target.name AS targetName
这个查询非常适合调试:你不确定张三节点上都挂了什么关系,跑一下就能把所有出边关系列出来。生产环境不建议频繁这么查,但开发排障效率很高。
5. 写入、更新和事务:增删改中比 SQL 麻烦的细节
5.1 通过 save 创建节点和关系
Spring Data Neo4j 的写入入口很统一:调用 Repository 的 save 方法。它会递归检查实体关联的节点和关系,只要是新对象就执行 CREATE,已存在并关联的就 MERGE。
比如给张三添加一条电影评分:
java复制@Service
public class UserService {
private final UserRepository userRepository;
private final MovieRepository movieRepository;
public UserService(UserRepository userRepository, MovieRepository movieRepository) {
this.userRepository = userRepository;
this.movieRepository = movieRepository;
}
@Transactional
public User addRating(String userName, String movieTitle, int stars, String comment) {
User user = userRepository.findByName(userName)
.orElseGet(() -> userRepository.save(new User(userName)));
Movie movie = movieRepository.findByTitle(movieTitle)
.orElseThrow(() -> new IllegalArgumentException("电影不存在: " + movieTitle));
RatingRel rating = new RatingRel(stars, comment, movie);
user.getRatings().add(rating);
return userRepository.save(user);
}
}
这里有个容易忽略的点:new User() 不带 ID,调用一次 save 会创建节点;RatingRel 也是新对象,所以保存 User 时,会同时创建 RATED 关系。如果 movieRepository.findByTitle 查到的是已有 Movie 节点,Spring Data Neo4j 知道目标节点已存在,不会重复创建 Movie,只会创建关系和 User。
5.2 关系替换与“孤儿关系”问题
关系型数据库里,更新中间表数据时,你会显式 DELETE + INSERT。Neo4j 里通过实体对象操作也有类似逻辑,但要注意:
- 如果你从一个已加载的 User 实体上清空
ratings列表后保存,Spring Data Neo4j 会把这些关系删掉; - 但如果你直接通过 Cypher 删除关系,只删关系不删节点,对应的 Movie 节点就变成“孤立节点”,仍然占内存。
比如想删除张三对某部电影的评分,最安全的是:
cypher复制MATCH (u:User {name: '张三'})-[r:RATED]->(m:Movie {title: '某电影'})
DELETE r
这条只会删除关系。如果还想同时删电影节点,必须 DETACH DELETE:
cypher复制MATCH (m:Movie {title: '某电影'})
DETACH DELETE m
在实体代码里,如果确实想删一个节点及其所有关系,我一般不用 deleteById,而是写一条自定义删除方法:
java复制public interface MovieRepository extends Neo4jRepository<Movie, Long> {
@Query("MATCH (m:Movie) WHERE m.title = $title DETACH DELETE m")
void deleteByTitleDetach(@Param("title") String title);
}
DETACH DELETE 会自动删除该节点关联的所有入边和出边,比让 ORM 帮你清理关系靠谱得多。
5.3 事务边界和批量写入
Spring Boot 整合 Neo4j 后,@Transactional 默认走的是 Neo4j 事务管理器。但要注意,Spring Data Neo4j 的事务边界和 JPA 不太一样:一次 repository 方法调用通常等价于一个事务内的操作,但如果你在 service 里循环调用多个 repository 方法,整个方法又不加 @Transactional,那每次调用可能都是独立事务,一旦中间出错,前面的写入不会回滚。
正确做法是给批量操作方法加事务:
java复制@Transactional
public void batchAddRatings(List<RatingInput> inputs) {
for (RatingInput input : inputs) {
addRating(input.getUserName(), input.getMovieTitle(),
input.getStars(), input.getComment());
}
}
但如果你的批量数据非常大,比如一次几万条,单事务把所有操作串起来会非常慢。更推荐的做法是直接用 Neo4jClient 把数据批量灌进去:
java复制Map<String, Object> params = Map.of("rows", ratingRows);
neo4jClient.query("""
UNWIND $rows AS row
MATCH (u:User {name: row.userName})
MATCH (m:Movie {title: row.movieTitle})
MERGE (u)-[r:RATED]->(m)
SET r.stars = row.stars, r.comment = row.comment
""").bindAll(params).run();
UNWIND 能把 Java 里的 List 展开成 Cypher 中的多行,一次网络往返处理大量写入。这是个非常实用的性能优化手段,尤其适合做数据迁移和冷数据导入。
6. 性能优化与常见坑:真实项目里绕不开的几件事
6.1 索引设计:先建索引再谈查询
很多 Neo4j 慢查询,并不是 Cypher 写得差,而是没建索引。用户按 name 查电影按 title 查,如果不建索引,Neo4j 会做全库扫描,数据量一大就完蛋。
在 Neo4j Browser 或启动时执行:
cypher复制CREATE INDEX user_name_idx IF NOT EXISTS FOR (u:User) ON (u.name);
CREATE INDEX movie_title_idx IF NOT EXISTS FOR (m:Movie) ON (m.title);
另外,如果 Cypher 里经常按关系属性过滤,比如只查评分 4 星以上的关系,也可以考虑对关系建索引。Neo4j 5.x 支持关系属性索引,但具体语法要看你用的版本,建立前先查一下官方文档。
建索引不是一劳永逸。并发上来之后,还可以用 EXPLAIN 看查询计划,确认是否走了索引。
6.2 慢查询诊断:EXPLAIN 与 PROFILE
Neo4j Browser 里直接跑 Cypher 时,可以加前缀:
cypher复制EXPLAIN
MATCH (u:User {name: '张三'})-[:RATED]->(m:Movie)
RETURN m.title
EXPLAIN 只生成查询计划,不实际执行。如果想看到每个算子的真实行数、耗时、命中数量,用:
cypher复制PROFILE
MATCH (u:User {name: '张三'})-[:RATED]->(m:Movie)
RETURN m.title
我在排查慢查询时,重点看两个地方:一是有没有 NodeByLabelScan,这代表全标签扫描;二是 Expand 算子有没有预估行数远大于实际行数,如果有,通常说明关系类型或方向写错了,导致 Cypher 走了错误路径。
6.3 常见问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 项目启动连不上 Neo4j | Neo4j 没启动,或 bolt 端口不对 | 先确认 7687 能通,telnet localhost 7687 |
| 浏览器能开 7474,Java 连不上 7687 | 只映射了 7474,或容器端口没卷出来 | Docker 启动加上 -p 7687:7687 |
| Neo4j 不能通过 IP 访问 | Neo4j 默认监听 127.0.0.1 | 设置监听地址为 0.0.0.0,并放行 TCP 7687 |
| Spring Boot 2.x 项目照抄 3.x 配置 | 配置前缀不同 | 2.x 用 spring.data.neo4j.*,3.x 用 spring.neo4j.* |
| 启动报驱动协议版本不兼容 | Neo4j Driver 版本和数据库版本不匹配 | 用 Spring Boot BOM 管理版本,不要手动乱指定 |
findByName 查不出数据 |
参数名编译后丢失 | 方法参数加 @Param("name") |
| save 后关系重复创建 | 双向关联都在维护 | 只在一侧维护关系,另一侧用 Cypher 查询 |
| 删除节点报“仍有关系” | 未使用 DETACH DELETE | 自定义 @Query 执行 DETACH DELETE |
| 返回实体导致 JSON 无限递归 | 节点间循环引用 | 在 DTO 层裁剪字段,不要把实体直接返给前端 |
6.4 查询结果过大导致内存飙升
图数据库最容易踩的坑,是查询不限制返回条数。你写 RETURN u, m,如果中间路径是笛卡尔积膨胀,可能一下返回几十万条记录,然后服务直接 OOM。
统一规范:
- 开发环境也要写
LIMIT; - 聚合字段用
collect(DISTINCT ...)而不是collect(...); - 大数据集分页时,不要用
SKIP加很大的 offset,用WHERE id(movie) > $lastId LIMIT 20这种方式。
我这边实际出现过一次线上事故,就是同事写了一个三跳路径查询,没加 LIMIT,一个请求把所有变化关系全部展开,Neo4j 内存直接被打满。后来把所有对外查询都收口到底层 Repository,并且强制了 LIMIT 约束,才把风险压下去。
最后分享一个我个人用得很顺的经验:SpringBoot 整合 Neo4j,没必要把整个业务的读写都塞进实体对象和 Repository 的自动映射里。我现在的团队约定是,简单的单节点 CRUD 用 Neo4jRepository 内置方法,复杂多跳路径查询全部写原生 Cypher 并放在 Repository 的 @Query 注解里,批量数据用 Neo4jClient 或者直接走 Cypher UNWIND。这样既保留了 Spring 的声明式事务,又能把图查询的性能控制在自己手里,出问题也好排查。如果你正卡在实体映射或者多关系查询上,按上面的例子跑一遍,应该能少走不少弯路。
