跳到主要内容

创建日期:2026-10-15 | 最近更新:2026-10-15 版本基线:Neo4j 5.26.4 community。本系列用本机 Docker 实例实测(docker exec neo4j-lab cypher-shell ...),文中输出均为真实查询结果,脚本与日志留档在 source/neo4j-lab/。

Neo4j 101 聚合与边界:UNWIND、子查询,以及什么时候别用图

一句话:Cypher 没有 GROUP BY——非聚合列自动成为分组键;UNWIND 与 collect 是一对互逆操作;而「不用 GDS 也能算的二跳推荐」,靠的是子查询表达式加聚合。最后一节讲清楚:哪些问题不该用图解。

本篇是 Neo4j 101 系列 第 5 篇,收尾。

1. 聚合:没有 GROUP BY 的 GROUP BY​

Cypher 有 count / sum / avg / min / max,但没有 GROUP BY 子句。规则是:

RETURN / WITH 里的非聚合表达式,自动成为分组键。

MATCH (u:User)-[:PLACED]->(o:Order)-[:CONTAINS]->(p:Product)
RETURN u.city AS 城市, count(DISTINCT o) AS 订单数, sum(p.price) AS GMV
ORDER BY GMV DESC;

真实输出:

+--------------------+
| 城市 | 订单数 | GMV |
+--------------------+
| "北京" | 3 | 27755 |
| "广州" | 1 | 10998 |
| "上海" | 2 | 10127 |
+--------------------+

城市 不是聚合函数,所以它成了分组键——等价于 SQL 里的 GROUP BY u.city,只是不用写。

count(DISTINCT o) 里的 DISTINCT 在这里是必需的:一个用户可能有多张订单、 每张订单又有多件商品,不按订单去重会把"订单行数"当成"订单数"。 这类"一对多扇出导致重复计数"的坑,和 SQL 里的表现一模一样。

2. count(*) 和 count(p):NULL 的经典差异​

这是和图里 OPTIONAL MATCH 配合时最容易踩的一个。实测:

MATCH (b:Brand) OPTIONAL MATCH (b)<-[:MADE_BY]-(p:Product) WHERE p.price > 999999
RETURN count(*) AS 保留的行数, count(p) AS 非NULL的p;

真实输出:

+-----------------+
| 保留的行数 | 非NULL的p |
+-----------------+
| 3 | 0 |
+-----------------+

同一个查询,两个数完全不同。

  • count(*) 数的是行数——3 个品牌都在(OPTIONAL MATCH 保住了它们)。
  • count(p) 数的是非 NULL 的 p——没有商品超过 999999,所以每一个 p 都是 NULL,结果是 0。

这和 SQL 里 COUNT(*) 和 COUNT(col) 的区别是同一回事。 但图里更容易撞上,因为 OPTIONAL MATCH 用得非常频繁("有就带上,没有也别丢"是常态)。 要数"有多少"的时候先问自己:数的是行,还是某个变量真的存在?

3. UNWIND:把列表摊成行​

collect 和 UNWIND 是一对逆运算:

MATCH (o:Order {orderId:'o1'})-[:CONTAINS]->(p:Product)
RETURN o.orderId AS 订单, collect(p.name) AS 商品;
+---------------------------------------------------+
| 订单 | 商品 |
+---------------------------------------------------+
| "o1" | ["小米 65W 氮化镓充电器", "iPhone 15 Pro 256G 黑色"] |
+---------------------------------------------------+

反过来,把列表摊开去匹配:

UNWIND ['SKU-001','SKU-004','SKU-006'] AS sku
MATCH (p:Product {skuId: sku})
RETURN p.name AS 商品, p.price AS 价格 ORDER BY 价格 DESC;
+--------------------------------+
| 商品 | 价格 |
+--------------------------------+
| "iPhone 15 Pro 256G 黑色" | 8999 |
| "小米 14 256G 白色" | 3999 |
| "小米 65W 氮化镓充电器" | 129 |
+--------------------------------+

UNWIND 最实用的两个场合:批量写入(篇 2 的 load.cypher 全靠它)和把参数里的列表展开成行。

4. 列表推导与模式推导​

列表推导(长得像 Python 的推导式):

