Jim Gray、事务处理以及为什么 ACID 仍然重要
实践性审视 Jim Gray 的事务处理思想,以及 ACID 原则如何让银行、商业与 SaaS 系统保持可靠。

Jim Gray 是谁以及为什么他的思想延续至今
Jim Gray 是一位计算机科学家,他痴迷于一个看似简单的问题:当大量用户同时使用系统——且故障不可避免时——如何保证结果是正确的?
他对事务处理的工作把数据库从“运气好时才正确”变成可以用于构建业务的基础设施。他推广的思想——尤其是 ACID 属性——无处不在,即便你在产品会议上从未提及“事务”这个词。
什么是“值得信赖的系统”(用通俗的话)
值得信赖的系统是用户可以依赖结果,而不仅仅是界面显示。
- 你的银行余额不会因为两个取款并发而变成负数。
- 一个订单要么完全下单(保留库存并记录付款),要么完全不下——没有神秘的中间状态。
- 订阅升级不会因为后台任务跑两次而随机授予或撤销访问权。
- 审计日志和收据在崩溃后依然能对得上实际发生的事情。
换句话说:正确的余额、正确的订单,不缺失记录。
在现实产品中你会在哪里看到 Gray 的思想
即便是使用队列、微服务和第三方支付的现代产品,在关键时刻仍然依赖事务思维。
- 银行 在资金移动时更看重正确性而非速度。
- 电商 在高并发结账时需要安全的流程:订单、库存、付款、退款。
- SaaS 需要一致的订阅、权限和审计轨迹,避免客户被多计费或被锁定。
本文要做什么(以及不做什么)
我们保持概念实用:ACID 保护什么、错误常藏在哪(隔离与并发)、以及日志和恢复如何让故障可被承受。
我们也会讨论现代权衡——你如何划定 ACID 边界、何时值得使用分布式事务、以及像 Saga、重试和幂等性等模式何时能在不过度工程的前提下提供“足够好”的一致性。
用通俗语言解释事务处理
事务 是把一个多步骤的业务动作当作单个“是/否”单元来处理的方式。如果一切成功就提交;如果发生任何错误就回滚,仿佛什么都没发生。
一个简单例子:转账
想象把 50 美元从活期转到储蓄账户。这不是一步改变,而至少两步:
- 从活期扣除 50 美元
- 向储蓄账户增加 50 美元
如果系统只做“单步更新”,它可能在扣款后崩溃,导致存款没有完成。现在客户少了 50 美元——工单开始涌入。
结账同样是多步的
典型的结账包含创建订单、保留库存、授权付款和记录收据。每一步可能触及不同的表(甚至不同的服务)。没有事务思维,你可能得到“已标为已支付但没有保留库存”的订单,或是为从未创建的订单保留了库存。
现实中故障发生的地方
故障很少在“方便”的时刻发生。常见断点包括:
- 应用在第 1 步后崩溃、第 2 步前停掉。
- 应用与数据库之间发生网络中断。
- 超时发生,用户再次点击“支付”。
- 重试或负载均衡导致重复请求被发送。
目标:要么所有步骤生效,要么全部不生效
事务处理存在的目的是保证一个简单承诺:业务动作的所有步骤要么一同生效,要么全部不生效。这个承诺是信任的基础——无论是转钱、下单,还是更改订阅计划。
ACID 快速回顾:每个字母保护什么
ACID 是一份保障清单,使“事务”感觉可信。它不是营销术语;它是在你修改重要数据时对发生行为的一组承诺。
A — 原子性(要么全部,要么不发生)
原子性意味着事务要么完全完成,要么不留痕迹。
想想一次银行转账:你从账户 A 借记 100 美元,同时向账户 B 贷记 100 美元。如果系统在借记后但在贷记前崩溃,原子性确保整个转账要么回滚(没人“丢”钱),要么整个转账成功。不存在只有一端发生的有效终态。
C — 一致性(规则保持成立)
一致性意味着你的数据规则(约束和不变量)在每次提交后都成立。
例如:如果产品禁止透支,余额不能为负;转账的借方和贷方总和必须匹配;订单总额必须等于明细加税。一致性部分靠数据库约束实现,部分靠应用层的业务规则实现。
I — 隔离性(并发不会破坏结果)
隔离性在多事务同时发生时保护你。
例子:两个顾客尝试购买最后一件商品。没有合适隔离,两次结账都可能“看到”库存 = 1 并都成功,导致库存变为 -1 或需要麻烦的人工修正。
D — 持久性(提交就要存活)
持久性意味着一旦你看到“已提交”,结果在崩溃或断电后仍然存在。如果收据显示转账成功,账本在重启后必须仍然能证明这一点。
一个常见误解
“ACID”并不是一个单一的开关。不同系统和隔离级别提供不同的保证,你通常要为不同的操作选择应用哪些保护。
银行:当钱在流动时正确性胜过速度
谈到“事务”时,银行是最清晰的例子:用户期望余额始终正确。银行应用可以稍微慢一点,但不能出错。一次错误的余额会触发透支费、错过付款,以及长长的后续工作链条。
一次转账,就是一个工作单元
简单的银行转账不是一项单独动作——它包含若干必须一起成功或一起失败的步骤:
- 借记账户 A。
- 贷记账户 B。
- 写入审计记录(谁/何时/为什么/多少)。
ACID 思维把这些视为一个单元。如果任何一步失败——网络抖动、服务崩溃、校验错误——系统不能“部分成功”。否则,你会得到 A 扣了钱但没到 B、B 有钱但没有对应借记,或没有审计轨迹解释发生了什么。
“我们以后再修”为什么代价高
在很多产品里,小的不一致可以在下个版本修补。在银行领域,“以后修复”会变成争议、监管风险和人工对账。工单激增,工程师被拉去处理事故,运营团队花数小时对不匹配的记录进行核对。
即便你能修正数字,依然需要解释历史。
分类账、不可变日志与对账
这就是为什么银行依赖分类账与追加日志:不是覆盖历史,而是记录一系列借方和贷方的变动。不可变日志和清晰的审计轨迹使恢复与调查成为可能。
对账——比较独立的事实源——在出现问题时是最后的保障,帮助团队定位分歧发生的时间和地点。
对用户的影响
正确性换来信任,也降低支持量并加快问题解决:当问题发生时,清晰的审计轨迹和一致的分类账能让你快速回答“发生了什么?”,并在不靠猜测的情况下进行修复。
电商:高并发下的订单、库存与支付
电商看起来简单,直到高峰流量来临:同一件最后商品出现在十个购物车里,顾客刷新页面,支付提供商超时。这时 Jim Gray 的事务处理思维以实用且不花哨的方式出现。
把一次结账拆成步骤
典型结账涉及多块状态:保留库存、创建订单、捕获支付。在高并发下,各步单独正确也可能产生糟糕的整体结果。
如果在没有隔离的情况下减少库存,两个结账都可能读取到“剩 1 件”并都成功——超卖来了。如果你先捕获了付款然后未能创建订单,就会出现扣款但无可履行的情况。
ACID 在数据库边界最有效:把订单创建和库存预留包裹在单个数据库事务中,使其要么一起提交要么一起回滚。你也可以通过约束(例如“库存不能低于零”)来强制正确性,即使应用代码并发出错,数据库也会拒绝不可能的状态。
支付:为什么“恰好一次”很难
网络会丢包,用户会双击,后台任务会重试。这就是为什么跨系统实现“恰好一次”处理困难。目标变为:对资金移动尽量实现至多一次(at most once),其他地方实现安全重试。
对支付提供者使用幂等键,并持久化与订单关联的“支付意图”记录。即使服务重试,也不会重复收费。
退款与争议
退货、部分退款和争议是业务事实,不是边缘情况。清晰的事务边界使这些操作更容易:你可以可靠地把每次调整关联到订单、付款和审计轨迹——当出现问题时,对账可以解释发生的情况。
SaaS:订阅、权限与审计轨迹
SaaS 业务靠承诺生存:客户付费后应该立即并可预测地获得相应功能。这看似简单,直到你把计划升级、降级、计费周期内的按比例计费、退款和异步支付事件混在一起。ACID 风格的思维有助于保持“计费事实”和“产品事实”一致。
订阅变更不要出差错
一次计划变更通常触发一连串动作:创建或调整发票、记录按比例计费、尝试收款、更新权限(功能、席位、配额)。把这些当作一个工作单元来处理,部分成功是不可接受的。
如果生成了升级发票但权限未更新(或反过来),客户要么丢失了付费的访问,要么获得了未付费的访问。
一个实用模式是持久化计费决策(新计划、生效日期、按比例计费条目)和权限决策到同一个记录,然后基于该已提交记录运行下游流程。如果付款确认稍后到达,你可以在不重写历史的情况下安全推进状态。
多租户系统中的正确性
在多租户系统中,隔离不是学术问题:一个客户的高并发活动不能阻塞或破坏另一个客户。使用租户范围的键、为每个租户明确的事务边界,并选择合适的隔离级别,这样租户 A 的续订突发不会导致租户 B 的不一致读取。
能回答支持问题的审计轨迹
支持工单通常以“为什么我被收费?”或“为什么我不能访问 X?”开始。保持一个追加式审计日志,记录谁在何时更改了什么(用户、管理员、自动化),并把它与发票和权限变迁关联。
这防止了无声漂移——发票上写着“Pro”,但权限仍显示“Basic”——并让对账变成一次查询,而不是一次调查。
隔离与并发:大多数 bug 的藏身之处
隔离是 ACID 中的“I”,也是系统常常以微妙且昂贵的方式失败的地方。核心想法很简单:许多用户同时操作,但每个事务应该表现得好像它单独执行一样。
一个日常比喻:两位收银员,一件商品
想象一家店有两位收银员和货架上最后一件商品。如果两位收银员同时检查库存并都看到“1 件可售”,他们可能各自都卖出这件商品。没有崩溃,但结果是错误的——像双重消费一样。
数据库在两个事务并发读取并更新相同行时面临同样的问题。
隔离要防止的常见异常
- 脏读(Dirty reads): 你看到来自尚未提交(且可能回滚)的事务的更改。
- 丢失更新(Lost updates): 两个事务更新同一记录,后者的写入悄悄覆盖前者。
- 类似双重消费的 bug: 两个事务都“预留”了同一稀缺资源(库存、余额、席位)。
隔离级别通俗说明
大多数系统在安全性和吞吐量之间选择隔离级别:
- Read committed(已提交读): 只读已提交的数据。可避免脏读,但某些异常仍可能发生。
- Repeatable read(可重复读): 确保你重读同一行会得到相同结果,减少“移动目标”行为,但并不能防止所有冲突。
- Serializable(可串行化): 最强的隔离——结果等同于事务按顺序逐个运行。最安全,但通常最慢。
根据业务风险而非仅性能做选择
如果错误会造成财务损失、法律风险或客户可见不一致,倾向于更强的隔离(或显式锁/约束)。如果最坏情况只是临时的 UI 问题,较弱的级别或许可以接受。
更高的隔离会降低吞吐,因为数据库需要更多协调——等待、加锁或中止/重试事务——以防止不安全的交错。这是有成本的,但错误数据的成本也是真实的。
日志、持久性与故障后恢复
当系统崩溃时,最重要的问题不是“为什么崩溃?”,而是“重启后应处于什么状态?”。Jim Gray 在事务处理方面的工作让答案可行:通过严谨的日志和恢复来实现持久性。
事务日志:系统的记忆
事务日志(常称为 WAL)是对更改的追加式记录。它在恢复中至关重要,因为即使数据库文件在写入时中断,日志仍保存着更新的意图和顺序。
重启期间,数据库可以:
- 重做(Redo) 已提交但未完全写入数据文件的更改。
- 回滚(Undo) 未完成的事务,防止半完成的更新流入最终状态。
这就是为什么“我们已提交”可以在服务器未能干净关机时仍然成立。
写前日志(WAL)以及它如何实现持久性
写前日志的含义是:在数据页被写入前,相关的日志记录先被刷新到持久存储。在实践中,“提交”通常与确保相关日志记录已安全落盘(或以其他方式持久化)相关联。
如果崩溃发生在提交之后,恢复可以重放日志并重建提交的状态;如果崩溃发生在提交之前,日志可以帮助回滚。
备份 vs 日志:两者都需要
备份是一个快照(某个时间点的副本)。日志是一个历史(自该快照以来的变更)。备份帮助应对灾难性丢失(错误部署、表被删、勒索软件)。日志帮助恢复最近已提交的工作,并支持时间点恢复:先还原备份,然后重放日志到选定时刻。
运维提醒:测试恢复流程
从未恢复过的备份只是希望而不是计划。把定期恢复演练纳入到预生产环境,验证数据完整性检查,并衡量恢复实际所需时间。如果不满足你的 RTO/RPO 需求,在事故发生前调整保留策略、日志传输或备份频率。
分布式系统:ACID 边界与实用替代方案
当一个数据库可以作为事务的“真相来源”时,ACID 最有效。一旦你把一个业务动作分散到多个服务(支付、库存、邮件、分析),你就进入了分布式系统领域——在那里故障不再像干净的“成功”或“失败”。
为什么分布式事务困难
在分布式环境中,你必须假定部分失败:一个服务可能已经提交而另一个崩溃,或网络抖动可能隐藏真实结果。更麻烦的是,超时是模糊的——对方是失败了,还是仅仅慢了?
这种不确定性正是导致双重收费、超卖和权限丢失的根源。
两阶段提交(2PC)通俗说明
两阶段提交尝试让多个数据库表现为“作为一个整体提交”。
- 阶段 1(准备): 每个参与者承诺它可以提交并锁定所需资源。
- 阶段 2(提交/中止): 协调者告诉大家最终是提交还是回滚。
团队通常规避 2PC,因为它可能很慢,持锁时间更长(影响吞吐),协调者会成为瓶颈,并且将系统紧耦合起来:所有参与者都必须支持该协议并保持高可用。
更可扩展的实用替代方案
常见做法是缩小 ACID 边界,并显式管理跨服务的工作:
- Saga: 把大流程拆成步骤,每步都有本地事务。
- 补偿操作: 如果第 4 步失败,执行“反向”步骤(退款、释放库存)。
- Outbox 模式: 在本地事务中同时写数据库变更和要发布的事件,然后可靠地把事件发送出去。
经验法则
尽可能把最强保证(ACID)放在单个数据库内部,把跨边界的部分视为需要重试、对账和清晰“失败怎么办”的协调工作。
重试、幂等性与重复请求
故障很少是干净的“没有发生”。更常见的是请求部分成功,客户端超时,某个端(浏览器、移动端、作业运行器或合作方)发起重试。
如果没有防护,重试会制造最难查的 bug:看起来正确的代码偶尔会重复扣款、重复发货或重复授予权限。
幂等性的实践含义
幂等性 意味着对同一操作执行多次,最终结果与执行一次相同。对面向用户的系统来说,它就是“安全重试且不产生双重效果”。
一个有用的规则:GET 天然是幂等的;许多 POST 操作默认不是,除非你有意设计成幂等的。
防止重复的工具
通常你会结合几种机制:
- 幂等键: 客户端为预期操作发送唯一键(例如
Idempotency-Key: ...)。服务按该键存储结果并在重复时返回相同结果。 - 唯一约束: 在数据库层强制“只有一个”(例如每个
order_id只有一次付款)。 - 去重表: 存储已处理的请求 ID/事件(常带 TTL),常用于 webhook 与消息队列。
当去重检查与实际影响在同一个数据库事务内时,这些机制最有效。
重试 vs 事务与超时
超时并不意味着事务回滚;它可能已经提交但响应丢失。这就是为什么重试逻辑必须假定服务器可能已成功。
常见模式是:先写入幂等记录(或锁住它),执行副作用,然后把它标记为完成——在可能的情况下在一个事务内完成。如果无法把所有工作放进一个事务(例如调用支付网关),就持久化一个“意图”,并在之后对账推进状态。
日常示例
- 双击“提交付款”: 两个相同请求到达。没有幂等性则可能产生两次扣款。
- Webhook 重发: 提供方会重发事件直到收到确认。没有去重,你可能会重复创建发票或重复开通权限。
面向可信数据的设计与测试清单
当系统“看起来不靠谱”时,根源常是缺失或破碎的事务思维。典型症状包括没有对应支付的孤立订单、并发结账后出现负库存、以及账本、发票与分析数据不一致。
设计清单(在写代码前)
先写下你的不变量——那些必须永远成立的事实。例如:“库存永远不能低于零”、“订单要么未付款要么已付款(不能两者同时)”、“每次余额变动都有匹配的分类账记录”。
然后围绕最小的必须原子化单元定义事务边界。如果一个用户动作触及多行/多表,决定哪些必须一起提交,哪些可以安全延后。
最后,选择你在负载下如何处理冲突:
- 加锁 vs 乐观并发(版本列)。
- 唯一约束防止重复(例如每个订单只有一次付款)。
- 在死锁/超时发生时的明确重试策略。
捕捉真实世界失败的测试思路
并发 bug 很少在愉快路径测试中出现。增加能制造压力的测试:
- 并发测试:从多个线程/进程运行相同操作;完成后断言不变量成立。
- 故障注入:在事务中途杀死服务、断开 DB 连接或强制超时;验证恢复后没有半完成状态。
- 重放近似生产流量:使用(脱敏的)请求序列重现边界情况并验证修复。
值得告警的监控指标
你无法保护不被测量的东西。值得关注的信号包括死锁、锁等待时间、回滚率(尤其是部署后激增)、以及源表之间的对账差异(分类账 vs 余额、订单 vs 付款)。这些指标常常在客户报告“缺钱”或“缺货”之前数周发出警告。
在不搞过度工程的前提下应用 ACID 思维
Jim Gray 的持久贡献不仅是一组属性——还是一套描述“哪些情况不能发生”的共同词汇。当团队能明确命名他们需要的保证(原子性、一致性、隔离性、持久性)时,关于正确性的讨论就不再模糊(“应该可靠”),而变成可执行的决策(“这个更新必须与那个扣款原子化”)。
在何处坚持 ACID
当用户合理期待一个单一、确定的结果且错误代价高时,使用完整事务:
- 资金移动: 扣款、退款、余额更新、出款
- 订单提交: 创建订单 + 预留库存 + 记录支付意图
- 访问与权限: 订阅变更、角色授予、席位分配
- 审计要求: 任何你需要日后向客户、财务或安全解释的事项
在这些场景里,为吞吐优化而削弱保证往往只是把成本转嫁给支持工单、人工对账和信任损失。
在何处可以放宽保证
当临时不一致是可接受且容易修复时,可以放宽:
- 只做读模型与分析(延迟数分钟通常可以接受)
- 非关键计数器(浏览、点赞)重复并不致命
- 异步副作用(邮件、webhook),只要它们是幂等的
关键是保持一个清晰的ACID 边界作为“真相来源”,让其他一切都可以滞后。
实用的下一步(轻量且高影响)
- 列出关键流程: 资金、订单、访问以及任何改变与客户合约相关的操作。
- 用白话写不变量(并把它们靠近代码):“订单最多被标记为已支付一次”、“一个席位不能分配给两个用户”、“余额不能为负”。
- 把每个不变量映射到机制: 事务范围、唯一约束、幂等键、追加式审计日志。
- 测试丑陋路径: 重试、超时、双击与部分失败。
如果你在原型这些流程(或重建遗留流水线),选择一个把事务与约束作为第一公民的技术栈会有帮助。例如,Koder.ai 可以从一次简单对话生成 React 前端加上 Go + PostgreSQL 后端,这是一种在投入完整微服务之前,早期建立真实事务边界(包括幂等记录、outbox 表和回滚安全工作流)的实用方式。
如果你希望更多模式与清单,请把这些期望链接到 /blog。如果你按层级提供可靠性承诺,请在 /pricing 上明确说明客户购买的是哪些正确性保证。
常见问题
Jim Gray 是谁?为什么他的思想仍然重要?
Jim Gray 是一位计算机科学家,他把事务处理从理论变成了实用工程并广泛传播开来。他的思想核心是:重要的多步操作(资金转移、结账、订阅变更)在并发与故障条件下也必须产生“正确的结果”。
用产品语言说:更少“神秘状态”、更少对账火警,以及关于“已提交”到底意味着什么的明确定义。
用通俗的话讲,什么是事务?
事务把多个更新分组为一个“要么全部生效,要么全都不发生”的单元。当所有步骤都成功时你就提交;任何一步失败就回滚。
典型场景:
- 银行转账:借记 + 贷记 + 审计记录
- 结账:创建订单 + 预留库存
- 订阅变更:计费决策 + 权限变更
ACID 各个属性实际上保护什么?
ACID 是一组保证,使事务可信:
- 原子性(Atomicity): 要么所有步骤都发生,要么都不发生
- 一致性(Consistency): 提交后规则/不变量仍然成立
- 隔离性(Isolation): 并发活动不会产生错误结果
- 持久性(Durability): 已提交的结果能在故障后保存下来
这不是一个开关——你可以针对不同操作选择不同强度的保证。
为什么隔离性会导致这么多并发 bug?
许多“只在生产发生”的 bug 都源于弱隔离在高并发下的缺陷。
常见异常包括:
- 丢失更新(Lost updates): 两个写者互相覆盖
- 双重消费/超售: 两个结账都预留了最后一件商品
- 脏读(Dirty reads): 看到尚未提交且可能回滚的数据
实践建议:根据业务风险选择隔离级别,并在必要处用约束或显式加锁做兜底。
如何定义不变量并选择合适的事务边界?
先用一句话写下不变量(必须永远成立的事实),然后把必须保护这些不变量的最小事务边界画出来。
常用机制:
- 数据库约束(例如“库存不能低于 0”)
- 唯一约束(例如“每个订单只允许一次付款”)
- 乐观并发(版本列)或显式锁
把约束当作在应用代码并发失误时的安全网。
WAL 和事务日志是什么?为什么重要?
写前日志(WAL)是数据库让“提交”在故障后仍然成立的机制。
运维层面:
- DB 将变更追加到只增日志
- 重启时可以 重做(redo) 已提交但未写入数据页的操作,或 回滚(undo) 未完成的事务
因此设计良好时:如果它提交了,它即使在断电后也应保持提交状态。
如果我有事务日志(WAL),还需要备份吗?
备份是时间点快照;日志是快照以来的变更历史。
实用的恢复策略:
- 定期做备份
- 保留/传送日志以支持时间点恢复
- 在预生产环境定期演练恢复并测量实际 RTO/RPO
没有恢复演练的备份只是希望,而不是计划。
为什么像 2PC 的分布式事务常被避免?
分布式事务试图跨多个系统做“要么一起提交,要么一起回滚”,但部分失败与模糊超时使其变得复杂。
两阶段提交(2PC)通常带来的代价:
- 长时间持有锁(吞吐下降)
- 服务间耦合变紧
- 协调者成为瓶颈并带来可用性问题
只有在确实需要跨系统的原子性且能承担运维复杂度时才考虑 2PC。
有哪些比在服务间强一致更实用的替代方案?
倾向于把强 ACID 保证保持在单个数据库边界内,并用显式协调来处理跨服务工作。
常见模式:
- Saga: 将流程拆成若干带本地事务的步骤
- 补偿动作: 如果后续步骤失败,执行退款/释放等回滚性操作
- Outbox 模式: 在同一本地事务中写入 DB 变更和要发布的事件,然后可靠发布
这些方式在重试与故障下更可预测,同时避免把每个工作流变成全局锁争用。
重试与幂等性如何防止重复扣款和重复订单?
要假定超时可能意味着“服务器已成功但你没收到确认”。因此重试必须是安全的。
防止重复的工具包括:
- 幂等键(Idempotency keys): 客户端为每次意图操作提供唯一键,服务按键存储结果并在重复请求时返回相同结果
- 唯一约束: 在 DB 级别强制“至多一次”效果(例如每个 order_id 只有一次付款)
- 去重表: 存储已处理的请求 ID/事件(通常带 TTL),常用于 webhook 与消息队列
最佳实践是在同一个数据库事务内完成去重检查与状态变更,或先持久化“意图”并在之后对账推进状态。