跳到主要内容

创建日期: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..31 到 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. 查询五坑​

  1. 变长路径不写上界(* 或 *2..)——一旦图里有环或数据量上来,就是灾难。生产上永远写上界。
  2. 写成笛卡尔积:两个互不相连的模式写在一起(MATCH (a:Brand), (b:Category))会让计划里出现 CartesianProduct(篇 4 实测)。 能连起来的就用关系连起来。
  3. OPTIONAL MATCH 放在查询开头:它后面必须有个"主模式"提供行,否则语义说不清。标准写法是先 MATCH 再 OPTIONAL MATCH。
  4. 在 WHERE 里给属性套函数:WHERE toLower(p.name) = '...' 会让索引失效,退化成全标签扫描(篇 4 有实测计划)。
  5. 忘了 count(*) 和 count(p) 的区别——在 OPTIONAL MATCH 之后,这两者会给出完全不同的数,篇 5 实测是 3 和 0。

自测​

  1. -[:X]->、-[:X]-、-- 三者分别匹配什么?
  2. 普通 MATCH 匹配不到东西时,返回的是"一行 NULL"还是"零行"?为什么这个区别重要?
  3. OPTIONAL MATCH 后面跟 WHERE,过滤的是哪一部分?会误删外层行吗?
  4. *..3 的下界是多少?* 又在生产上意味着什么风险?
  5. shortestPath 的结果为什么可能随"允许的关系类型"变化?为什么必须给上界?
  6. 为什么"张伟"和"王芳"之间没有任何好友边,却只隔 2 跳?这对推荐意味着什么?
  7. WITH u, count(...) 里的 u 不写会怎样?

关联​

参考​