第 10 篇假定「计划健康仍慢」时要换诊断轴。本篇换到并发:默认隔离允许多少异常、写锁落在节点还是关系、sparse/dense 建边为何锁集合不同,以及死锁为何是可重试的瞬时错误。高可用复制留给第 12 篇。
本文是「图数据库内核」系列第 11 篇(共 16 篇)。→ 系列目录
篇目 核心内容 第 6 篇 · 写入路径 挂链、升 dense、逻辑删除 第 11 篇 · 事务与锁 隔离异常、锁表、死锁 第 12 篇 · HA 边界 集群与只读副本
版本锚定:Operations Manual(2026.07 / current)Database internals(ACID 总述)与 Concurrent data access(隔离、异常、锁、dense、死锁、删除语义)。死锁错误码含 5.25+ 的 GQLSTATUS
50N05注记。不粘贴本机锁转储。
一、ACID 总述与默认隔离
手册 Database internals:Neo4j 事务具备 ACID,持久化靠 write-ahead transaction log。行为要点:
- 访问图、索引或 schema 的操作都在事务中;
- 默认隔离:read-committed;
- 写锁在节点/关系级自动获取;可用显式写锁模拟更强隔离(serializable 效果);
- 遍历取到的数据不受保护,可被其它事务修改;
- 可能发生 non-repeatable reads(只拿写锁并持有至事务结束);
- 内置死锁检测。
相对站内 MVCC / PG / InnoDB 叙事:Neo4j 默认路径不是「读也持长期快照锁」的 Serializable 故事,而是写互斥 + 读已提交。图工作负载大量是遍历——手册把「遍历结果可被并发改掉」写成一等事实,而不是脚注。
1.1 两档隔离心智
| 级别 | 手册含义 | 典型用法 |
|---|---|---|
| read-committed(默认) | 读到某节点/关系后,不阻塞他人在本事务结束前改写它 | 绝大多数查询 |
| 显式写锁 → serializable 效果 | 对公共节点/关系拿写锁,相关事务在该锁上串行 | 需要确定性读改写(如计数器)时 |
Cypher 没有完整的
SET TRANSACTION ISOLATION LEVEL 菜单式
API;升高隔离常靠锁技巧(见第三节 lost
updates)。
二、read-committed 下的图特有异常
手册 Anomalies 列出的异常仅在 read-committed 下讨论(摘要):
2.1 Lost updates
并发 SET n.prop = …
时,若读与写之间无写锁保护,最终值可不决定。
Cypher 有时自动加写锁:当
SET
右侧直接依赖正在读的同一属性(如
SET n.prop = n.prop + 1,或 map literal 中对
n.prop
的依赖)时,会在读前拿节点/关系写锁。
若先 WITH n.prop AS p 再基于 p
计算后
SET,或形成属性间循环依赖,则不会自动加锁——百客户端自增可能远到不了
100。workaround(手册):先对 dummy 属性
SET/REMOVE
逼出写锁,再读真属性。
2.2 Non-repeatable reads
同一事务内两次读同一属性,中间可被并发提交改掉,两次结果不一致。缓解:只读一次并在查询内保留变量。
2.3 Missing / double reads(索引扫描)
对索引做 scan(如
NodeIndexScan、范围类
RelationshipIndexSeek)时,并发改属性可能使实体在扫描游标「前方/后方」移动
→ 重复出现或完全跳过。手册强调:即使索引
backing 唯一性约束也可能发生。Seek 单点与
scan 路径不同——调优时区分算子(第 10 篇)。
2.4 与 Expand 的合读
第 09 篇的多跳 Expand 在默认隔离下,路径上后半段看到的图可以与前半段开始时不同。需要「整条路径冻结」时,不能指望默认隔离;要收窄写并发、显式锁,或接受应用层重试/校验。
三、锁落在哪:默认表与 dense 分叉
3.1 默认行为(手册条目)
锁加入事务,提交或回滚时释放(回滚立即释放)。
| 操作 | 锁(简化叙述) |
|---|---|
| 改节点/关系属性 | 该实体写锁 |
| 创建/删除节点 | 该节点写锁 |
| 创建/删除关系 | 该关系 + 两端节点(见下表细分) |
更细的 Table 2:
| 修改 | 获得的锁 |
|---|---|
| 创建节点 | 无锁 |
| 更新标签 / 节点属性 / 删除节点 | NODE |
| 创建关系 | sparse:NODE;dense:NODE DELETE prevention(防删点) |
| 更新关系属性 | RELATIONSHIP |
| 删除关系 | 节点侧同创建关系规则 + RELATIONSHIP(sparse/dense 皆然) |
索引与内部结构还会加额外锁——手册声明不作保证哪些一定出现。
观测:管理员可 SHOW TRANSACTIONS 取
currentQueryId,再
CALL dbms.listActiveLocks(queryId)。
3.2 Dense:为何超节点写入更能并
接第 06 篇:
- record
系(standard/aligned/high_limit):节点曾达
db.relationship_grouping_threshold(默认 50)即 dense,且不因边变少而逆转。 - block:按关系类型近似「约 50 条该类型边(受属性体积影响)」可对该类型视为 dense;同一节点可对类型 A dense、对 B sparse。
建/删关系时:
- 事务执行期:不对 dense 节点拿排他 NODE 锁;用共享锁防删点,并用度数相关共享锁与标签变更同步(保证 count store)。
- 提交期:把关系插入允许并发修改的后备结构;偶发按序加排他锁保一致。
- Sparse 的主要争用常是度数/链头更新;dense 把度数放进并发友好结构,关系修改几乎总能避开排他节点锁。
一句话:升 dense 不只是读路径改 group/树,也是写路径的锁粒度迁移。 热点门户点上的边写入,在越过阈值后争用形态会变——不等于「永远不堵」,但不再与 sparse 链头更新同构。
3.3 内部锁类型(仅作心智,版本可改)
手册列出
LABEL/RELATIONSHIP_TYPE、SCHEMA_NAME、NODE_RELATIONSHIP_GROUP_DELETE、NODE、DEGREES、RELATIONSHIP_DELETE、RELATIONSHIP_GROUP、RELATIONSHIP
等,并警告版本间可无通知变更。读排障日志时认名字即可;不要把内部类型写进跨版本
SLA。
应用侧排序建议:对属性更新等引擎不代为排序的操作,固定锁顺序(如总是先 A 后 B);引擎内部排序主要协助建/删关系类锁。
四、死锁:瞬时错误与 MERGE 陷阱
4.1 检测与语义
两事务互相等待对方持有的节点/关系锁 → 死锁。引擎终止其一:
Neo.TransientError.Transaction.DeadlockDetected- 5.25+ 另含 GQLSTATUS
50N05
被终止事务持有的锁在事务结束时释放,等待方继续;应用应有限次重试(可加退避),并覆盖其它瞬时错误。频繁死锁说明写模式在隔离目标下不可规模化并发——要改顺序或分区写,而不是无限重试。
4.2 避免
- 多实体更新固定顺序(先 A 后 B);
- 避免无必要的交叉热点写;
- 注意:
MERGE为保唯一可能乱序取锁,削弱引擎内部排序;可能时用CREATE(手册鼓励)。唯一性需求用约束 + 明确创建路径,而不是到处 MERGE「图方便」。
4.3 锁等待超时
db.lock.acquisition.timeout(默认
0 = 关闭):正时长(如
10s)内拿不到锁则失败事务。动态设置不可用(手册)。用于防止事务无限卡住,与死锁检测互补。
五、删除语义(提交约束)
手册 Delete semantics:
- 删除节点/关系时其属性自动清除;
- 删除节点不会自动删光关系;提交时仍挂关系
→ 失败(需先删边,或
DETACH DELETE); - 事务内可短暂持有「已删未提交」实体的引用;其后写操作抛错;
- 提交后再用旧引用 → 抛错。
与第 06 篇逻辑删除/.id
复用衔接:并发删除与建边抢同一热点时,锁表(RELATIONSHIP_DELETE、NODE_RELATIONSHIP_GROUP_DELETE
等)比「文件是否缩小」更先成为症状。
六、和计划、缓存的分工诊断
| 症状 | 先查 |
|---|---|
| PROFILE Rows 爆炸、Estimated 离谱 | 第 10 篇 |
| DB Hits / page miss 高、Rows 正常 | 第 05 篇 |
查询挂起、listActiveLocks 显示等待 |
本篇 |
DeadlockDetected / 50N05 |
本篇(重试 + 改写序) |
| 自增丢更新、路径两次读不一致 | 本篇隔离 |
慢查询三轴:计划基数 × 存储 I/O ×
锁等待——不要用单一 pagecache.size
或单一 IndexSeek 解释一切。
七、争论与开放问题
- 默认 RC vs 更强隔离:A 侧要遍历吞吐;B 侧要多跳读稳定。图产品多数押 A,把强一致留给显式锁与窄写集。
- dense 锁优化 vs 建模:引擎让超节点边写入更能并,不免除「门户点」成为业务热点;建模拆点与锁优化应并行,而不是互相替代。
- 开放问题:block 类型级 dense 与内部锁表的可观测性;多数据库/Fabric 下的锁语义(第 12 篇边界);与因果集群提交可见性的交叠。
谱系:隔离异常词汇接 Berenson et al.(SIGMOD 1995,站内 MVCC 文常用);图引擎的增量是边两端锁 + 邻接结构锁,不是行锁词典的简单翻译。
八、来源与实验台账(本篇)
| 结论 | 等级 | 来源 |
|---|---|---|
| ACID、WAL、默认 RC、遍历不受护、非重复读、死锁检测总述 | A | Operations Manual Database internals |
| Lost update / 索引 scan 异常 / 锁表 / dense 锁 / 死锁码 / MERGE 警告 / 删除语义 / 锁超时 | A | Concurrent data access(current) |
本机死锁复现、listActiveLocks |
未跑 | 不贴伪造输出 |
九、小结
- 默认
read-committed:写互斥,读与遍历不冻结图。
要确定性读改写,靠直接依赖的
SET或 dummy 写锁。 - 建边锁在 sparse/dense 上分叉;dense 是读布局与写并发的共同转折点(接第 06 篇)。
- 死锁是设计内的瞬时错误——有限重试,并固定更新顺序;慎用乱序取锁的
MERGE。 - 下一篇:因果集群 / 只读副本如何改变「提交可见」与本篇单机锁故事的边界。
→ 系列目录 · 上一篇:Cypher 计划 · 下一篇:HA 边界
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【图数据库内核】邻接的代价模型:边表 JOIN、CSR 与原生指针为何不是同一件事
把同一逻辑图落成边表+索引、CSR、原生关系链与 Neo4j block 内联四条路径,用统一代价语言比较一次 hop 与 k 跳扩张;钉住幂律超节点与局部性,为后续 record/block 布局篇垫底座。
【图数据库内核】Neo4j record 系布局:节点、关系链、属性链与 dense group
以 Neo4j 5.26 record-storage-engine 源码钉住 standard/aligned 固定记录:15B 节点、34B 关系、41B 属性、25B relationship group;讲清 sparse 链、dense 阈值与一次 hop 的指针路径。
【图数据库内核】写入路径:建边、升 dense、逻辑删除与 ID 复用
拆解 Neo4j 写路径:RelationshipCreator 的 sparse 挂链与 dense 转换、逻辑删除与 .id 复用、block 主块/动态重定位,以及删除如何反过来打碎读路径局部性;锁与隔离细节留给第 11 篇。
【图数据库内核】图引擎全景:属性图作为一等公民的存储与遍历
定位属性图相对行存/LSM/向量/GraphRAG 的生态位;钉住邻接代价、Neo4j store format(record→block)、page cache、索引与 Expand、Cypher 计划五条坐标系,并以 Angles & Gutierrez 谱系与原生图争论收束系列路线。