数据库基础体系 · 第 52/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

Neo4j 图数据建模:节点、关系、属性、约束与 Cypher 基础

Neo4j 是以图为核心的数据管理系统。它把实体之间的连接作为一等数据,而不是把连接隐含在多张关系表的外键中。一个典型图模型由以下元素组成:

  • 节点(node):表示实体;
  • 关系(relationship):表示两个节点之间有方向的连接;
  • 标签(label):对节点进行分类;
  • 关系类型(relationship type):对关系进行分类;
  • 属性(property):附着在节点或关系上的键值;
  • 约束(constraint):对部分结构和属性值施加一致性规则;
  • Cypher:用于查询、写入和管理图数据的声明式语言。

这些概念并不是 SQL 表、行、列和外键的一一翻译。图建模的关键,是先明确业务中的实体、连接和连接本身的事实,再决定哪些内容放在节点、关系或属性上。

下文以 Neo4j 5.x 及其当前公开 Cypher 语义为背景。具体可用的约束类型、管理命令和部署能力还会受到 Neo4j 版本、商业版或社区版、单机或集群部署的影响;示例默认在一个已启动的 Neo4j 数据库中执行,且命令使用 Cypher Shell、Neo4j Browser 或驱动提交的单条事务语句。


一、先建立图模型:实体、事实与连接

1. 节点表示可独立识别的实体

节点通常表示可以单独被讨论、查询或被多个事实引用的对象,例如:

  • 用户;
  • 商品;
  • 公司;
  • 订单;
  • 项目;
  • 城市。

节点可以有一个或多个标签:

(:User)
(:User:Employee)
(:Company)

标签不是类继承,也不是强制类型系统。一个节点可以没有标签,也可以同时拥有多个标签。Employee 不会因为写成 :User:Employee 就自动继承 User 的约束或属性;如果业务需要两个标签都存在,应显式写入并在应用层维护其语义。

节点的内部标识符由 Neo4j 管理,但不应把内部 ID 当作跨系统、跨备份或长期业务主键。更稳妥的做法是在节点上保存业务标识:

(:User {userId: 'u-1001', name: 'Alice'})

其中 userId 是业务键,而不是 Neo4j 内部节点 ID。

2. 关系表示两个节点之间的事实

关系连接两个节点,并且具有:

  1. 起点;
  2. 终点;
  3. 一个关系类型;
  4. 可选属性。

例如:

(:User)-[:WORKS_AT]->(:Company)

表示用户在公司工作。箭头方向是数据的一部分,而不只是绘图习惯。下面两个模式在 Cypher 中含义不同:

(u:User)-[:FOLLOWS]->(v:User)
(u:User)<-[:FOLLOWS]-(v:User)

关系类型 FOLLOWS 也属于模式的一部分。FOLLOWSWORKS_ATPURCHASED 是三个不同的类型。

关系必须连接两个节点,不能直接连接三个或更多节点。如果业务事实本质上是多元关系,例如:

Alice 在 2024 年以某个职位参加了 Acme 项目

可以建模为:

(:Person)-[:ASSIGNED_TO {role: 'Architect'}]->(:Project)

如果 companyperiod 等信息不能自然附着在这条关系上,或者这次参与本身需要被其他实体引用,则可引入事件或关联节点:

(:Person)-[:HAS_ASSIGNMENT]->(:Assignment)-[:FOR_PROJECT]->(:Project)
                                      |
                                      +--[:AT_COMPANY]->(:Company)

这不是“节点越多越好”,而是要根据事实的独立身份和查询需求决定是否提升为节点。

3. 属性是键值,不是列定义

节点和关系都可以有属性:

(:User {
  userId: 'u-1001',
  name: 'Alice',
  age: 30,
  tags: ['backend', 'java']
})

(:User)-[:WORKS_AT {
  since: date('2021-03-01'),
  title: 'Engineer'
}]->(:Company)

Neo4j 属性值通常可以是:

  • 字符串、布尔值、整数、浮点数;
  • 日期、时间、日期时间、持续时间;
  • 这些基本值组成的列表。

