跳到主要内容

创建日期:2026-10-15 | 最近更新:2026-10-15 版本基线:Neo4j 5.26.4 community。本系列用本机 Docker 实例实测(docker exec neo4j-lab cypher-shell ...),文中输出均为真实查询结果,含两段故意报错的实测原文,脚本与日志留档在 source/neo4j-lab/。

Neo4j 101 写入与约束:MERGE 其实不是 upsert

一句话:MERGE 常被当成 INSERT ... ON DUPLICATE KEY UPDATE,但它没有那层原子性——没有唯一约束兜底,两个并发请求会各建一个节点。这篇把写入、约束和批量导入一次讲清。

本篇是 Neo4j 101 系列 第 2 篇。上一篇我们把图建起来了,其中反复用到 MERGE——现在把它讲清楚。

1. CREATE 还是 MERGE​

语义跑第二遍会怎样
CREATE无条件新建再来一个同样的节点/关系(重复数据)
MERGE有就复用,没有才建(get-or-create)啥也不变

一句话记住区别:CREATE 是 INSERT,MERGE 是"先 SELECT 再决定 INSERT"。

注意措辞:MERGE 是 get-or-create,不是 upsert。 SQL 的 ON DUPLICATE KEY UPDATE 有数据库层面的原子保证;MERGE 的保证来自约束—— 没有约束时它什么也保证不了。下面两节就是讲这个。

2. 第一坑:MERGE 匹配的是整个模式​

很多人以为 MERGE 只按"主键"匹配,其余属性是"要写入的值"。不是。它按模式里写出来的全部属性匹配:

关键在于属性写多写少,匹配范围就变了。实测一遍(:Demo 这个标签上刻意没建唯一约束):

MATCH (d:Demo) DETACH DELETE d;
MERGE (d:Demo {userId:'u1'}) RETURN d.userId AS 先用窄键建;
MERGE (d:Demo {userId:'u1', city:'北京'}) RETURN d.userId AS 再用宽键匹配;
MATCH (d:Demo) RETURN count(*) AS 同名节点数;

真实输出——两个 userId 都是 u1 的节点:

+-----------------+
| 同名节点数 |
+-----------------+
| 2 |
+-----------------+

+-----------+
| city |
+-----------+
| "(缺失)" |
| "北京" |
+-----------+

发生了什么:第一句按 userId='u1' 建了一个没有 city 属性的节点; 第二句要求同时满足 userId='u1' 和 city='北京',而已有的那个节点 city 是空的 → 匹配不上 → 又建了一个。

顺序反过来的话不会有问题。先 MERGE (d {userId:'u1', city:'北京'})、再 MERGE (d {userId:'u1'}), 第二句的条件更宽,能匹配上已有的节点,不会新建。 坑只在从窄键走向宽键时出现——而这恰好是代码演进中最常发生的事: 今天先上线个简版,明天加个字段。

正确姿势:MERGE 里只写业务键,其余一律交给 ON CREATE SET / ON MATCH SET。

MERGE (u:User {userId:'u1'})
ON CREATE SET u.name = '张伟', u.city = '北京', u.regDate = date('2026-01-05')
ON MATCH SET u.lastSeen = datetime();

这样无论以后加多少属性,匹配范围永远只有 userId 一个键。

3. 第二坑:没有约束,MERGE 就没有原子性​

MERGE 内部的动作是**"先查,查不到再建"。这中间是两步**,中间有窗口。两个并发事务同时进来:

约束在这里的作用不只是"防重复",它同时提供了索引锁,把"查—建"这两步变成互斥的。

有约束和没约束,实测对照:

// :Demo 没有约束 —— 上一节已经看到,会建出 2 个
// :User 有唯一约束 —— 同样的操作被数据库挡下来
MERGE (u:User {userId:'u1'}) RETURN u.name AS 复用而不是新建;
MATCH (u:User {userId:'u1'}) RETURN count(*) AS 加了约束之后;

真实输出:

+--------------+
| 复用而不是新建 |
+--------------+
| "张伟" |
+--------------+

+--------------+
| 加了约束之后 |
+--------------+
| 1 |
+--------------+

所以**「先建约束,再灌数据」不是风格问题,是正确性问题**。 反过来说:如果你发现图里出现了本该唯一的重复节点,先去看那句 MERGE 里的标签上有没有唯一约束。

4. 约束在 Community 版能用哪几种​

约束Community写法
唯一性(UNIQUENESS)✅ 可用REQUIRE u.userId IS UNIQUE
NODE KEY❌ 企业版REQUIRE (u.userId, u.city) IS NODE KEY
属性存在(NOT NULL)❌ 企业版REQUIRE u.name IS NOT NULL

后两种在社区版上会直接报错。下面是真实报错原文(不是转述):

CREATE CONSTRAINT user_key IF NOT EXISTS FOR (u:User) REQUIRE (u.userId, u.city) IS NODE KEY;
Unable to create Constraint( type='NODE KEY', schema=(:User {userId, city}) ):
Node Key constraint requires Neo4j Enterprise Edition. Note that only the first found violation is shown.
CREATE CONSTRAINT user_name_nn IF NOT EXISTS FOR (u:User) REQUIRE u.name IS NOT NULL;
Unable to create Constraint( type='NODE PROPERTY EXISTENCE', schema=(:User {name}) ):
Property existence constraint requires Neo4j Enterprise Edition. Note that only the first found violation is shown.

我们这张图上的 7 条约束(SHOW CONSTRAINTS 实测)全是 UNIQUENESS:

