3.2 事务与 ACID:写入的可靠性契约 本节摘要:Neo4j 单机提供完整 ACID:原子性保证一次事务要么全落要么全不落,一致性由约束与锁维护,隔离性默认读已提交、可升级,持久性依赖预写日志。本节讲清每一条在图场景下的具体含义,示范显式事务与死锁重试的正确姿势,并解释为什么长事务是图数据库的头号敌人。 上一节解决了"写对",本节解决"写稳"。转账式的业务诉求——扣一端、加另一端,中间不能有半态——在图上同样存在:转账关系、库存扣减、状态迁移,都需要事务兜底。 一、ACID 在图场景下的四张面孔 原子性(Atomicity):一个事务里的所有写入,要么全部生效要么全部回滚。图场景最典型的需求是"建边必须连同两端节点一起成活": 一致性(Consistency):约束在事务提交时强制校验。
本节摘要:Neo4j 单机提供完整 ACID:原子性保证一次事务要么全落要么全不落,一致性由约束与锁维护,隔离性默认读已提交、可升级,持久性依赖预写日志。本节讲清每一条在图场景下的具体含义,示范显式事务与死锁重试的正确姿势,并解释为什么长事务是图数据库的头号敌人。
上一节解决了"写对",本节解决"写稳"。转账式的业务诉求——扣一端、加另一端,中间不能有半态——在图上同样存在:转账关系、库存扣减、状态迁移,都需要事务兜底。
原子性(Atomicity):一个事务里的所有写入,要么全部生效要么全部回滚。图场景最典型的需求是"建边必须连同两端节点一起成活":
// 一个事务内:节点与边同生共死 BEGIN CREATE (o:Order {orderNo: 'SO-1001'}) CREATE (o)-[:CONTAINS {qty: 2}]->(:Product {sku: 'KB-87'}) COMMIT -- COMMIT 前任何一步失败,两个节点与一条边全部消失
一致性(Consistency):约束在事务提交时强制校验。唯一性冲突会让整个事务回滚,而不是悄悄插进去。
隔离性(Isolation):默认读已提交(read committed)。同一事务里两次读之间,别人可能已经提交了新数据——对大多数分析场景无感;需要更强保证时可以用显式锁:
// 悲观锁:把节点锁住直到事务结束,用于"先读后写"的竞态场景 MATCH (p:Product {sku: 'KB-87'}) SET p._lock = true // 写动作 = 上锁 RETURN p.stock // 此刻读到的库存不会再被并发改掉
持久性(Durability):提交成功的数据先写预写日志(transaction log)再落页缓存,进程崩溃可以从日志恢复。备份策略在 3.4 节与之衔接。
自动提交事务(一条查询一个事务)适合单语句;多条语句要共进退时,用显式边界。Browser 与 cypher-shell 里用 :begin / :commit:
:begin MATCH (p:Product {sku: 'KB-87'}) SET p.stock = p.stock - 2; MATCH (p:Product {sku: 'KB-87'}) SET p.sold = p.sold + 2; :commit
⚠️ 事务要短。图库的写锁以关系与节点记录为粒度,长事务会让锁长期悬空,把后续写入全堵在后面。大批量写入宁可拆成几千行一小批,也不要一条事务灌十万个节点——3.3 节的导入参数同样贯彻这条纪律。
两个事务以不同顺序争抢同样的资源时会发生死锁。Neo4j 检测到死锁后主动牺牲其中一个,抛出 TransientError——它的正确打开方式是重试,不是排查"谁写错了":
# 驱动侧标准重试:execute_query 内建指数退避重试 from neo4j import GraphDatabase def transfer_stock(tx, sku, qty): tx.run(""" MATCH (p:Product {sku: $sku}) SET p.stock = p.stock - $qty """, sku=sku, qty=qty) driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "secret")) # execute_query 自动重试可重试错误(含死锁) driver.execute_query(transfer_stock, sku="KB-87", qty=2, database_="neo4j")
场景回放: 事务A 先改 P1 再改 P2,事务B 先改 P2 再改 P1 → 检测到环 → 牺牲其中一个(报 TransientError) → 驱动自动重试 → 第二次成功 应用层无感,数据最终一致
单机是全量 ACID; causal 集群里,写主复制到从库是异步的——从库读到的是"最终一致"的数据。工程口径:
| 部署形态 | 一致性承诺 | 应用侧注意 |
|---|---|---|
| 单机 | 完整 ACID | 无需特殊处理 |
| 集群写主 | ACID(写) | 写必须路由到主库 |
| 集群读从 | 最终一致 | 强一致读要回主库或等书签(bookmark) |
"先写入、立刻读回校验"的逻辑在读从库部署上会踩坑——驱动书签(bookmark)机制就是为这个准备的:把写事务结束时的书签传给下一次读会话,驱动会确保该次读发生在已包含这次写的服务器上。第 4 章驱动一节会给出代码。

问:一条 Cypher 是一个事务吗?
是。自动提交事务逐条生效;多条语句共进退才需要显式边界。反过来讲,把"清空全部标签 + 重建"写成十条独立语句就是十次中途失败的机会——共进退的场景必须显式开事务。
问:读操作需要事务吗?
驱动里读也走事务(execute_read),但语义上读已提交已足够,无需额外操心。真正要设计的是 4.1 的书签:写后读的因果链。
问:锁在哪里能看到?SHOW TRANSACTIONS 列出当前事务与等待状态;死锁现场会在日志里留下双方持锁记录,用于复盘"谁先谁后"的顺序问题。
事务里的查询与写入要平衡。 一个事务里先做大规模 MATCH 再写入,MATCH 部分持有的锁会随事务时长累积——"先在事务外把目标算好,事务内只做纯写"是批量更新的黄金结构。
错误要分清"可重试"与"不可重试"。 死锁与瞬时连接错误可以重试;约束冲突、语法错误重试一百遍也不会好。驱动按错误类型自动分流,应用侧只需要捕获最终失败——别把两类错误混在同一个 catch 里盲目重试。
TransientError,标准姿势是驱动层重试;写入的正确性与稳定性齐了。数据量一大,就需要本册第三章的下一站:批量导入。