属性不是任意嵌套的 JSON 文档。复杂对象通常应拆成节点、关系,或在边界处序列化为字符串;后一种做法会牺牲图查询能力和类型约束能力。

属性不存在与属性为 null 在 Cypher 中基本都表现为“没有可用值”。Neo4j 不把 null 作为一个普通可存储属性值。下面的写法不会保存一个值为 null 的属性:

CREATE (:User {userId: 'u-1002', nickname: null})

nickname 会被视为未设置。删除已有属性使用:

MATCH (u:User {userId: 'u-1002'})
REMOVE u.nickname

列表可以保存,但通常要求元素类型一致。例如字符串列表适合保存标签词:

SET u.tags = ['backend', 'java']

不要把列表当作关系的替代品。如果系统需要查询“所有关注 Alice 的用户”,应建模为 [:FOLLOWS] 关系,而不是在每个用户节点上维护一个 followingUserIds 列表。关系能被索引式定位、遍历和附加属性,而列表需要应用层维护引用完整性。


二、图模型与关系模型的差异

关系数据库通常用表描述实体,用外键描述连接。例如:

users(id, name)
companies(id, name)
employment(user_id, company_id, since)

图模型则直接保存:

(:User)-[:WORKS_AT {since: ...}]->(:Company)

两种模型都能表达相同事实,但访问路径不同。

在关系模型中,要查找 Alice 工作公司的员工,通常需要:

  1. 根据 Alice 的主键查找用户;
  2. 读取 employment 外键;
  3. 连接 companies
  4. 再根据公司反向连接 employment
  5. 连接回 users

在图模型中,查询直接沿关系遍历:

MATCH (alice:User {userId: 'u-1001'})-[:WORKS_AT]->(company:Company)
MATCH (colleague:User)-[:WORKS_AT]->(company)
RETURN colleague;

这里的优势不是“图数据库永远比关系数据库快”,而是关系路径本身是存储模型的一部分。最终性能仍取决于起点定位、数据规模、模式选择、遍历深度、选择性和执行计划。

图模型也不自动消除规范化问题。比如下面两种设计都可能合理:

(:User {email: 'alice@example.com'})

和:

(:User)-[:HAS_EMAIL]->(:Email {value: 'alice@example.com'})

如果邮箱只是用户的一个不可共享属性,第一种更简单;如果邮箱需要独立验证、归属变更、历史记录或被多个实体引用,第二种更适合。判断标准仍然是事实的独立身份、基数、生命周期和查询方式,而不是机械套用“所有字段都做节点”或“所有信息都做属性”。


三、一个可运行的示例模型

下面建立一个小型项目协作图:

  • User 表示用户;
  • Company 表示公司;
  • Project 表示项目;
  • User 可以在 Company 工作;
  • User 可以参与 Project
  • User 可以关注其他 User

1. 创建约束

先为业务键建立唯一性约束:

CREATE CONSTRAINT user_user_id_unique IF NOT EXISTS
FOR (u:User)
REQUIRE u.userId IS UNIQUE;

CREATE CONSTRAINT company_company_id_unique IF NOT EXISTS
FOR (c:Company)
REQUIRE c.companyId IS UNIQUE;

CREATE CONSTRAINT project_project_id_unique IF NOT EXISTS
FOR (p:Project)
REQUIRE p.projectId IS UNIQUE;

这几条语句的含义分别是:

  • 对标签为 User 的节点,userId 必须唯一;
  • 对标签为 Company 的节点,companyId 必须唯一;
  • 对标签为 Project 的节点,projectId 必须唯一。

唯一性约束通常同时为该属性建立可用于定位的索引,但“约束”和“索引”概念不能混为一谈:约束负责拒绝不合法数据,索引主要负责加速定位。一个普通索引并不会自动禁止重复值。

如果要求业务键必须存在,不能只依赖唯一性语义。应根据目标版本和部署版本使用属性存在性约束,例如:

