创建日期:2026-10-15 | 最近更新:2026-10-15 版本基线:Neo4j 5.26.4 community(
neo4j:5.26.4-community)。本系列用本机 Docker 实例实测(docker exec neo4j-lab cypher-shell ...),文中输出均为真实查询结果,脚本与日志留档在source/neo4j-lab/。
Neo4j 101:图数据库入门——从 MySQL 的表走到节点与关系
一句话:Neo4j 把「关系」从 JOIN 的产物变成了存储本身——你要查"三度好友""一起买过""最短供应链路径",SQL 里得写一串自连接,Cypher 里就是一句话。这个系列共 6 篇,全部围绕同一张电商图,带你从建图走到读懂执行计划。
| 篇 | 主题 | 状态 |
|---|---|---|
| 0 | 入门:从「表」到「图」,以及本机跑起来(本篇) | ✅ |
| 1 | 建模:把电商 ER 图翻译成属性图 | ✅ |
| 2 | 写入与约束:MERGE 其实不是 upsert | ✅ |
| 3 | 查询与路径:变长路径、最短路径与共同购买 | ✅ |
| 4 | 索引与执行计划:五种索引与 EXPLAIN / PROFILE | ✅ |
| 5 | 聚合与边界:UNWIND、子查询,以及什么时候别用图 | ✅ |
1. 先看一个 SQL 写起来很别扭的问题
需求很朴素:「买了 iPhone 15 Pro 的人,还买了什么?」
关系型库里,数据是这么存的——四张表,靠外键串起来:
-- 示意。本机没有跑 MySQL,这里只想让你看"形状"。
SELECT b.name, COUNT(DISTINCT u.id)
FROM users u
JOIN orders o ON o.user_id = u.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products a ON a.id = oi.sku_id
JOIN orders o2 ON o2.user_id = u.id
JOIN order_items oi2 ON oi2.order_id = o2.id
JOIN products b ON b.id = oi2.sku_id
WHERE a.sku_id = 'SKU-001' AND b.sku_id <> 'SKU-001'
GROUP BY b.name;
六次 JOIN。而且注意它是同一个问题的"两跳":用户 → 订单 → 商品。每多跳一次,就多两个 JOIN、多一层中间结果。
到了图数据库里,同一件事:
MATCH (u:User)-[:PLACED]->(:Order)-[:CONTAINS]->(a:Product {skuId:'SKU-001'})
MATCH (u)-[:PLACED]->(:Order)-[:CONTAINS]->(b:Product)
WHERE a <> b
RETURN b.name AS 一起买的, count(DISTINCT u) AS 人数
ORDER BY 人数 DESC;
差别不在"少打几个字"。差别在于:关系在这两个系统里的身份完全不同。
左边那些虚线是外键——它只是"值碰巧等于另一张表的主键",数据库不会为它单独存任何东西。 所以每次查询,数据库都得现算这些连接。
右边那条实线是关系(Neo4j 里叫关系,relationship)——它是和节点一样的一等公民,独立存在于存储里,两端直接指向彼此。 查询时不用"算",顺着走就行。这就是图数据库常说的 index-free adjacency(无索引邻接)。
多跳的时候,这个差别会被放大:
别把这张图读成"图一定比 SQL 快"。 它说的是代价随深度增长的方式不同: JOIN 的中间结果随跳数膨胀,而图是按"命中多少条边"计费。 跳数少的时候两者都很快;跳数变成 3、4、5 且不固定时,差距才真正拉开。 篇 5 会讲清楚图的适用边界——包括哪些活不该交给它。
2. 图的两个基本单位
先记住,图里只有两样东西:
| 是什么 | 能不能带属性 | 像关系型里的什么 | |
|---|---|---|---|
| 节点(Node) | 一个实体 | ✅ | 一行 |
| 关系(Relationship) | 两个节点之间的有向连接 | ✅ | 一行……或者一张中间表 |
节点身上有 标签(Label,相当于"这张表叫什么",比如 :User)和 属性(Property,键值对)。
关系身上有 类型(Type,比如 :PLACED)、方向,以及属性。
一句话对照:
| 关系型 | 图 | |
|---|---|---|
| 实体 | 表 / 行 | 标签 / 节点 |
| 关联 | 外键(值相等,不存连接) | 关系(真实存储,有方向) |
| 关联上的数据 | 中间表(例如 order_items) | 关系上的属性(例如 :CONTAINS {qty, unitPrice}) |
| 查询 | 声明"要哪些列",由优化器决定怎么连 | 画出你想要的形状,数据库去找图里所有长成这样的地方 |
最后一行是 Cypher 和其他所有查询语言最不一样的地方。你写 (u)-[:PLACED]->(:Order)-[:CONTAINS]->(p) 时,
你不是在"指定步骤",而是在画一个形状——Cypher 里管这叫 pattern(模式)。
3. 本机跑起来
一个文件就够了。装好 Docker,直接跑:
docker run -d --name neo4j-lab \
-p 127.0.0.1:7475:7474 -p 127.0.0.1:7688:7687 \
-e NEO4J_AUTH=neo4j/password \
-e NEO4J_PLUGINS='["apoc"]' \
neo4j:5.26.4-community
注意端口:7687 是 Bolt(neo4j:// 协议,驱动和 cypher-shell 走这个),7474 是 HTTP(浏览器里的 Neo4j Browser)。
这里都绑在 127.0.0.1 上——Neo4j 默认没有鉴权,绑 0.0.0.0 等于把一个可读可写的数据库丢到局域网上。
等十几秒,进命令行:
docker exec -it neo4j-lab cypher-shell -u neo4j -p password
先自检三条:
CALL dbms.components() YIELD name, versions, edition RETURN name, versions, edition;
SHOW DATABASES;
RETURN apoc.version() AS apoc版本;
真实输出:
+-------------------------------------------+
| name | versions | edition |
+-------------------------------------------+
| "Neo4j Kernel" | ["5.26.4"] | "community" |
+-------------------------------------------+
+-----------------------------------------------------------------------------------------------------------+
| name | type | aliases | access | address | role | writer | requestedStatus | currentStatus | statusMessage | default | home | constituents |
+-----------------------------------------------------------------------------------------------------------+
| "neo4j" | "standard" | [] | "read-write" | "localhost:7687" | "primary" | TRUE | "online" | "online" | "" | TRUE | TRUE | [] |
| "system" | "system" | [] | "read-write" | "localhost:7687" | "primary" | FALSE | "online" | "online" | "" | FALSE | FALSE | [] |
+-----------------------------------------------------------------------------------------------------------+
+----------+
| apoc版本 |
+----------+
| "5.26.4" |
+----------+
4. Community 版的四条边界(先知道,免得后面撞墙)
上面 SHOW DATABASES 只返回了两行——这不是巧合,是第一条边界:
| # | 边界 | 具体表现 |
|---|---|---|
| ① | 只有一个用户库 | Community 版只有 neo4j(用户库)+ system(管理库)。你建不了第二个库,多租户要靠标签或子图自己划 |
| ② | NODE KEY / 属性存在约束是企业版 | 唯一性约束(UNIQUENESS)社区版能用;NODE KEY 和"属性不能为空"约束会直接报错(篇 2 贴原文) |
| ③ | GDS 不随包 | 图算法库(Graph Data Science)要单独装。apoc 随镜像启用,GDS 不在里面(篇 5 有实证) |
| ④ | apoc 要显式声明 | 靠环境变量 NEO4J_PLUGINS='["apoc"]' 打开,不是默认带的 |
我们的容器 apoc 是开着的(上面
apoc.version()返回5.26.4)。 另外官方镜像会自动把dbms.security.procedures.unrestricted设成apoc.*—— 不然 apoc 的过程会因为安全策略调不动。
5. 第一个图:3 个 CREATE 建,1 个 MATCH 查
空库里,造一条最小的数据——一个用户、一件商品、一条"浏览过":
CREATE (u:User {userId:'u1', name:'张伟', city:'北京'})
CREATE (p:Product {skuId:'SKU-001', name:'iPhone 15 Pro 256G 黑色', price:8999})
CREATE (u)-[:VIEWED {at: datetime('2026-04-01T10:00:00'), times:2}]->(p);
() 是节点,[] 是关系,--> 是方向。(:User {…}) 读作"一个标签是 User、属性是这些的节点"。
然后用同一个形状把数据查回来:
MATCH (u:User {userId:'u1'})-[r:VIEWED]->(p:Product)
RETURN u.name AS 用户, type(r) AS 关系, r.times AS 次数, p.name AS 商品;
真实输出:
+------------------------------------------------+
| 用户 | 关系 | 次数 | 商品 |
+------------------------------------------------+
| "张伟" | "VIEWED" | 2 | "iPhone 15 Pro 256G 黑色" |
+------------------------------------------------+
两处值得停一下:
MATCH里的(u:User {userId:'u1'})-[r:VIEWED]->(p:Product)和CREATE里写的是同一个形状。 查询就是把建图时那个 pattern 再写一遍——这是 Cypher 最省心的地方。r在这里是关系变量。因为写在了[]里,它能被type(r)、r.times这样取用—— 关系的属性(这里是times)是直接读到的,不需要联表。
6. 同一个问题,SQL 和 Cypher 各写一遍
「我(u1)浏览过什么、买过什么、买过哪些品牌」,分别是 1、2、3 跳:
| 跳数 | Cypher | 真实输出 |
|---|---|---|
| 1 跳 | MATCH (u:User {userId:'u1'})-[:VIEWED]->(p:Product) RETURN p.name | MacBook Air M3 13 英寸、iPhone 15 Pro 256G 黑色 |
| 2 跳 | MATCH (u:User {userId:'u1'})-[:PLACED]->(:Order)-[:CONTAINS]->(p:Product) | 小米 65W 氮化镓充电器、MacBook Air M3 13 英寸、iPhone 15 Pro 256G 黑色 |
| 3 跳 | …-[:CONTAINS]->(p:Product)-[:MADE_BY]->(b:Brand) | 小米、Apple |
第 2 跳上那个
(:Order)是匿名节点——不写变量,只写标签。因为我们只拿它当"过路的",不关心它自己的属性。 用不上的东西就别绑变量,这能让查询意图更清楚,也少一次内存分配。
跳到第 3 跳时,问题来了:如果我要查"任意深度的上级品类"呢?总不能无限往下写 -[:X]->()-[:X]->()…。
这就是 Cypher 的变长模式,下一节……不是,下一篇。
7. 本系列的电商图
后面 5 篇全部围绕这一张图,27 个节点、44 条关系:
真实规模:
+------------------+
| 标签 | 数量 |
+------------------+
| "Order" | 6 |
| "Product" | 6 |
| "Category" | 4 |
| "User" | 4 |
| "Brand" | 3 |
| "Promotion" | 2 |
| "Store" | 2 |
+------------------+
+-----------------------+
| 关系类型 | 数量 |
+-----------------------+
| "CONTAINS" | 10 |
| "BELONGS_TO" | 6 |
| "MADE_BY" | 6 |
| "PLACED" | 6 |
| "VIEWED" | 6 |
| "SELLS" | 5 |
| "SUBCATEGORY_OF" | 3 |
| "APPLIES_TO" | 2 |
+-----------------------+
数据集不大,但每条边都是为了演示某个查询刻意连的:
| 素材 | 用来演示 |
|---|---|
| u1 和 u3 都买过 SKU-001、都浏览过 SKU-001 | 共同购买、以及"没有好友边也能 2 跳连通" |
| SKU-006 被 u1、u2 买过 | 二跳推荐:给 u1 推出 SKU-002 与 SKU-004 |
| 手机 / 笔记本 / 配件 → 数码 | 变长路径(品类树) |
| Apple 与小米之间有 6 条并列最短路,长度都是 4 | 最短路径:走目录是一条,走共同订单是另一条 |
| 用户分布在北京 / 上海 / 广州 | 聚合与隐式分组 |
8. 动手
脚本、数据集、以及文章里每一个数字的日志都在 source/neo4j-lab/:
cd source/neo4j-lab
bash docker-run.sh # 起容器(127.0.0.1:7475 / 7688)
bash run.sh # 灌库 + 跑全部查询,输出落到 out/*.log
自测
- 用一句话说清"外键"和"关系"在存储层面的差别——为什么图数据库不需要 JOIN?
- Cypher 里
()、[]、-->、-[:X]-各表示什么?最后那个少了箭头是什么意思? - Community 版和 Enterprise 版在这个系列里会撞到的边界有哪些?(至少说三条)
CREATE和MATCH里写的 pattern 长得一样,这说明了什么?- 为什么容器要把端口绑在
127.0.0.1而不是0.0.0.0? - 关系上的属性和节点上的属性,读起来的写法有什么不同?
关联
- 下一篇:建模——把一张真实的电商 ER 图逐条翻译成属性图
- 关系型那头的对照:MySQL 入门、MySQL 查询进阶
- 另一种"把关系算出来"的路线(全文检索而非图):高性能 SQLite 入门
- 系列入口:Neo4j 101
参考
- 实测环境:Neo4j 5.26.4 community(
neo4j:5.26.4-community)+ apoc 5.26.4 / Docker 28.x / Linux - 脚本与真实输出:
source/neo4j-lab/(cypher/*.cypher+out/*.log,跑法与局限见其中README.md) - Neo4j 官方文档:Cypher Manual(语法权威定义)、Operations Manual(部署与配置)