创建日期: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. 写入安全五条
- 任何
MERGE之前,先确认对应标签上有唯一约束(否则就是第 3 节那个并发事故)。 MERGE里只写业务键,其余进ON CREATE SET。DETACH DELETE前先RETURN count()——先看清楚要删多少。- 脚本用
cypher-shell -f file.cypher,别在 shell 里拼字符串(引号地狱 + 注入)。 - 导入脚本用
--fail-at-end,让它把这一批的错误一次全报出来,而不是撞到第一个就停。
自测
CREATE和MERGE的语义差别是什么?为什么说MERGE不是 upsert?MERGE (d {userId:'u1'})之后再MERGE (d {userId:'u1', city:'北京'})会发生什么?反过来呢?为什么?- 唯一约束除了"防重复",还在并发场景里起了什么作用?
- 社区版能用哪几种约束?哪一种"库都打不开"?
DELETE和DETACH DELETE的区别是什么?生产上为什么更该慎用后者?- 怎么证明一个导入脚本是幂等的?
IN TRANSACTIONS OF 1000 ROWS解决的是什么问题?
关联
- 上一篇:建模
- 下一篇:查询与路径
- 关系型那头的对照:MySQL CRUD 与约束、MySQL 事务与隔离级别
- 系列入口:Neo4j 101
参考
- 实测环境: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