CREATE CONSTRAINT user_user_id_exists IF NOT EXISTS
FOR (u:User)
REQUIRE u.userId IS NOT NULL;

Neo4j 5.x 还支持属性类型约束,语法形态为:

CREATE CONSTRAINT user_user_id_string IF NOT EXISTS
FOR (u:User)
REQUIRE u.userId IS :: STRING;

不同版本和发行版对属性存在性、类型、节点键等约束的支持范围可能不同,执行前应以目标数据库的 SHOW CONSTRAINTS 和对应版本手册为准。创建约束时,如果已有数据违反规则,创建会失败,而不是自动修复旧数据。

查看约束和索引:

SHOW CONSTRAINTS;
SHOW INDEXES;

2. 创建基础节点

CREATE
  (alice:User {
    userId: 'u-1001',
    name: 'Alice',
    tags: ['backend', 'java']
  }),
  (bob:User {
    userId: 'u-1002',
    name: 'Bob',
    tags: ['frontend']
  }),
  (acme:Company {
    companyId: 'c-2001',
    name: 'Acme'
  }),
  (neo:Project {
    projectId: 'p-3001',
    name: 'Neo4j Migration'
  })
RETURN alice, bob, acme, neo;

CREATE 每执行一次就创建模式中指定的新元素。即使相同的属性值已存在,它也不会自动去重。因此,下面的语句在没有约束时可能产生重复用户:

CREATE (:User {userId: 'u-1001', name: 'Alice'});

有唯一性约束时,第二次执行会因约束冲突失败,当前事务中的写入也会回滚。

3. 创建关系

MATCH (alice:User {userId: 'u-1001'})
MATCH (bob:User {userId: 'u-1002'})
MATCH (acme:Company {companyId: 'c-2001'})
MATCH (neo:Project {projectId: 'p-3001'})
CREATE
  (alice)-[:WORKS_AT {
    since: date('2021-03-01'),
    title: 'Backend Engineer'
  }]->(acme),
  (alice)-[:WORKS_ON {role: 'Tech Lead'}]->(neo),
  (bob)-[:WORKS_ON {role: 'Frontend Engineer'}]->(neo),
  (alice)-[:FOLLOWS]->(bob)
RETURN alice, bob, acme, neo;

这里先用业务键定位已有节点,再创建关系。业务键上的唯一性约束保证每个 MATCH 最多命中一个节点;如果没有这种保证,多个匹配会形成笛卡尔组合,意外创建多条关系。

例如:

MATCH (a:User {name: 'Alice'})
MATCH (b:User {name: 'Bob'})
CREATE (a)-[:FOLLOWS]->(b);

如果 name 不唯一,这条语句可能创建多条 FOLLOWS 关系。查询看似正确,结果却由数据重复情况决定,这是图写入中的常见错误。


四、Cypher 的基本读写语义

Cypher 用模式表达图结构,用变量引用匹配到的节点、关系或路径。

1. MATCH:匹配已有模式

查询 Alice:

MATCH (u:User {userId: 'u-1001'})
RETURN u.userId, u.name, u.tags;

模式由以下部分组成:

(u:User {userId: 'u-1001'})

分别表示:

  • u:变量名;
  • User:节点标签;
  • {userId: 'u-1001'}:属性过滤条件。

查询 Alice 工作的公司:

MATCH (u:User {userId: 'u-1001'})-[r:WORKS_AT]->(c:Company)
RETURN u.name, type(r), r.title, c.name;

查询结果中的 r 是关系变量,可以读取 r.sincer.title 等属性;type(r) 返回关系类型。

方向也可以写成反向:

MATCH (c:Company)<-[:WORKS_AT]-(u:User)
WHERE c.companyId = 'c-2001'
RETURN u.name;

若业务语义不关心方向,可以使用无向模式:

MATCH (u:User)-[:FOLLOWS]-(other:User)
RETURN u.name, other.name;

但无向匹配只影响本次匹配方向,不会改变关系存储的方向,也不应被用来掩盖方向建模不清的问题。

