TL;DR
- 系列方向:以 Neo4j + Cypher 为核心,从图数据库基础开始,逐步完成用户关系、好友推荐、商品关联等业务项目。
- 本篇重点:掌握图数据库的数据建模方法,理解节点、关系、属性分别应该承载什么信息,并通过用户社交关系案例设计一套可扩展的数据模型。
前两篇我们已经完成了 Neo4j 环境搭建,也接触了 Cypher 的基本操作。
但真正开始做业务时,很快就会遇到一个问题:
业务对象应该设计成节点,还是属性?两个对象之间的联系应该设计成关系,还是直接存储在节点属性里?
这不是语法问题,而是图数据库建模中更重要的一步。
如果模型设计不合理,后面写查询时就会越来越复杂,甚至需要推翻原来的结构重新设计。
这一篇就通过一个用户社交关系场景,把图数据模型从头设计一遍。
一、先看一个实际业务场景
假设我们正在开发一个用户社交系统,需要支持以下功能:
- 查询用户的基本信息。
- 查询某个用户的好友。
- 查询两个用户之间是否存在好友关系。
- 查询两个用户之间的共同好友。
- 查询用户关注了哪些人。
- 后续增加好友推荐功能。
如果使用传统关系型数据库,我们可能会设计以下几张表:
- user:用户信息表
- user_friend:用户好友关系表
- user_follow:用户关注关系表
例如:
用户表
| user_id | username | age |
|---|---|---|
| 1001 | 张三 | 25 |
| 1002 | 李四 | 28 |
| 1003 | 王五 | 26 |
好友关系表
| user_id | friend_id |
|---|---|
| 1001 | 1002 |
| 1001 | 1003 |
| 1002 | 1003 |
这种设计没有问题。
但当业务开始增加多层关系查询时,我们需要不断通过关联查询来寻找用户之间的联系。
在图数据库中,我们可以换一种思路:
- 用户是节点。
- 好友关系是关系。
- 关注关系也是关系。
- 用户姓名、年龄等信息作为节点属性。
最终形成一张用户关系网络。
二、节点、关系、属性分别应该放什么?
Neo4j 使用属性图模型,主要由三个部分组成。
1. 节点:表示业务中的实体
节点通常代表一个具有独立业务意义的对象。
例如:
- 用户
- 商品
- 订单
- 公司
- 设备
- 文章
在我们的社交系统中,一个用户就是一个节点。
cypher
CREATE (u:User {
user_id: '1001',
username: '张三',
age: 25,
city: '上海'
});
这里的 User 是标签,表示节点属于用户类型。
节点标签可以帮助我们区分不同业务对象。
例如:
cypher
CREATE (u:User {
user_id: '1002',
username: '李四'
});
CREATE (p:Product {
product_id: 'P001',
name: '机械键盘',
price: 299
});
虽然两者都是节点,但它们代表完全不同的业务实体。
实际项目中,我更建议按照业务对象划分节点标签,而不是把所有数据都塞进一个通用节点类型。
2. 关系:表示实体之间的联系
图数据库最重要的特点之一,就是关系可以直接参与查询。
例如:
张三和李四是好友:
cypher
MATCH (a:User {user_id: '1001'}),
(b:User {user_id: '1002'})
CREATE (a)-[:FRIEND]->(b);
张三关注了王五:
cypher
MATCH (a:User {user_id: '1001'}),
(b:User {user_id: '1003'})
CREATE (a)-[:FOLLOWS]->(b);
这里有两个不同的关系类型:
FRIEND:好友关系。FOLLOWS:关注关系。
需要注意一个建模细节:
好友关系和关注关系不应该随意混用。
如果业务中的好友关系是双向的,那么只存储一条有向关系时,查询双方好友都应该明确处理关系方向。例如,可以统一采用 user_id 较小的一方指向较大的一方作为存储规范,再通过无向匹配查询:
cypher
MATCH (a:User)-[:FRIEND]-(b:User)
RETURN a.username, b.username;
如果关注关系是单向的,则应该保留方向:
cypher
MATCH (a:User)-[:FOLLOWS]->(b:User)
RETURN a.username, b.username;
不要因为两者都是用户之间的联系,就把它们设计成同一种关系。
3. 属性:描述节点或关系的特征
属性主要用于描述实体本身,或者描述一段关系。
用户节点可以保存:
cypher
{
user_id: '1001',
username: '张三',
age: 25,
city: '上海'
}
关系也可以有属性。
例如,好友关系建立时间:
cypher
MATCH (a:User {user_id: '1001'}),
(b:User {user_id: '1002'})
CREATE (a)-[:FRIEND {
created_at: '2026-01-10',
source: 'mobile_app'
}]->(b);
这样就可以记录关系的业务信息。
查询时:
cypher
MATCH (a:User)-[r:FRIEND]-(b:User)
RETURN
a.username,
b.username,
r.created_at,
r.source;
这里有一个值得注意的地方:
如果某个字段描述的是用户自身,就应该优先考虑放在用户节点上;如果描述的是两个用户之间的联系,就应该考虑放在关系上。
例如:
| 数据 | 推荐存储位置 |
|---|---|
| 用户姓名 | User 节点属性 |
| 用户年龄 | User 节点属性 |
| 用户所在城市 | User 节点属性 |
| 好友建立时间 | FRIEND 关系属性 |
| 关注时间 | FOLLOWS 关系属性 |
| 关注来源 | FOLLOWS 关系属性 |
三、实际项目中,应该如何设计图模型?
现在我们开始设计一个稍微完整一点的用户社交模型。
业务需求如下:
- 用户之间可以建立好友关系。
- 用户可以关注其他用户。
- 用户可以发布文章。
- 用户可以给文章打标签。
- 用户之间可以通过共同兴趣发现潜在好友。
先不要急着写 Cypher,先把业务对象梳理出来。
1. 确定节点类型
| 节点标签 | 代表对象 | 主要属性 |
|---|---|---|
| User | 用户 | user_id、username、age、city |
| Article | 文章 | article_id、title、created_at |
| Tag | 标签 | tag_id、name |
这里为什么要把 Tag 单独设计成节点?
假设用户发布的文章有多个标签:
- Java
- Python
- 数据库
- 软件测试
如果标签只是文章的一个普通字符串属性,那么后续想查询共同兴趣、标签下的文章、对某个标签感兴趣的用户,就需要额外处理大量字符串。
把标签设计成节点后,就能通过关系表达它们之间的联系。
2. 确定关系类型
| 关系类型 | 起点 | 终点 | 业务含义 |
|---|---|---|---|
| FRIEND | User | User | 用户好友关系 |
| FOLLOWS | User | User | 用户关注关系 |
| PUBLISHES | User | Article | 用户发布文章 |
| HAS_TAG | Article | Tag | 文章拥有标签 |
| INTERESTED_IN | User | Tag | 用户对标签感兴趣 |
最终模型可以理解为:
text
(User)-[:FRIEND]-(User)
(User)-[:FOLLOWS]->(User)
(User)-[:PUBLISHES]->(Article)
(Article)-[:HAS_TAG]->(Tag)
(User)-[:INTERESTED_IN]->(Tag)
这张图已经可以支持不少业务查询。
例如:
- 查询用户发布的文章。
- 查询文章所属标签。
- 查询喜欢相同标签的用户。
- 查询好友之间的共同兴趣。
- 根据共同兴趣推荐潜在好友。
3. 用 Cypher 创建模型
下面把模型转换成实际数据。
首先创建用户:
cypher
CREATE
(:User {
user_id: '1001',
username: '张三',
age: 25,
city: '上海'
}),
(:User {
user_id: '1002',
username: '李四',
age: 28,
city: '北京'
}),
(:User {
user_id: '1003',
username: '王五',
age: 26,
city: '上海'
});
创建文章:
cypher
CREATE
(:Article {
article_id: 'A001',
title: 'Neo4j 入门实践',
created_at: '2026-11-01'
}),
(:Article {
article_id: 'A002',
title: 'Python 自动化测试',
created_at: '2026-11-02'
});
创建标签:
cypher
CREATE
(:Tag {tag_id: 'T001', name: 'Neo4j'}),
(:Tag {tag_id: 'T002', name: 'Python'}),
(:Tag {tag_id: 'T003', name: '软件测试'});
建立用户之间的关系:
cypher
MATCH
(u1:User {user_id: '1001'}),
(u2:User {user_id: '1002'}),
(u3:User {user_id: '1003'})
CREATE
(u1)-[:FRIEND]->(u2),
(u1)-[:FRIEND]->(u3),
(u1)-[:FOLLOWS]->(u2);
建立文章和用户的关系:
cypher
MATCH
(u:User {user_id: '1001'}),
(a:Article {article_id: 'A001'})
CREATE (u)-[:PUBLISHES]->(a);
建立文章和标签的关系:
cypher
MATCH
(a:Article {article_id: 'A001'}),
(t:Tag {tag_id: 'T001'})
CREATE (a)-[:HAS_TAG]->(t);
用户兴趣关系:
cypher
MATCH
(u1:User {user_id: '1001'}),
(u2:User {user_id: '1002'}),
(t1:Tag {tag_id: 'T001'}),
(t2:Tag {tag_id: 'T002'})
CREATE
(u1)-[:INTERESTED_IN]->(t1),
(u2)-[:INTERESTED_IN]->(t1),
(u2)-[:INTERESTED_IN]->(t2);
现在,我们就有了一张包含用户、文章、标签以及它们之间关系的图。
四、模型设计完成后,如何验证它是否合理?
模型不是画出来就结束了。
我更建议在正式开发之前,先用几个真实业务问题验证模型。
如果模型需要写很多复杂查询才能完成简单业务,通常意味着还需要重新检查设计。
场景一:查询用户发布的文章
cypher
MATCH (u:User {user_id: '1001'})
-[:PUBLISHES]->(a:Article)
RETURN
u.username,
a.title,
a.created_at;
场景二:查询文章所属标签
cypher
MATCH (a:Article {article_id: 'A001'})
-[:HAS_TAG]->(t:Tag)
RETURN
a.title,
collect(t.name) AS tags;
场景三:查询具有相同兴趣的用户
cypher
MATCH
(u1:User {user_id: '1001'})
-[:INTERESTED_IN]->(t:Tag)
<-[:INTERESTED_IN]-
(u2:User)
WHERE u1 <> u2
RETURN
u2.username,
collect(t.name) AS common_tags;
这个查询可以找到与张三具有相同兴趣标签的其他用户。
如果后续要做好友推荐,就可以在此基础上增加好友排除、兴趣数量统计、关系权重等业务规则。
场景四:查询好友之间的共同兴趣
cypher
MATCH
(u1:User {user_id: '1001'})
-[:FRIEND]-(u2:User),
(u1)-[:INTERESTED_IN]->(t:Tag),
(u2)-[:INTERESTED_IN]->(t)
RETURN
u2.username,
collect(t.name) AS common_tags;
这类查询体现了图数据库的优势:不需要手动维护多张关系表的关联条件,而是沿着已经定义好的关系进行查询。
五、图数据库建模中容易踩的几个坑
这部分比记住几个 Cypher 语法更重要。
坑一:把所有信息都设计成节点
并不是所有数据都适合单独创建节点。
例如用户年龄、手机号、注册时间等,通常只是用户自身的属性,没有必要全部独立成节点。
判断标准可以简单一些:
如果一个对象具有独立的业务身份,并且需要被多个对象引用或建立关系,那么可以考虑设计为节点。
如果只是描述对象的一个特征,通常放在属性中就够了。
坑二:把关系信息全部放进节点属性
例如:
cypher
CREATE (:User {
username: '张三',
friends: ['李四', '王五', '赵六']
});
这种设计看起来简单,但后续会带来问题:
- 无法直接表达好友之间的关系属性。
- 查询共同好友需要额外处理数组。
- 好友关系变化时,需要更新节点内部的数据。
- 多层关系查询不够自然。
如果好友本身是独立用户,应该优先使用关系表达:
text
(张三)-[:FRIEND]-(李四)
坑三:关系方向没有统一规范
Neo4j 的关系具有方向。
如果同一种业务关系有时从 A 指向 B,有时从 B 指向 A,后续查询就容易出现遗漏。
例如好友关系是双向的,但系统中只存储一条关系,那么查询时需要使用无向模式:
cypher
MATCH (a:User)-[:FRIEND]-(b:User)
RETURN a, b;
如果业务要求分别记录双方的好友状态,或者双方可以独立解除关系,就需要进一步设计状态和关系语义,不能简单认为一条关系就能解决所有问题。
坑四:没有设计业务唯一标识
不要直接把 Neo4j 内部节点 ID 当作业务用户 ID。
业务系统应该使用稳定的业务标识,例如:
- user_id
- product_id
- order_id
并为关键标识创建唯一约束:
cypher
CREATE CONSTRAINT user_id_unique
FOR (u:User)
REQUIRE u.user_id IS UNIQUE;
这样可以避免同一个业务用户被重复创建。
坑五:只考虑当前查询,不考虑未来扩展
例如一开始只需要查询好友,直接设计 FRIEND 关系就可以。
但后续可能增加:
- 好友申请状态。
- 好友建立时间。
- 关系来源。
- 亲密度。
- 是否屏蔽。
这些信息可能需要放到关系属性中,也可能需要进一步引入独立的关系实体节点,具体取决于业务复杂度。
不要为了未来所有可能性过度设计,但也不要把明显属于关系本身的信息随意塞进用户节点。
六、本篇总结
这一篇我们完成了一个用户社交系统的基础图模型设计。
回顾一下:
- 节点:表达用户、文章、标签等独立业务实体。
- 关系:表达用户之间、用户与文章之间的业务联系。
- 属性:描述节点或关系自身的特征。
- 关系方向:需要根据业务语义统一设计。
- 业务唯一标识:不要依赖内部节点 ID 代替业务主键。
我个人认为,图数据库学习中最值得花时间的地方并不是 Cypher 语法,而是建模。
语法写错了可以修改,模型设计错了,后面可能连查询方式和业务实现都要跟着调整。
因此,在实际项目中,我一般会先列出业务实体、梳理实体之间的关系,再用几个真实业务查询验证模型,最后才开始完善代码。