数据库事务有四个属性:ACID。C 是 Consistency,也就是一致性。但是教科书式的定义让人有点不清楚。谁能摆事实、讲道理,解释一下何为事务一致性,以及它与其他属性,尤其是原子性的关系?
“事务把数据库从一个一致状态带到另一个一致状态。”
这句定义少了两个信息:什么样的状态才算一致,以及谁负责让它成立。把这两个空补上,C 和 A 的区别就没那么绕了。
先别管 ACID,写一条业务规则
还是用转账举例,不过先限定场景:没有手续费,没有冻结资金,也没有在途账户。A、B 各有 100 元,那么系统至少要满足:
A.balance + B.balance = 200
转账会改变两个余额,不应该改变总额。这条规则就是一个不变量。
用稍微抽象一点的写法,数据库当前状态是 S,不变量是 P:
P(S) = true
事务 T 执行以后得到新状态 S'。所谓这笔事务保持一致性,就是:
P(S') = true
注意这里有一个前提:S 原本就是合法的。事务系统不会自动修复一份早已损坏的数据。教科书那句“从一致状态到一致状态”,说的就是这个状态转换。
抽象到这里就够了。回到 SQL。
BEGIN;
UPDATE account
SET balance = balance - 100
WHERE id = 'A';
UPDATE account
SET balance = balance + 90
WHERE id = 'B';
COMMIT;
A 从 100 变成 0,B 从 100 变成 190。两条语句一条没少,提交也成功了。结果却是:
0 + 190 != 200
这是一笔原子事务,也是一笔不一致的事务。
原子性防的是“做了一半”
假设第二条 UPDATE 执行时报错。没有原子性,第一条扣款可能已经留下,数据库里只剩 100 元;有原子性,整笔事务回滚,A、B 恢复到 100 和 100。
所以原子性确实在帮助维护一致性。多步骤操作一旦只落下一半,很多不变量马上就会坏掉。
这里还能顺手排除一个误解:一致性通常约束事务边界上的状态,并不要求事务内部每执行一条语句都满足全部业务不变量。哪怕代码写对,先执行 A -= 100、还没执行 B += 100 时,总额也会短暂少 100。数据库约束有立即检查和可延迟检查之分;并发事务能否看到中间结果则由隔离性处理。事务最终提交时,规则必须重新成立。
但刚才的代码不是只做了一半。它把错误完整地做完了。原子性只判断这组操作是否整体生效,不检查“减 100、加 90”在业务上是不是一笔合法转账。
这一点很容易被 BEGIN、COMMIT 带偏。事务边界写得再漂亮,也不能证明边界里面的公式是对的。
再看一个容易漏掉的细节。如果账户表有:
CHECK (balance >= 0)
上面的错误转账仍然能通过。A 是 0,B 是 190,两个值都没有违反 CHECK。数据库知道“余额不能为负”,却不知道“这两个账户的总额不能少 10 元”。
PRIMARY KEY、UNIQUE、FOREIGN KEY、CHECK 都能维护一致性,但只能维护已经声明出来的那部分。订单状态流转、每日转账额度、资金守恒这类业务规则,如果没有进入约束、事务代码或并发控制,数据库没有地方可以知道。
这也是 ACID 的 C 比另外三个字母麻烦的地方。A、I、D 主要描述数据库提供的事务机制;C 最终指向什么状态算合法,其中一部分由数据库约束表达,另一部分往往藏在应用逻辑里。把 C 全算到数据库头上不准确,把它完全推给应用也不准确。
隔离性处理的是另一种破坏方式
单个事务写对了,并发执行仍然可能出事。这里用一个比库存扣减更能说明问题的例子。
某个系统要求至少有一名医生值班:
Alice.on_call = true
Bob.on_call = true
不变量:on_call = true 的人数 >= 1
事务 T1 查到两人都在值班,于是把 Alice 改为休息。单独执行没问题,Bob 还在。
同一时间,事务 T2 也从自己的快照里看到两人都在,于是把 Bob 改为休息。单独看也没问题,Alice 还在。
如果数据库使用允许 write skew 的快照隔离,两笔事务修改的是不同记录,没有直接的写写冲突,可能一起提交。最终 Alice 和 Bob 都不值班。每笔事务都基于自己看到的合法状态做了一个看似合法的决定,组合起来却破坏了不变量。
这时原子性帮不上忙,因为两笔事务都完整执行了。需要 Serializable 隔离检测无法串行化的执行,或者显式锁住参与判断的记录,让第二笔事务重新检查。PostgreSQL 的文档明确指出,Repeatable Read 仍可能出现 serialization anomaly;Serializable 会监控危险的读写依赖,必要时回滚其中一个事务。
这也给出了 A、I、C 之间比较严谨的关系:
如果每个事务单独运行时都能把合法状态变成合法状态,并发执行的结果又等价于某个串行顺序,那么这些事务组合起来仍会保持不变量。原子性避免留下事务前缀,隔离性约束并发交错,业务代码和数据库约束负责定义并实现那次正确的状态转换。
这里不能顺手推出“有了 A、I、D 就自动有 C”。代码写错时,数据库可以原子、隔离、持久地保存一个错误结果。D 只保证提交结果在承诺的故障范围内不会丢,它不区分好结果和坏结果。
“一致性”这个词还撞了好几次名
ACID 的 Consistency 关注事务前后的不变量。它和主从复制是否同步、CAP 里的 Consistency、线性一致性、最终一致性不是同一个定义;也不等同于一次查询能否看到稳定快照。文章里只要出现“一致性”,最好先看上下文在讨论事务规则、并发可见性,还是多个副本。
实际评审事务代码时,可以先找不变量。比如“余额总和不能凭空变化”“至少一人值班”“一张券只能核销一次”。然后再看它靠什么守住:数据库约束、条件更新、锁、隔离级别,还是应用里的一次先查后写。
找不到这条规则落在哪里,只看到一对 BEGIN 和 COMMIT,还不能说这段代码维护了一致性。