- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 事务究竟承诺了什么
- ACID — 四个字母代表什么
- 为什么隔离很难 — 并发才是根本问题
- 四种异常现象 — 可能出什么问题
- 四种标准隔离级别
- MVCC — 不加锁也能读的魔法
- 锁 vs 乐观并发控制
- SELECT FOR UPDATE — 显式行锁
- PostgreSQL vs MySQL — 默认值不同
- 实务指南汇总
- 结语
- 参考资料
引言 — 事务究竟承诺了什么
当数据库里有钱在流动,一个可怕的问题就会浮现出来。"转账过程中服务器挂了怎么办?" 在从账户 A 扣掉 100 元、给账户 B 加上 100 元这两条语句之间,如果进程崩溃了,钱可能凭空消失,也可能变成两份。事务正是为了消除这种恐惧而存在的机制。
事务的承诺很简单。"把这一串语句当作一个整体来对待。要么全部生效,要么一个都不生效。" 这个整体一旦提交(commit)就被确定下来,一旦回滚(rollback)就整体作废,如同从未发生过。然而,要在多个事务同时运行的环境下守住这个简单的承诺,会冒出许多出人意料的微妙问题。本文正面处理这些微妙之处。
如果你想用真实的 SQL 亲自实验这些概念,可以在本站的 SQL 游乐场、PostgreSQL 游乐场、DuckDB 游乐场里运行查询。
ACID — 四个字母代表什么
事务的保证常被概括为 ACID 这个缩写。让我们逐个字母精确地看一遍。
- Atomicity(原子性):事务是不可分割的单个单位。要么全部成功,要么全部失败。转账操作里"扣钱"生效而"加钱"没有生效的情况不会发生。
- Consistency(一致性):事务把数据库从一个有效状态转移到另一个有效状态。约束(外键、唯一性、检查约束)在事务边界处得到遵守。这里的一致性指的是应用程序自己定义的不变式,与后文提到的分布式系统中的"一致性(consistency)"是不同的概念。
- Isolation(隔离性):让并发运行的事务彼此看不到对方的中间状态。本文有一半篇幅都在讲隔离性。因为完美的隔离代价高昂,实务中要在多个隔离级别之间做选择。
- Durability(持久性):已提交的事务,即便系统之后崩溃也能存活下来。通常靠先写入 WAL(Write-Ahead Log,预写日志)来保证这一点。
这四者之中,原子性和持久性大体上是直观的。真正的难点在于隔离性,因为"要让你看到另一个事务的中间状态到什么程度"这个问题没有唯一正确答案,只有性能与正确性之间的一段光谱。
为什么隔离很难 — 并发才是根本问题
如果事务一个接一个顺序执行,隔离根本不是问题。难点出现在多个事务在时间上发生重叠的时候。理论上最安全的状态是"可串行化(serializable)"——也就是说,即便事务实际上是重叠运行的,其结果也和按某种顺序一个个执行完全一致。
问题在于,强制这种完美的可串行性代价很大。事务之间互相等待,并发度下降,吞吐量降低。于是数据库提供了一种折中方案。"容忍到这种程度的异常现象,以换取更快的运行速度"——这些梯度正是隔离级别(isolation level)。
理解每个隔离级别的关键在于:"这个级别阻止了哪些异常现象,又允许了哪些?" 所以我们先精确定义这些异常现象。
四种异常现象 — 可能出什么问题
隔离较弱时会出现的典型读取异常现象如下。让我们逐一用具体场景来看。
1. 脏读(dirty read) — 读到未提交的值。 事务 B 读到了事务 A 尚未提交的值。如果 A 之后回滚,那么 B 读到的就是一个从未真正存在过的幽灵值。
A: UPDATE balance = 200 (尚未提交)
B: SELECT balance -> 200 (脏读!)
A: ROLLBACK (200 变成了一个从未存在过的值)
B: 基于从未存在过的 200 做出了判断
2. 不可重复读(non-repeatable read) — 同一行读两次,值却不同。 事务 B 两次读取同一行,而在两次读取之间,另一个事务 A 修改并提交了这一行,导致两次读取的值不同。
B: SELECT balance -> 100
A: UPDATE balance = 200; COMMIT
B: SELECT balance -> 200 (同一行,值却变了)
3. 幻读(phantom read) — 同一条件查询两次,行的集合却不同。 B 用某个条件(例如"余额 > 50")查询两次,而在两次查询之间,A 插入(或删除)了一行符合该条件的记录,导致结果集里的行数发生变化。如果说不可重复读是"已有行的值发生变化",那么幻读就是"行本身的出现或消失"。
B: SELECT count(*) WHERE balance > 50 -> 3 行
A: INSERT (balance = 500); COMMIT
B: SELECT count(*) WHERE balance > 50 -> 4 行 (幽灵行出现了)
4. 写偏斜(write skew) — 各自看到的值都没错,合在一起却违反了规则。 这一种更微妙。两个事务各自读取互不重叠的行并分别更新,单独看每个事务都遵守了规则,但合并后的结果却打破了不变式。经典的例子是"值班医生"规则。规则要求值班医生始终不少于一人,但如果两名医生同时判断"另一个人在值班,所以我可以下班",各自都下班了,结果就是值班人数变成 0。
规则: 值班医生始终 >= 1 人
当前: 爱丽丝(值班)、鲍勃(值班) — 2 人
A(爱丽丝): SELECT count(值班) -> 2,"鲍勃在,我可以下班了" -> 爱丽丝下班
B(鲍勃): SELECT count(值班) -> 2,"爱丽丝在,我可以下班了" -> 鲍勃下班
两者都提交 -> 值班人数为 0。违反规则!
写偏斜之所以特别危险,是因为单独看每个事务都显得完全正当。这种现象即便在快照隔离(后文说明)下也可能发生,这正是"可串行化"有时必不可少的原因所在。
四种标准隔离级别
SQL 标准以上述异常现象为基准,定义了四种隔离级别。级别越低,允许的异常现象越多,运行也越快;级别越高,越安全,代价也越大。
- READ UNCOMMITTED:最弱的级别。连脏读都允许,未提交的值也可能被读到。实务中几乎不用。
- READ COMMITTED:阻止脏读,只能读到已提交的值。但不可重复读和幻读仍被允许。这是许多数据库事实上的默认级别。
- REPEATABLE READ:进一步阻止不可重复读。事务开始时看到的行,在整个事务期间都保持同一个值。按标准,幻读是被允许的,不过部分实现连幻读也一并阻止。
- SERIALIZABLE:最强的级别。阻止所有异常现象,保证结果与按某种顺序逐一执行事务完全一致。
把标准定义的"级别对异常现象"关系整理成表格如下。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 允许 | 允许 | 允许 |
| READ COMMITTED | 阻止 | 允许 | 允许 |
| REPEATABLE READ | 阻止 | 阻止 | 允许(按标准) |
| SERIALIZABLE | 阻止 | 阻止 | 阻止 |
有一点需要特别留意:这张表只是 SQL"标准"的定义,实际数据库的行为往往比表格更强、或有微妙的差异。比如后文会看到,PostgreSQL 的 REPEATABLE READ 实际上连标准所允许的幻读也一并阻止了。所以"这个级别在我的数据库上到底保证了什么",永远都应该查阅该数据库自己的文档来确认。
MVCC — 不加锁也能读的魔法
实现隔离性的方式大体上分两大类。一类是锁(locking),另一类是多版本并发控制(MVCC, Multi-Version Concurrency Control)。如今主流的数据库——PostgreSQL、MySQL(InnoDB)、Oracle——全都使用 MVCC。
MVCC 的核心思路是这样的:不覆写行,而是创建新版本。 某一行被更新时,旧版本原样保留,新版本被追加进去。每个版本都带有"由哪个事务创建"的信息。事务读取数据时,只会依据自己所处的时间点,挑出"应当可见的版本"来看。
行 x 的各个版本 (按时间顺序):
v1 (由事务 10 创建)
v2 (由事务 25 创建) <- 最新
事务 20 读取 x 时:
-> 20 看不到 25 的结果 (那是尚未发生的未来)
-> 读到 v1 (与自己快照相符的版本)
这种方式的决定性优势在于读不会阻塞写,写也不会阻塞读。 读的一方只需看与自己时间点相符的旧版本,写的一方只需创建新版本即可。彼此都不需要等待,并发度因此大幅提升。这种事务能看到一份自身启动时刻起就一致的快照的隔离方式,被称为快照隔离(snapshot isolation)。
这也有代价。旧版本会不断累积,需要定期清理。在 PostgreSQL 中,这项清理工作由 VACUUM 负责,一旦清理跟不上,死元组就会堆积,性能随之恶化。而且前面提到的写偏斜,单靠纯粹的快照隔离是无法阻止的。因为每份快照各自内部都是一致的,但两份快照的决策合并时产生的冲突,是它们各自都看不到的。
锁 vs 乐观并发控制
应对并发更新冲突的策略大体分为悲观(pessimistic)与乐观(optimistic)两种。
悲观锁(pessimistic locking)。 假定"冲突一定会发生",在触碰数据之前先加锁。其他事务要等到锁释放为止。在冲突频繁的场景下比较安全,但存在等待与死锁(deadlock)的风险。后文会讲到的 SELECT ... FOR UPDATE 就是这种方式的代表性工具。
乐观并发控制(optimistic concurrency control)。 假定"冲突很少见",先不加锁就往下推进。取而代之的是,在提交前一刻检查"这份数据自我读取之后是否被改动过",如果改动过就让事务失败,交由上层重试。常见的实现方式是版本列。给行加一个版本号,更新时加上"只有当我读到的版本与当前版本一致时才更新"的条件。
-- 乐观锁: 只有版本没变时才更新
UPDATE accounts
SET balance = 200, version = version + 1
WHERE id = 42 AND version = 7;
-- 若受影响的行数为 0 -> 说明期间被别人改动过 -> 重试
选择的依据是冲突频率。冲突频繁时,悲观锁能减少重试造成的浪费,更有利;冲突稀少时,乐观方式能消除等待、提升吞吐量。Web 应用中很多更新操作冲突较少,乐观方式往往更合适。
SELECT FOR UPDATE — 显式行锁
得益于 MVCC,普通的读取不会加锁,但有时确实需要"把我刚读到的这一行锁住,不让任何人碰"。典型场景是读取-检查-写入(read-modify-write)模式。读取余额、检查是否充足、再扣款,就是这样的流程。
如果这时只用普通的 SELECT 读取,读取之后另一个事务可能立刻改动同一行,造成更新丢失(lost update)。SELECT ... FOR UPDATE 会在读取的那一刻就给该行加上写锁,在自己的事务结束之前,不让其他事务修改这一行。
BEGIN;
-- 锁住这一行的同时读取。其他事务修改这一行时会等待。
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- 应用程序检查余额是否充足之后
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
COMMIT; -- 提交时释放锁
FOR UPDATE 的几个变体也值得了解。FOR SHARE 是共享锁,允许其他读取但阻止写入。FOR UPDATE SKIP LOCKED 会跳过已被锁定的行,对于多个 worker 各自从队列中取走不同任务的工作队列模式很有用。FOR UPDATE NOWAIT 在无法立即获得锁时不会等待,而是直接报错。
有一点需要留意,那就是死锁。如果两个事务试图以相反的顺序锁住对方已锁定的行,就会永远互相等待。数据库会检测到这种情况,强制让其中一方失败,所以应用程序必须准备好捕获这个错误并重试。减少死锁的实用技巧是"始终按同一顺序加锁"。
PostgreSQL vs MySQL — 默认值不同
这里要说明一个直接影响实务的差异。两种数据库的默认隔离级别不同。
- PostgreSQL 的默认值是 READ COMMITTED。 它在每条语句开始时看到最新的已提交快照。因此即便在同一个事务内,不同语句也可能看到不同的快照,不可重复读在默认设置下是可能发生的。
- MySQL(InnoDB)的默认值是 REPEATABLE READ。 它在事务的第一次读取时刻锁定一份快照,并在整个事务期间保持不变。
更有意思的是,各自的更高级别实际上是如何运作的。
- PostgreSQL 的 REPEATABLE READ 实现的是真正的快照隔离,连标准所允许的幻读也实际上被阻止了。不过写偏斜依然可能发生。
- PostgreSQL 的 SERIALIZABLE 使用一种叫 SSI(Serializable Snapshot Isolation)的技术,在快照隔离之上检测危险的依赖关系,让发生冲突的事务失败。这样一来连写偏斜也被阻止了,但代价是可能出现序列化失败(40001 类错误),应用程序必须对此进行重试。
- MySQL/InnoDB 的 REPEATABLE READ 对普通读取提供一致的快照,但对锁定读(
FOR UPDATE等)会使用间隙锁(gap lock)来阻止幻读插入。这是纯粹的快照隔离与锁混合在一起的微妙行为,所以即便同样叫 REPEATABLE READ,细节表现也与 PostgreSQL 不同。
这个差异为什么在实务中很重要?因为同样的应用代码,连接到不同的数据库时,并发相关 bug 的表现形态也会不同。在 PostgreSQL 上没问题的代码,到了 MySQL 上可能行为不同,反之亦然。所以涉及并发的逻辑,一定要清楚目标数据库实际的默认隔离级别,必要时应显式提升隔离级别并加以验证。
实务指南汇总
把到目前为止的内容压缩成实战要点。
首先,一开始就要准确了解默认隔离级别。 PostgreSQL 默认是 READ COMMITTED,MySQL 默认是 REPEATABLE READ。不了解这一点,就会陷入"这个值为什么在事务中途变了?"或者相反方向的困惑。
其次,要小心读取-检查-写入模式。 扣减余额、减少库存、预订座位这类"根据刚读到的值来写入"的逻辑,是更新丢失的温床。用乐观的版本列或 SELECT ... FOR UPDATE 显式加以保护。
记住写偏斜。 即便每个事务单独看都显得正当,合并起来也可能打破不变式。纯快照隔离无法阻止这一点,所以对于真正需要保证的不变式,应该提升到 SERIALIZABLE,或用显式加锁来强制保证。
为序列化失败和死锁准备好重试。 SERIALIZABLE、乐观方式、加锁,这些方式全都以"失败了就重试"为前提。如果应用程序没有重试逻辑,这些安全机制反而会变成故障。
最后,让事务保持简短。 长事务会长时间持有锁,妨碍 MVCC 清理旧版本(拖慢 PostgreSQL 的 VACUUM),并提高冲突的概率。
结语
事务从"把多个操作捆成一个整体,要么全部处理要么全部不处理"这样一个简单的承诺出发,但一旦多个事务同时运行,就会引出隔离这个深层问题。存在脏读、不可重复读、幻读、写偏斜这些异常现象,而阻止到什么程度,则分成了四种隔离级别。
现代数据库大多通过 MVCC 提供"读不阻塞写"的快照隔离,并在此基础上,需要时用锁或乐观版本来处理冲突。而 PostgreSQL 与 MySQL 从默认隔离级别到更高级别的细节行为都存在差异,所以了解实际目标数据库的行为才是最重要的。
核心在于这一点:完美的隔离代价高昂,而完全放弃隔离又很危险。所以我们要决定"这份数据能容忍到什么程度的异常现象",然后选择与之匹配的隔离级别与并发策略。当这个选择是有意识做出的,事务就不再是恐惧的对象,而会成为可以信赖的基础。
参考资料
- PostgreSQL 官方文档, "Transaction Isolation": https://www.postgresql.org/docs/current/transaction-iso.html
- MySQL 官方文档, "Transaction Isolation Levels": https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html
- "A Critique of ANSI SQL Isolation Levels" (Berenson et al., 1995): https://www.microsoft.com/en-us/research/publication/a-critique-of-ansi-sql-isolation-levels/
- Martin Kleppmann, "Designing Data-Intensive Applications" (第 7 章, Transactions)
- Jepsen, "Consistency models": https://jepsen.io/consistency