2. WHERE:过滤匹配结果

属性模式和 WHERE 都可用于过滤:

MATCH (u:User)-[r:WORKS_ON]->(p:Project)
WHERE r.role = 'Tech Lead'
RETURN u.name, p.name;

属性不存在时,比较结果通常是 null,而不是 true。Cypher 使用三值逻辑:truefalsenull。例如:

MATCH (u:User)
WHERE u.nickname = 'A'
RETURN u;

没有 nickname 的节点不会被返回,因为条件不是 true

判断属性是否存在可使用:

MATCH (u:User)
WHERE u.nickname IS NOT NULL
RETURN u;

对于可选关系,使用 OPTIONAL MATCH

MATCH (u:User {userId: 'u-1001'})
OPTIONAL MATCH (u)-[:WORKS_ON]->(p:Project)
RETURN u.name, p.name;

如果 Alice 没有项目,Alice 仍会返回,但 p.namenullOPTIONAL MATCH 后的过滤条件要特别注意作用范围,通常应将与可选模式相关的条件放在同一个 WHERE 中。

3. RETURN、聚合和排序

统计每个项目的参与人数:

MATCH (u:User)-[:WORKS_ON]->(p:Project)
RETURN p.projectId, p.name, count(u) AS memberCount
ORDER BY memberCount DESC;

count(u) 统计非 null 的用户。若要统计去重用户,使用:

count(DISTINCT u)

查询路径:

MATCH p=(u:User {userId: 'u-1001'})-[:FOLLOWS*1..3]->(target:User)
RETURN p;

*1..3 表示关系长度为 1 到 3。可变长度遍历很容易产生大量路径,尤其在高连接度图中。关系类型、方向、起点条件和最大深度都应尽量明确。需要“到达过哪些节点”时,往往还应使用:

RETURN DISTINCT target;

否则同一个目标节点可能因不同路径被返回多次。

4. CREATEMERGE

CREATE 表示无条件创建。

MERGE 表示:尝试匹配整个模式;匹配不到时创建。最常见的用法是按业务键幂等创建节点:

MERGE (u:User {userId: 'u-1003'})
ON CREATE SET u.name = 'Carol',
              u.createdAt = datetime()
ON MATCH SET  u.lastSeenAt = datetime()
RETURN u;

ON CREATE SET 只在新建时执行,ON MATCH SET 只在匹配到已有节点时执行。

创建两个节点并确保它们之间有关系:

MATCH (u:User {userId: 'u-1001'})
MATCH (p:Project {projectId: 'p-3001'})
MERGE (u)-[r:WORKS_ON]->(p)
ON CREATE SET r.role = 'Tech Lead'
RETURN r;

MERGE 不等于任意业务去重。下面的模式把用户、公司和关系属性一起作为一个整体匹配:

MERGE (u:User {userId: 'u-1001'})
      -[:WORKS_AT {title: 'Engineer'}]->
      (c:Company {companyId: 'c-2001'})

如果关系上的 title 变化,整个模式可能匹配不到,从而创建另一条关系。更可控的写法是把节点和关系分开:

MERGE (u:User {userId: 'u-1001'})
MERGE (c:Company {companyId: 'c-2001'})
MERGE (u)-[r:WORKS_AT]->(c)
SET r.title = 'Engineer';

如果业务要求同一对节点之间只能存在一条某种关系,MERGE 只能在语句执行时帮助避免重复创建;Neo4j 的常规属性约束并不能直接表达“同一对端点和同一关系类型唯一”。并发写入下还应通过事务设计、应用幂等键或专门的建模方案保证这一业务规则。

5. SETREMOVEDELETE

更新节点:

MATCH (u:User {userId: 'u-1001'})
SET u.name = 'Alice Chen',
    u.updatedAt = datetime()
RETURN u;

添加或移除标签:

MATCH (u:User {userId: 'u-1001'})
SET u:Verified
REMOVE u:Temporary
RETURN labels(u);

删除属性:

