3.2 事务与 ACID:写入的可靠性契约


文档摘要

3.2 事务与 ACID:写入的可靠性契约 本节摘要:Neo4j 单机提供完整 ACID:原子性保证一次事务要么全落要么全不落,一致性由约束与锁维护,隔离性默认读已提交、可升级,持久性依赖预写日志。本节讲清每一条在图场景下的具体含义,示范显式事务与死锁重试的正确姿势,并解释为什么长事务是图数据库的头号敌人。 上一节解决了"写对",本节解决"写稳"。转账式的业务诉求——扣一端、加另一端,中间不能有半态——在图上同样存在:转账关系、库存扣减、状态迁移,都需要事务兜底。 一、ACID 在图场景下的四张面孔 原子性(Atomicity):一个事务里的所有写入,要么全部生效要么全部回滚。图场景最典型的需求是"建边必须连同两端节点一起成活": 一致性(Consistency):约束在事务提交时强制校验。

3.2 事务与 ACID:写入的可靠性契约

本节摘要:Neo4j 单机提供完整 ACID:原子性保证一次事务要么全落要么全不落,一致性由约束与锁维护,隔离性默认读已提交、可升级,持久性依赖预写日志。本节讲清每一条在图场景下的具体含义,示范显式事务与死锁重试的正确姿势,并解释为什么长事务是图数据库的头号敌人。

上一节解决了"写对",本节解决"写稳"。转账式的业务诉求——扣一端、加另一端,中间不能有半态——在图上同样存在:转账关系、库存扣减、状态迁移,都需要事务兜底。

一、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 的边界在哪

单机是全量 ACID; causal 集群里,写主复制到从库是异步的——从库读到的是"最终一致"的数据。工程口径:

部署形态 一致性承诺 应用侧注意
单机 完整 ACID 无需特殊处理
集群写主 ACID(写) 写必须路由到主库
集群读从 最终一致 强一致读要回主库或等书签(bookmark)

"先写入、立刻读回校验"的逻辑在读从库部署上会踩坑——驱动书签(bookmark)机制就是为这个准备的:把写事务结束时的书签传给下一次读会话,驱动会确保该次读发生在已包含这次写的服务器上。第 4 章驱动一节会给出代码。

图:一个事务的提交时间线

图:一个事务的提交时间线

五、FAQ:事务相关的三个高频疑问

问:一条 Cypher 是一个事务吗?
是。自动提交事务逐条生效;多条语句共进退才需要显式边界。反过来讲,把"清空全部标签 + 重建"写成十条独立语句就是十次中途失败的机会——共进退的场景必须显式开事务。

问:读操作需要事务吗?
驱动里读也走事务(execute_read),但语义上读已提交已足够,无需额外操心。真正要设计的是 4.1 的书签:写后读的因果链。

问:锁在哪里能看到?
SHOW TRANSACTIONS 列出当前事务与等待状态;死锁现场会在日志里留下双方持锁记录,用于复盘"谁先谁后"的顺序问题。

再补充:两个容易被忽视的细节

事务里的查询与写入要平衡。 一个事务里先做大规模 MATCH 再写入,MATCH 部分持有的锁会随事务时长累积——"先在事务外把目标算好,事务内只做纯写"是批量更新的黄金结构。

错误要分清"可重试"与"不可重试"。 死锁与瞬时连接错误可以重试;约束冲突、语法错误重试一百遍也不会好。驱动按错误类型自动分流,应用侧只需要捕获最终失败——别把两类错误混在同一个 catch 里盲目重试。

本节要点回顾

  • 单机完整 ACID;集群写主读从,读从是最终一致
  • 原子性由事务边界决定,多条语句共进退要显式开事务;
  • 默认隔离级别读已提交,竞态场景用显式锁升级;
  • 死锁报 TransientError,标准姿势是驱动层重试
  • 事务务必短小:批量写入分批提交,别让锁悬空。

写入的正确性与稳定性齐了。数据量一大,就需要本册第三章的下一站:批量导入。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U