创建日期: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. 生产上线的七件事
- 约束先行:每个要
MERGE的标签都得有唯一约束(篇 2 的并发事故)。 - 索引按查询建:先把最常跑的查询列出来,看
PROFILE,再决定建哪个(篇 4)。 - 变长路径必须有上界:
*..3,不要写*。 - 驱动要配连接池:官方驱动默认池大小是 100,按实际并发调。
EXPLAIN进 CI:把关键查询的计划固化下来,索引被误删、查询被改坏时能第一时间发现。- 备份在社区版只能离线做:
neo4j-admin database dump要求停库; 在线热备是企业版功能。这一条极其容易在选型时被漏掉。 - 监控:
SHOW TRANSACTIONS看长事务;SHOW INDEXES看populationPercent有没有卡住。
自测
- Cypher 没有
GROUP BY,那分组是怎么发生的? - 同一个查询里
count(*)返回 3、count(p)返回 0,为什么?什么时候会踩到? collect和UNWIND是什么关系?各举一个真实用途。CALL (u) { ... }括号里的u是干什么的?- 怎么用一行 Cypher 算某个节点的度数?GDS 不装的话还缺什么能力?
- 为什么"算全站 GMV"不该用图数据库?
- 社区版备份有什么限制?为什么这一条容易在选型时被忽略?
关联
- 上一篇:索引与执行计划
- 系列入口:Neo4j 101 入门
- 数据建模的对照:MySQL 表设计与范式、MySQL 性能调优与生产要点
- 推荐系统的另一条路线(向量召回,与图互补):RAG 检索能力边界、Milvus 企业级开发运维方案
- 全文检索的对照实现(FTS5 + BM25):高性能 SQLite 入门
参考
- 实测环境: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 安装