MATCH (u:User {userId: 'u-1001'})
REMOVE u.nickname
RETURN u;

删除节点前必须处理关系:

MATCH (u:User {userId: 'u-1003'})
DETACH DELETE u;

DELETE u 在节点仍有关系时会失败;DETACH DELETE 会先删除该节点的所有关系,再删除节点。它可能造成大量数据删除,生产操作必须有明确的匹配范围、事务边界和恢复方案。


五、约束:保证哪些数据规则

约束是数据库层的一致性规则。它与应用代码中的“先查询、再判断、后写入”不同:约束由数据库在写入时检查,能覆盖多个客户端和并发事务。

1. 唯一性约束

CREATE CONSTRAINT project_project_id_unique IF NOT EXISTS
FOR (p:Project)
REQUIRE p.projectId IS UNIQUE;

含义是:所有带有 Project 标签且存在 projectId 属性的节点,projectId 值不能重复。

它不等价于“所有 Project 都必须有 projectId”。因此通常要同时考虑:

CREATE CONSTRAINT project_project_id_exists IF NOT EXISTS
FOR (p:Project)
REQUIRE p.projectId IS NOT NULL;

如果目标版本支持节点键,也可以用节点键表达“属性组合必须存在且组合唯一”。概念上,节点键 (tenantId, userId) 要求:

  1. 两个属性都存在;
  2. 两个属性的组合不能重复。

这适合多租户业务中的局部标识,但具体语法和版本支持应以目标环境为准。

2. 属性存在性和类型约束

属性存在性约束表达:

所有符合标签或关系类型的实体都必须有某属性

例如:

CREATE CONSTRAINT works_at_since_exists IF NOT EXISTS
FOR ()-[r:WORKS_AT]-()
REQUIRE r.since IS NOT NULL;

属性类型约束表达:

属性必须是指定类型

例如:

CREATE CONSTRAINT user_tags_list IF NOT EXISTS
FOR (u:User)
REQUIRE u.tags IS :: LIST<STRING>;

类型约束不能替代业务校验。例如 date 类型的日期值合法,并不意味着它一定不晚于当前日期;后者仍需通过查询、事务逻辑或应用规则实现。

3. 约束不能表达所有图规则

常见但不能直接由普通属性约束保证的规则包括:

  • 每个用户必须恰好属于一个公司;
  • 一个项目至少有一名负责人;
  • WORKS_ATsince 必须早于离职日期;
  • 同一用户不能关注自己;
  • 同一对节点之间只能有一条特定关系;
  • 用户和公司必须处于同一租户。

这些规则通常需要:

  1. 在同一写事务中先读后写并检查;
  2. 使用应用服务封装写入;
  3. 通过事件、批处理或数据质量任务校验;
  4. 重新建模,把难以表达的关系提升为具有业务键的关联节点。

约束冲突通常表现为语句或事务失败,失败事务中的写入不会部分提交。客户端必须正确处理异常,而不是假设“只有冲突的那一行失败,其他写入仍然成功”。


六、索引、标签与查询起点

图遍历并不意味着每次查询都可以从任意节点开始。一个典型查询分为两步:

  1. 定位起点:根据业务键、邮箱等属性找到少量节点;
  2. 遍历关系:从这些节点沿方向和类型扩展。

例如:

MATCH (u:User {userId: $userId})-[:WORKS_ON]->(p:Project)
RETURN p.projectId, p.name;

$userId 是参数。标签和属性条件使数据库有机会使用对应索引;找到用户后,再遍历其 WORKS_ON 关系。

普通索引示例:

CREATE INDEX user_email_index IF NOT EXISTS
FOR (u:User)
ON (u.email);

这表示 User.email 可建立索引,但它不保证邮箱唯一。若邮箱是业务唯一标识,应优先使用唯一性约束,而不是普通索引。

需要区分:

  • 标签:分类和模式匹配条件;
  • 索引:加速属性定位;
  • 约束:拒绝违反规则的数据;
  • 关系遍历:从已定位节点沿图结构访问相邻数据。

