创建日期:2026-10-15 | 最近更新:2026-10-15 版本基线:Neo4j 5.26.4 community。本系列用本机 Docker 实例实测(
docker exec neo4j-lab cypher-shell ...),文中输出均为真实查询结果,脚本与日志留档在source/neo4j-lab/。
Neo4j 101 查询与路径:MATCH、变长路径与最短路径
一句话:Cypher 的查询方式是画一个形状,让数据库去找图里所有长成这样的地方——你写的是模式,不是步骤。这篇从最简单的三节点模式讲到变长路径和最短路径,顺带纠正一个 5.x 的高频误传。
本篇是 Neo4j 101 系列 第 3 篇。前面把图建好了,现在开始查。
1. MATCH 的读法:模式即查询
MATCH 里写的东西和 CREATE 里长得一模一样,区别只是:CREATE 是"造一个这样的",MATCH 是"找所有这样的"。
逐字符拆开看:
| 写法 | 含义 |
|---|---|
() | 匿名节点,不绑定变量 |
(u:User) | 绑定到变量 u,标签必须是 User |
(u:User {userId:'u1'}) | 再追加属性条件(内联条件) |
-[r:PLACED]-> | 有向关系,类型 PLACED,绑定变量 r |
--> | 匿名、任意类型、指定方向 |
-[:PLACED]- | 两头都不写箭头 = 忽略方向 |
-- | 任意关系、任意方向 |
从 1 跳走到 3 跳,就是在模式后面接着加:
// 1 跳:我浏览过什么
MATCH (u:User {userId:'u1'})-[:VIEWED]->(p:Product) RETURN p.name AS 我浏览过的;
// 2 跳:我买过什么
MATCH (u:User {userId:'u1'})-[:PLACED]->(:Order)-[:CONTAINS]->(p:Product)
RETURN DISTINCT p.name AS 我买过的;
// 3 跳:我买过哪些品牌
MATCH (u:User {userId:'u1'})-[:PLACED]->(:Order)-[:CONTAINS]->(p:Product)-[:MADE_BY]->(b:Brand)
RETURN DISTINCT b.name AS 我买过的品牌;
真实输出:
+-------------------------+
| 我浏览过的 |
+-------------------------+
| "MacBook Air M3 13 英寸" |
| "iPhone 15 Pro 256G 黑色" |
+-------------------------+
+-------------------------+
| 我买过的 |
+-------------------------+
| "小米 65W 氮化镓充电器" |
| "MacBook Air M3 13 英寸" |
| "iPhone 15 Pro 256G 黑色" |
+-------------------------+
+---------+
| 我买过的品牌 |
+---------+
| "小米" |
| "Apple" |
+---------+
第 2、3 跳里的
(:Order)是匿名节点,只写标签不写变量。 用不上的东西别绑变量——查询意图更清楚,也少一次内存分配。
2. 必选 vs 可选:5.x 没有「关系默认可选」这回事
这一节是辟谣。社区里流传一个说法:Neo4j 5 改了默认行为,MATCH (a)-[r:REL]->(b) 里的关系变成可选的了。
不对。普通 MATCH 依然是必选的——匹配不到就丢行,和 SQL 的 INNER JOIN 一个语义。
实测:u1 没有任何 :WISHLIST 边——
MATCH (u:User {userId:'u1'})-[:WISHLIST]->(p:Product) RETURN count(*) AS 必选版本;
真实输出:
+------+
| 必选版本 |
+------+
| 0 |
+------+
0 行。不是"返回一行但 p 是 null",是一行都没有。
要保留左边那一行,只能显式写 OPTIONAL MATCH(对应 SQL 的 LEFT JOIN):
MATCH (u:User {userId:'u1'}) OPTIONAL MATCH (u)-[:WISHLIST]->(p:Product)
RETURN u.name AS 用户, p IS NULL AS 没有这条边;
+--------------+
| 用户 | 没有这条边 |
+--------------+
| "张伟" | TRUE |
+--------------+
对照着看:
那个"默认可选"的传言大概是这么来的:
OPTIONAL MATCH里能写出匹配不到也合法的模式, 而某些版本里OPTIONAL MATCH后面的WHERE行为有变化。但普通MATCH从头到尾都是必选的, 这条任何时候都没变过。
3. WHERE 的两个坑
① 跟在 OPTIONAL MATCH 后面的 WHERE,属于那个模式的一部分
它不会把你外层的行干掉——只会让"补 NULL"这件事发生得更频繁:
MATCH (u:User) OPTIONAL MATCH (u)-[:PLACED]->(o:Order) WHERE o.amount > 9000
RETURN u.name AS 用户, count(o) AS 大额订单数 ORDER BY 大额订单数 DESC, 用户;
真实输出——四个人全在,只是没达标的计数为 0:
+--------------+
| 用户 | 大额订单数 |
+--------------+
| "张伟" | 2 |
| "刘强" | 1 |
| "李娜" | 0 |
| "王芳" | 0 |
+--------------+
李娜有两笔订单(6257、3999),王芳有一笔(8999),都不到 9000,所以是 0——但她们没有被过滤掉。
如果这是个普通 MATCH,李娜和王芳会直接消失。
② exists(属性) 已经被硬删
老教程里的 WHERE exists(p.name) 在 5.x 上直接报错(真实原文):
The property existence syntax `... exists(variable.property)` is no longer supported.
Please use `variable.property IS NOT NULL` instead.
改写成 WHERE p.name IS NOT NULL 即可。另外还有个**判断"有没有这种边"**的写法:
MATCH (p:Product) WHERE EXISTS { (p)-[:MADE_BY]->(:Brand) } RETURN count(p) AS 有品牌的商品;
+--------------+
| 有品牌的商品 |
+--------------+
| 6 |
+--------------+
EXISTS { 模式 } 是 5.x 推荐写法,exists((a)-[:X]->()) 那种老写法还能跑但已弃用。
4. 变长路径 [:TYPE*1..3]
回到上一篇留下的问题:品类树有任意深度,总不能写死跳数。
MATCH (c:Category {name:'手机'})-[:SUBCATEGORY_OF*1..3]->(root:Category)
RETURN root.name AS 根品类, root.level AS 层级;
*1..3 读作"1 到 3 跳"。真实输出:
+-----------+
| 根品类 | 层级 |
+-----------+
| "数码" | 1 |
+-----------+
把整条路径也打出来(把模式赋给变量 p):
MATCH p = (a:Category {name:'手机'})-[:SUBCATEGORY_OF*1..5]->(b)
RETURN [n IN nodes(p) | n.name] AS 路径, length(p) AS 跳数;
+-------------------+
| 路径 | 跳数 |
+-------------------+
| ["手机", "数码"] | 1 |
+-------------------+
边界写法要记牢:
| 写法 | 含义 |
|---|---|
*1..3 | 1 到 3 跳 |
*..3 | 省略下界 = 从 1 开始(不是 0) |
*2.. | 省略上界 = 到无穷 |
* | 等价于 *1..,无穷深度——生产上别写 |
还有个容易忽略的规则:Cypher 保证"一条关系在一个路径里不重复走",但节点可以重复。 所以变长路径不会原地打转,但可能绕一圈回来。
5. 最短路径
MATCH (a:Brand {name:'Apple'}), (b:Brand {name:'小米'})
MATCH p = shortestPath((a)-[*]-(b))
RETURN [n IN nodes(p) | coalesce(n.name, n.skuId, n.orderId)] AS 路径,
[r IN relationships(p) | type(r)] AS 边, length(p) AS 跳数;
真实输出:
+-----------------------------------------------------------------------------------------------------------------------+
| 路径 | 边 | 跳数 |
+-----------------------------------------------------------------------------------------------------------------------+
| ["Apple", "iPhone 15 Pro 256G 黑色", "o1", "小米 65W 氮化镓充电器", "小米"] | ["MADE_BY", "CONTAINS", "CONTAINS", "MADE_BY"] | 4 |
+-----------------------------------------------------------------------------------------------------------------------+
注意这条路径是"走订单"的:Apple 的 iPhone 和小米的充电器出现在同一张订单 o1 里,所以 2 跳就跨过去了。
如果只允许走"商品目录",路径会换一条——但跳数一样是 4:
MATCH (a:Brand {name:'Apple'}), (b:Brand {name:'小米'})
MATCH p = shortestPath((a)-[:MADE_BY|BELONGS_TO|SUBCATEGORY_OF*..6]-(b))
RETURN [n IN nodes(p) | coalesce(n.name, n.skuId, n.orderId)] AS 路径,
[r IN relationships(p) | type(r)] AS 边, length(p) AS 跳数;
+---------------------------------------------------------------------------------------------------------------------------+
| 路径 | 边 | 跳数 |
+---------------------------------------------------------------------------------------------------------------------------+
| ["Apple", "iPhone 15 Pro 256G 黑色", "手机", "小米 14 256G 白色", "小米"] | ["MADE_BY", "BELONGS_TO", "BELONGS_TO", "MADE_BY"] | 4 |
+---------------------------------------------------------------------------------------------------------------------------+
这条是"sibling 商品"路径:两个品牌的产品在同一个叶子品类下碰头。
两个结论: ① 最短路径依赖你允许哪些关系类型——
-[*]-和-[:A|B*..6]-会给出不同的答案; ② 上界必须给。shortestPath本质是双向 BFS,没有上界时一旦两端不连通,它会一路搜到底。 补一句:shortestPath只返回一条最短路。实测这两个品牌之间有 6 条并列最短路,长度都是 4—— 想看全用allShortestPaths()。
再看一个更有意思的:两个用户之间。他们没有任何好友边,却只隔 2 跳:
MATCH (a:User {userId:'u1'}), (b:User {userId:'u3'})
MATCH p = shortestPath((a)-[*]-(b))
RETURN [n IN nodes(p) | coalesce(n.name, n.skuId, n.orderId)] AS 路径,
[r IN relationships(p) | type(r)] AS 边, length(p) AS 跳数;
+-------------------------------------------------------------------+
| 路径 | 边 | 跳数 |
+-------------------------------------------------------------------+
| ["张伟", "iPhone 15 Pro 256G 黑色", "王芳"] | ["VIEWED", "VIEWED"] | 2 |
+-------------------------------------------------------------------+
这就是协同过滤的雏形:用户之间的距离可以只用"行为"来度量,不需要显式的好友关系。
6. 多跳实战:共同购买
回到开篇那个问题——「买了 SKU-001 的人,还买了什么?」。Cypher 把它写成两段模式:
MATCH (u:User)-[:PLACED]->(:Order)-[:CONTAINS]->(a:Product {skuId:'SKU-001'})
MATCH (u)-[:PLACED]->(:Order)-[:CONTAINS]->(b:Product)
WHERE a <> b
RETURN b.skuId AS 一起买的, b.name AS 商品, count(DISTINCT u) AS 人数
ORDER BY 人数 DESC, 一起买的;
真实输出:
+-----------------------------------------+
| 一起买的 | 商品 | 人数 |
+-----------------------------------------+
| "SKU-005" | "MacBook Air M3 13 英寸" | 1 |
| "SKU-006" | "小米 65W 氮化镓充电器" | 1 |
+-----------------------------------------+
读法:第一段模式锁定"买过 SKU-001 的人"(绑定到 u),第二段模式沿同一个 u 再把他的其它商品捞出来。
两段之间没有任何 JOIN——它们只是共享了变量 u,这就是 Cypher 里"连接"的表达方式。
WHERE a <> b排除自己跟自己配对。count(DISTINCT u)是因为同一个用户可能在多张订单里买过同一件商品, 不去重会把"订单数"当成"人数"。
同单共同购买矩阵(看哪些商品在同一张订单里出现过):
MATCH (o:Order)-[:CONTAINS]->(a:Product), (o)-[:CONTAINS]->(b:Product)
WHERE a.skuId < b.skuId
RETURN a.skuId AS 商品A, b.skuId AS 商品B, count(DISTINCT o) AS 同单次数
ORDER BY 同单次数 DESC, 商品A, 商品B;
+------------------------------+
| 商品A | 商品B | 同单次数 |
+------------------------------+
| "SKU-001" | "SKU-006" | 1 |
| "SKU-002" | "SKU-006" | 1 |
| "SKU-003" | "SKU-004" | 1 |
| "SKU-005" | "SKU-006" | 1 |
+------------------------------+
真实数据里,充电器(SKU-006)是那个"总被顺手带一件"的商品——它出现在 4 组配对中的 3 组里。
这种结论在 SQL 里要自连接 order_items 两次,在图里就是同一条边写两遍。
7. WITH 管道
WITH 是 Cypher 的"分段符":把上一步的结果交给下一步,作用类似子查询,但写起来是流水线。
最常见的用法是聚合后再过滤(等价于 SQL 的 HAVING):
MATCH (u:User)-[:PLACED]->(o:Order)-[:CONTAINS]->(p:Product)
WITH u, count(DISTINCT p) AS 商品种类, sum(p.price) AS 客单价
WHERE 商品种类 >= 2
RETURN u.name AS 用户, 商品种类, 客单价 ORDER BY 客单价 DESC;
为什么
WHERE能接在WITH后面?因为WITH把结果变成了新的"行集", 后面的WHERE过滤的是聚合之后的列(商品种类),而不是原始图里的属性。
一个必须记住的规则:聚合之后,原始变量就"断"了。
上面 WITH u, count(...) 里的 u 必须显式带过去,否则后面的 RETURN 里用不了。
8. 查询五坑
- 变长路径不写上界(
*或*2..)——一旦图里有环或数据量上来,就是灾难。生产上永远写上界。 - 写成笛卡尔积:两个互不相连的模式写在一起(
MATCH (a:Brand), (b:Category))会让计划里出现CartesianProduct(篇 4 实测)。 能连起来的就用关系连起来。 OPTIONAL MATCH放在查询开头:它后面必须有个"主模式"提供行,否则语义说不清。标准写法是先MATCH再OPTIONAL MATCH。- 在
WHERE里给属性套函数:WHERE toLower(p.name) = '...'会让索引失效,退化成全标签扫描(篇 4 有实测计划)。 - 忘了
count(*)和count(p)的区别——在OPTIONAL MATCH之后,这两者会给出完全不同的数,篇 5 实测是3和0。
自测
-[:X]->、-[:X]-、--三者分别匹配什么?- 普通
MATCH匹配不到东西时,返回的是"一行 NULL"还是"零行"?为什么这个区别重要? OPTIONAL MATCH后面跟WHERE,过滤的是哪一部分?会误删外层行吗?*..3的下界是多少?*又在生产上意味着什么风险?shortestPath的结果为什么可能随"允许的关系类型"变化?为什么必须给上界?- 为什么"张伟"和"王芳"之间没有任何好友边,却只隔 2 跳?这对推荐意味着什么?
WITH u, count(...)里的u不写会怎样?
关联
- 上一篇:写入与约束
- 下一篇:索引与执行计划——上面这些查询的计划到底长什么样
- 关系型那头的对照:MySQL 查询进阶:JOIN 与聚合
- 推荐系统的另一条路线(向量召回):RAG 入门、pgvector 入门
- 系列入口:Neo4j 101
参考
- 实测环境:Neo4j 5.26.4 community(
neo4j:5.26.4-community) - 脚本与真实输出:
source/neo4j-lab/(cypher/03-queries.cypher、out/03-queries.log、out/07-exists-error.log) - Neo4j 官方文档:Cypher Manual — MATCH、OPTIONAL MATCH、Variable-length patterns