MATCH (b:Brand {name:'Apple'})
RETURN b.name AS 品牌,
[(b)<-[:MADE_BY]-(p:Product) | p.skuId] AS 全部商品,
[(b)<-[:MADE_BY]-(p:Product) WHERE p.price > 8000 | p.skuId] AS 高端商品;
+----------------------------------------------------------------------+
| 品牌 | 全部商品 | 高端商品 |
+----------------------------------------------------------------------+
| "Apple" | ["SKU-005", "SKU-002", "SKU-001"] | ["SKU-005", "SKU-001"] |
+----------------------------------------------------------------------+

注意第二个:[(b)<-[:MADE_BY]-(p:Product) WHERE p.price > 8000 | p.skuId]—— 它是**"模式 + 过滤 + 投影"三合一,可以直接写在 RETURN 里,不用另起一个 MATCH。 这种叫模式推导**(pattern comprehension)。

用在路径上也很顺手:

MATCH p = (a:Category {name:'手机'})-[:SUBCATEGORY_OF*1..5]->(b)
RETURN [n IN nodes(p) | n.name] AS 路径;

5. 子查询与子查询表达式​

三种写法,用途不同:

① CALL { } 子查询(5.23 起可以在括号里显式声明作用域):

MATCH (u:User)
CALL (u) {
MATCH (u)-[:PLACED]->(:Order)-[:CONTAINS]->(p:Product)
RETURN sum(p.price) AS gmv
}
RETURN u.name AS 用户, gmv ORDER BY gmv DESC;
+--------------+
| 用户 | gmv |
+--------------+
| "张伟" | 18756 |
| "刘强" | 10998 |
| "李娜" | 10127 |
| "王芳" | 8999 |
+--------------+

老写法是 CALL { WITH u ... },两种在 5.26 上都能跑。新写法把"用到哪些外层变量"写在括号里,可读性好得多。

② EXISTS { } / COUNT { } / COLLECT { } 子查询表达式——这是最实用的一个,因为它能直接写进 RETURN:

MATCH (u:User)
RETURN u.name AS 用户,
COUNT { (u)-[:PLACED]->(:Order) } AS 订单数,
EXISTS { (u)-[:VIEWED]->(:Product {skuId:'SKU-001'}) } AS 看过iPhone
ORDER BY 订单数 DESC, 用户;
+-----------------------+
| 用户 | 订单数 | 看过iPhone |
+-----------------------+
| "张伟" | 2 | TRUE |
| "李娜" | 2 | FALSE |
| "刘强" | 1 | FALSE |
| "王芳" | 1 | TRUE |
+-----------------------+

③ IN TRANSACTIONS 用于分片提交(篇 2 讲过)。

6. 本机的 apoc 和 GDS 到底有什么​

先说结论(都是实测):

状态
apoc✅ 已启用。RETURN apoc.version() → "5.26.4",SHOW PROCEDURES 里 192 个 apoc.* 过程
apoc 的安全策略dbms.security.procedures.unrestricted = "apoc.*"(官方镜像在 NEO4J_PLUGINS 含 apoc 时自动设)
apoc.import.file.enabled❌ 未设置(默认 false)——apoc.load.json 读本地文件会失败,要自己开
GDS(图算法库)❌ 不随包。实测 RETURN gds.version() 直接报错

GDS 的报错原文(这就是"不随包"的实证):

Unknown function 'gds.version' (line 1, column 8 (offset: 7))
"RETURN gds.version() AS gds"
^

要用得重启容器、加进插件列表:

-e NEO4J_PLUGINS='["apoc","graph-data-science"]'

GDS 本身有 Community 版、免 license,社区版 Neo4j 上是可以跑的(它是个独立安装的插件,不受 Neo4j 的版本划分影响)。 但记住:装了 GDS 才叫"有图算法"。

7. 不用 GDS 也能做的三件事​

很多"我需要图算法"的需求,其实一行 Cypher 就够。三件事:

① 度数中心性——找最"热"的商品:

MATCH (p:Product)
RETURN p.skuId AS SKU, p.name AS 商品, count { (p)<-[:CONTAINS]-() } AS 被下单次数
ORDER BY 被下单次数 DESC, SKU;
+---------------------------------------------+
| SKU | 商品 | 被下单次数 |
+---------------------------------------------+
| "SKU-006" | "小米 65W 氮化镓充电器" | 3 |
| "SKU-001" | "iPhone 15 Pro 256G 黑色" | 2 |
| "SKU-004" | "小米 14 256G 白色" | 2 |
| "SKU-002" | "iPhone 15 128G 蓝色" | 1 |
| "SKU-003" | "华为 Mate 60 Pro 512G" | 1 |
| "SKU-005" | "MacBook Air M3 13 英寸" | 1 |
+---------------------------------------------+