要观察实际执行方式,可使用:

EXPLAIN
MATCH (u:User {userId: 'u-1001'})-[:WORKS_ON]->(p:Project)
RETURN p.name;

EXPLAIN 只生成执行计划,不执行查询。PROFILE 会实际执行并收集运行统计:

PROFILE
MATCH (u:User {userId: 'u-1001'})-[:WORKS_ON]->(p:Project)
RETURN p.name;

应重点观察起点是否被高选择性地定位、遍历产生的行数是否迅速膨胀、是否出现不必要的笛卡尔积,以及返回数据量是否远大于业务需要。执行计划和具体算子名称可能随版本变化,不能只凭算子名称推断性能,必须结合实际数据分布和运行统计。


七、事务、参数和驱动边界

Cypher 是语句语言,但语句总是在事务中执行。一次事务可以包含多条 Cypher 语句;事务提交时才使写入对其他事务可见。发生约束冲突、语法错误或事务异常时,客户端应回滚并根据错误类型重试或返回失败。

使用参数,而不是拼接字符串:

MATCH (u:User {userId: $userId})
RETURN u.name;

参数示例:

{
  "userId": "u-1001"
}

参数的作用包括:

  • 避免 Cypher 注入;
  • 让查询文本稳定;
  • 使数据库更容易复用计划;
  • 清晰区分查询结构和用户数据。

以 Python 驱动为例,端到端读取可以写成:

from neo4j import GraphDatabase

driver = GraphDatabase.driver(
    "neo4j://localhost:7687",
    auth=("neo4j", "password")
)

def get_projects(tx, user_id):
    result = tx.run(
        """
        MATCH (u:User {userId: $user_id})-[:WORKS_ON]->(p:Project)
        RETURN p.projectId AS project_id, p.name AS name
        ORDER BY p.projectId
        """,
        user_id=user_id,
    )
    return [record.data() for record in result]

try:
    with driver.session(database="neo4j") as session:
        rows = session.execute_read(get_projects, "u-1001")
        print(rows)
finally:
    driver.close()

这里有几个边界:

  • driver 是长期复用的连接管理对象,应用结束时关闭;
  • session 通常按请求或短生命周期创建;
  • execute_read 将读取函数放入驱动管理的读事务中;
  • 事务函数可能因瞬态错误被重试,因此事务函数应尽量幂等,不能在其中无条件执行外部副作用;
  • 写事务应使用 execute_write,并把需要原子完成的节点、关系和校验放在同一事务中。

如果使用显式事务,则必须明确 beginruncommitrollback 的生命周期。网络断开后不能简单假设事务已提交或未提交,应依据驱动提供的错误分类和业务幂等设计恢复。

URI 方案也具有部署语义:

  • bolt://neo4j:// 用于 Bolt 连接;
  • neo4j:// 通常允许驱动根据路由表选择合适的服务器;
  • 单机、集群和路由配置不同,不能只把连接字符串从一个环境复制到另一个环境而不验证 TLS、认证、数据库名和路由能力。

八、建模中的常见错误与失败表现

1. 把关系类型写成动态属性

不应把所有关系都写成:

(:User)-[:RELATION {type: 'WORKS_AT'}]->(:Company)

如果 WORKS_ATFOLLOWSPURCHASED 本身是稳定的业务关系类型,直接使用关系类型更容易表达查询意图:

(:User)-[:WORKS_AT]->(:Company)
(:User)-[:FOLLOWS]->(:User)

关系属性适合表示这条关系的附加事实,如 sinceroleweight

2. 把有意义的关系方向当成可省略细节

“用户关注用户”通常有明确方向:

(follower)-[:FOLLOWS]->(followed)

如果建模时随意反向,查询虽然可以用反向箭头修正,但团队会持续产生语义误读。方向应遵循稳定、可读的业务约定。

3. 用 MERGE 保存会变化的属性

MERGE (u:User {userId: $id, name: $name})

