跳到主要内容

创建日期: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.nameMacBook 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

自测​

  1. 用一句话说清"外键"和"关系"在存储层面的差别——为什么图数据库不需要 JOIN?
  2. Cypher 里 ()、[]、-->、-[:X]- 各表示什么?最后那个少了箭头是什么意思?
  3. Community 版和 Enterprise 版在这个系列里会撞到的边界有哪些?(至少说三条)
  4. CREATE 和 MATCH 里写的 pattern 长得一样,这说明了什么?
  5. 为什么容器要把端口绑在 127.0.0.1 而不是 0.0.0.0?
  6. 关系上的属性和节点上的属性,读起来的写法有什么不同?

关联​

参考​

  • 实测环境: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(部署与配置)