count { (p)<-[:CONTAINS]-() } 就是度数。结论也很符合直觉:充电器被下单次数最多(被顺手带走的配件)。

② 二跳推荐——"买过同样东西的人还买了什么":

MATCH (u:User {userId:'u1'})-[:PLACED]->(:Order)-[:CONTAINS]->(a:Product)
MATCH (other:User)-[:PLACED]->(:Order)-[:CONTAINS]->(a)
MATCH (other)-[:PLACED]->(:Order)-[:CONTAINS]->(rec:Product)
WHERE other <> u AND NOT (u)-[:PLACED]->(:Order)-[:CONTAINS]->(rec)
RETURN DISTINCT rec.skuId AS 推荐, rec.name AS 商品, count(DISTINCT other) AS 支持人数
ORDER BY 支持人数 DESC, 推荐;
+----------------------------------------+
| 推荐 | 商品 | 支持人数 |
+----------------------------------------+
| "SKU-002" | "iPhone 15 128G 蓝色" | 1 |
| "SKU-004" | "小米 14 256G 白色" | 1 |
+----------------------------------------+

给买了 iPhone 15 Pro(+充电器)的张伟,推的是 iPhone 15 128G 和 小米 14。 后一条有意思——推荐出来的可能是竞品,因为"买了同一件充电器的人"里有个小米用户。这在真实推荐系统里很常见,也正是需要业务规则兜底的地方。

③ 共同购买(篇 3 讲过)。这三件事是"大部分图算法需求"的真实覆盖面。

什么时候才真的需要 GDS?当你要跑 PageRank、Louvain 社区发现、Node2Vec 嵌入这类 需要迭代收敛的算法时。它们是"对整个图算一次、结果写回属性"的批处理,不是查询。

8. 什么时候别用图​

这一节比前面任何一节都重要。

适合不适合
多跳、深度不定的遍历(好友的朋友、供应链、血缘)大范围聚合扫描("算一下全站 GMV")——图的优势是"沿边走",不是"扫全部"
路径问题(最短、可达、环路检测)需要成熟 SQL 生态与 BI 工具(图的可视化工具链还差得远)
推荐、反欺诈、知识图谱宽表批量更新(按列改一整批行)——图不擅长
模型本身就是图(关系比实体更重要)严格的多行事务记账(账务系统要的是行锁与 SQL 语义)
读多写少、关系密集写入吞吐要求极高的场景

一句话判别:如果"关系"在你的问题里比"实体的属性"更重要,图是天然选择;如果你只是想要一个能存数据的库,关系型更省事。

9. 生产上线的七件事​

  1. 约束先行:每个要 MERGE 的标签都得有唯一约束(篇 2 的并发事故)。
  2. 索引按查询建:先把最常跑的查询列出来,看 PROFILE,再决定建哪个(篇 4)。
  3. 变长路径必须有上界:*..3,不要写 *。
  4. 驱动要配连接池:官方驱动默认池大小是 100,按实际并发调。
  5. EXPLAIN 进 CI:把关键查询的计划固化下来,索引被误删、查询被改坏时能第一时间发现。
  6. 备份在社区版只能离线做:neo4j-admin database dump 要求停库; 在线热备是企业版功能。这一条极其容易在选型时被漏掉。
  7. 监控:SHOW TRANSACTIONS 看长事务;SHOW INDEXES 看 populationPercent 有没有卡住。

自测​

  1. Cypher 没有 GROUP BY,那分组是怎么发生的?
  2. 同一个查询里 count(*) 返回 3、count(p) 返回 0,为什么?什么时候会踩到?
  3. collect 和 UNWIND 是什么关系?各举一个真实用途。
  4. CALL (u) { ... } 括号里的 u 是干什么的?
  5. 怎么用一行 Cypher 算某个节点的度数?GDS 不装的话还缺什么能力?
  6. 为什么"算全站 GMV"不该用图数据库?
  7. 社区版备份有什么限制?为什么这一条容易在选型时被忽略?

关联​

参考​

  • 实测环境:Neo4j 5.26.4 community(neo4j:5.26.4-community)+ apoc 5.26.4;GDS 未安装
  • 脚本与真实输出:source/neo4j-lab/(cypher/03-queries.cypher、out/03-queries.log、out/05-gds-error.log)
  • Neo4j 官方文档:Cypher Manual — Aggregation、Subqueries、GDS 安装