如果用户改名,这个模式可能匹配不到原节点并创建新节点。业务键应放在 MERGE 模式中,变化字段放在 SET 中:

MERGE (u:User {userId: $id})
SET u.name = $name,
    u.updatedAt = datetime();

4. 忽略多匹配导致的组合爆炸

MATCH (u:User {name: $name})
MATCH (c:Company {name: $company_name})
CREATE (u)-[:WORKS_AT]->(c);

如果两个名称各有两条记录,Cypher 可能产生 2×2=42 \times 2 = 4 个匹配组合,进而创建四条关系。解决方式是使用唯一业务键和约束,或在写入前确保匹配基数符合预期。

5. 用高阶可变长度遍历代替明确业务条件

MATCH (a)-[*..]->(b)
RETURN b;

这种模式没有明确上界、关系类型和方向,可能遍历极大范围的数据,甚至导致查询超时或内存压力。应至少限制起点、方向、类型和最大深度:

MATCH (a:User {userId: $id})-[:FOLLOWS*1..3]->(b:User)
RETURN DISTINCT b;

6. 把内部 ID 当业务键

使用内部标识符进行长期接口传递会使数据迁移、导入导出和跨环境同步变得脆弱。业务接口应使用应用生成的稳定 ID,并对其建立唯一性约束。


九、一个完整的查询与更新流程

假设需求是:

找出 Alice 参与的项目及其同项目成员,并把 Alice 在项目中的角色更新为架构师。

可以拆成两个明确的事务步骤。

先读取:

MATCH (alice:User {userId: $user_id})-[:WORKS_ON]->(p:Project)
OPTIONAL MATCH (member:User)-[:WORKS_ON]->(p)
RETURN
  p.projectId AS projectId,
  p.name AS projectName,
  collect(DISTINCT member.userId) AS memberIds;

再更新关系:

MATCH (alice:User {userId: $user_id})
MATCH (p:Project {projectId: $project_id})
MERGE (alice)-[r:WORKS_ON]->(p)
SET r.role = $role,
    r.updatedAt = datetime()
RETURN alice.userId, p.projectId, r.role;

参数:

{
  "user_id": "u-1001",
  "project_id": "p-3001",
  "role": "Architect"
}

每一步成立的前提是:

  1. userIdprojectId 有唯一性约束,因此节点定位不会因重复键产生组合;
  2. MERGE 的节点部分按稳定业务键匹配;
  3. 关系属性由 SET 更新,不会因为角色变化而创建另一条关系;
  4. 如果项目不存在,第二个 MATCH 不会创建项目,语句影响行数为零;
  5. 如果需求是“项目不存在就创建”,应显式将项目改为 MERGE,并确认这符合业务语义。

十、从模型到生产数据的判断边界

图模型的核心不是把所有数据画成点和线,而是明确以下问题:

  1. 这个对象是否有独立身份和生命周期?
  2. 这条连接是否是需要查询的业务事实?
  3. 连接本身是否有属性?
  4. 该实体的业务键是什么?
  5. 哪些属性必须存在、唯一或具有固定类型?
  6. 连接方向是否具有稳定语义?
  7. 查询从哪里开始,最多遍历多少层?
  8. 哪些规则由数据库约束保证,哪些必须由事务或应用保证?

例如,用户的 name 通常是属性,因为它会变化且不适合作为引用标识;用户与公司之间的 WORKS_AT 通常是关系,因为它表达可遍历的业务事实;任职时间和职位通常是关系属性,因为它们描述这段任职事实;如果任职经历要保存离职、合同、审批和薪资历史,则可以把它提升为 Employment 节点。

最终,一个可维护的 Neo4j 模型应同时满足三点:

  • 图结构直接表达重要业务路径;
  • 业务键和约束防止实体重复与关键属性缺失;
  • Cypher 查询能够从高选择性的起点开始,以受控的方向、类型和深度遍历数据。

这三者共同构成 Neo4j 图数据建模的基础,而不是单独依赖节点数量、关系数量或某一条查询语句。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。