+------------------------------------------------------------+
| name | type | labelsOrTypes | properties |
+------------------------------------------------------------+
| "brand_name" | "UNIQUENESS" | ["Brand"] | ["name"] |
| "category_nm" | "UNIQUENESS" | ["Category"] | ["name"] |
| "order_id" | "UNIQUENESS" | ["Order"] | ["orderId"] |
| "product_sku" | "UNIQUENESS" | ["Product"] | ["skuId"] |
| "promo_id" | "UNIQUENESS" | ["Promotion"] | ["promoId"] |
| "store_id" | "UNIQUENESS" | ["Store"] | ["storeId"] |
| "user_id" | "UNIQUENESS" | ["User"] | ["userId"] |
+------------------------------------------------------------+

还有个容易被忽略的坑:带 NODE KEY / 属性存在约束的库,在社区版上根本打不开。 从企业版迁移到社区版时,这两种约束必须先删掉。

5. 删:DELETE 和 DETACH DELETE​

MATCH (u:User {userId:'u1'}) DELETE u;

节点上还挂着关系时,这条会直接报错(真实原文):

Cannot delete node<0>, because it still has relationships. To delete this node, you must first delete its relationships.

要么先删关系,要么用 DETACH DELETE——它会先删掉这个节点的所有关系,再删节点:

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

DETACH DELETE 是把双刃剑:清库、删测试数据很方便,但在生产上误用一次就可能带走一大片。 习惯是先 RETURN count(...) 确认命中范围,再改写成 DELETE。

属性层面:SET 改值、REMOVE 删属性、SET u += {...} 合并写入(只覆盖给出的键,其余保留)。

6. 幂等重灌:UNWIND + MERGE​

本系列的数据集脚本 01-load.cypher 从头到尾用 MERGE,所以跑多少遍结果都一样。实测——灌完再灌一遍:

+------+
| 节点总数 |
+------+
| 27 |
+------+

+------+
| 关系总数 |
+------+
| 44 |
+------+

还是 27 / 44。这就是幂等:脚本可以随便重跑,不用先清库。

配合 UNWIND 批量写入是标准姿势:

UNWIND [
{orderId:'o1', userId:'u1', paidAt:'2026-04-02T20:15:00', amount:9257},
{orderId:'o2', userId:'u1', paidAt:'2026-04-11T09:30:00', amount:9628}
] AS row
MATCH (u:User {userId: row.userId})
MERGE (o:Order {orderId: row.orderId})
ON CREATE SET o.status = 'paid', o.amount = row.amount, o.paidAt = datetime(row.paidAt)
MERGE (u)-[:PLACED {at: o.paidAt}]->(o);

一行一条对象,UNWIND 摊成多行,后面的语句对每行各跑一次。一次网络往返、一个事务,比循环发 N 条语句快得多。

7. LOAD CSV 与大图的分片提交​

数据在 CSV 里时用 LOAD CSV(文件要放在容器的 import 目录下):

LOAD CSV WITH HEADERS FROM 'file:///orders.csv' AS row
MATCH (u:User {userId: row.user_id})
MERGE (o:Order {orderId: row.order_id})
ON CREATE SET o.amount = toInteger(row.amount)
MERGE (u)-[:PLACED]->(o);

数据量大时,整条语句会变成一个巨大事务。Cypher 5 支持分片提交:

MATCH (p:Product)
CALL (p) { SET p.updatedAt = datetime() } IN TRANSACTIONS OF 1000 ROWS;

每 1000 行提交一次,内存不会被撑爆。apoc.periodic.iterate 是另一种写法(apoc 已随我们的镜像启用)。

CALL (p) { ... } 是 5.23 起的新式写法——在括号里显式声明子查询要用到外层哪些变量。 老写法是 CALL { WITH p ... },两种在 5.26 上都能跑。

8. 事务与隔离​

  • 一条 Cypher 语句 = 一个事务(除非显式写了 IN TRANSACTIONS)。
  • 同一事务里读得到自己刚写的。
  • SHOW TRANSACTIONS 能看到运行中的事务,排查"谁把库锁住了"很有用。
  • 图数据库的事务是写写互斥的:并发写会串行化,靠的是节点/关系的锁——这也是第 3 节里那个"索引锁"的舞台。

9. 写入安全五条​

  1. 任何 MERGE 之前,先确认对应标签上有唯一约束(否则就是第 3 节那个并发事故)。
  2. MERGE 里只写业务键,其余进 ON CREATE SET。
  3. DETACH DELETE 前先 RETURN count()——先看清楚要删多少。
  4. 脚本用 cypher-shell -f file.cypher,别在 shell 里拼字符串(引号地狱 + 注入)。
  5. 导入脚本用 --fail-at-end,让它把这一批的错误一次全报出来,而不是撞到第一个就停。

自测​

  1. CREATE 和 MERGE 的语义差别是什么?为什么说 MERGE 不是 upsert?
  2. MERGE (d {userId:'u1'}) 之后再 MERGE (d {userId:'u1', city:'北京'}) 会发生什么?反过来呢?为什么?
  3. 唯一约束除了"防重复",还在并发场景里起了什么作用?
  4. 社区版能用哪几种约束?哪一种"库都打不开"?
  5. DELETE 和 DETACH DELETE 的区别是什么?生产上为什么更该慎用后者?
  6. 怎么证明一个导入脚本是幂等的?
  7. IN TRANSACTIONS OF 1000 ROWS 解决的是什么问题?

关联​

参考​

  • 实测环境:Neo4j 5.26.4 community(neo4j:5.26.4-community)
  • 脚本与真实输出:source/neo4j-lab/(cypher/01-load.cypher、out/03-queries.log、out/idempotency.log)
  • Neo4j 官方文档:Cypher Manual — MERGE(含并发语义的权威说明)、Constraints