LabHub

博客

DB 事务与隔离级别完全解析

한국어English日本語中文

引言 — 事务究竟承诺了什么

当数据库里有钱在流动,一个可怕的问题就会浮现出来。"转账过程中服务器挂了怎么办?" 在从账户 A 扣掉 100 元、给账户 B 加上 100 元这两条语句之间,如果进程崩溃了,钱可能凭空消失,也可能变成两份。事务正是为了消除这种恐惧而存在的机制。

事务的承诺很简单。"把这一串语句当作一个整体来对待。要么全部生效,要么一个都不生效。" 这个整体一旦提交(commit)就被确定下来,一旦回滚(rollback)就整体作废,如同从未发生过。然而,要在多个事务同时运行的环境下守住这个简单的承诺,会冒出许多出人意料的微妙问题。本文正面处理这些微妙之处。

如果你想用真实的 SQL 亲自实验这些概念,可以在本站的 SQL 游乐场PostgreSQL 游乐场DuckDB 游乐场里运行查询。

ACID — 四个字母代表什么

事务的保证常被概括为 ACID 这个缩写。让我们逐个字母精确地看一遍。

这四者之中,原子性和持久性大体上是直观的。真正的难点在于隔离性,因为"要让你看到另一个事务的中间状态到什么程度"这个问题没有唯一正确答案,只有性能与正确性之间的一段光谱。

为什么隔离很难 — 并发才是根本问题

如果事务一个接一个顺序执行,隔离根本不是问题。难点出现在多个事务在时间上发生重叠的时候。理论上最安全的状态是"可串行化(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阻止阻止阻止

有一点需要特别留意:这张表只是 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 — 默认值不同

这里要说明一个直接影响实务的差异。两种数据库的默认隔离级别不同。

更有意思的是,各自的更高级别实际上是如何运作的。

这个差异为什么在实务中很重要?因为同样的应用代码,连接到不同的数据库时,并发相关 bug 的表现形态也会不同。在 PostgreSQL 上没问题的代码,到了 MySQL 上可能行为不同,反之亦然。所以涉及并发的逻辑,一定要清楚目标数据库实际的默认隔离级别,必要时应显式提升隔离级别并加以验证。

实务指南汇总

把到目前为止的内容压缩成实战要点。

首先,一开始就要准确了解默认隔离级别。 PostgreSQL 默认是 READ COMMITTED,MySQL 默认是 REPEATABLE READ。不了解这一点,就会陷入"这个值为什么在事务中途变了?"或者相反方向的困惑。

其次,要小心读取-检查-写入模式。 扣减余额、减少库存、预订座位这类"根据刚读到的值来写入"的逻辑,是更新丢失的温床。用乐观的版本列或 SELECT ... FOR UPDATE 显式加以保护。

记住写偏斜。 即便每个事务单独看都显得正当,合并起来也可能打破不变式。纯快照隔离无法阻止这一点,所以对于真正需要保证的不变式,应该提升到 SERIALIZABLE,或用显式加锁来强制保证。

为序列化失败和死锁准备好重试。 SERIALIZABLE、乐观方式、加锁,这些方式全都以"失败了就重试"为前提。如果应用程序没有重试逻辑,这些安全机制反而会变成故障。

最后,让事务保持简短。 长事务会长时间持有锁,妨碍 MVCC 清理旧版本(拖慢 PostgreSQL 的 VACUUM),并提高冲突的概率。

结语

事务从"把多个操作捆成一个整体,要么全部处理要么全部不处理"这样一个简单的承诺出发,但一旦多个事务同时运行,就会引出隔离这个深层问题。存在脏读、不可重复读、幻读、写偏斜这些异常现象,而阻止到什么程度,则分成了四种隔离级别。

现代数据库大多通过 MVCC 提供"读不阻塞写"的快照隔离,并在此基础上,需要时用锁或乐观版本来处理冲突。而 PostgreSQL 与 MySQL 从默认隔离级别到更高级别的细节行为都存在差异,所以了解实际目标数据库的行为才是最重要的。

核心在于这一点:完美的隔离代价高昂,而完全放弃隔离又很危险。所以我们要决定"这份数据能容忍到什么程度的异常现象",然后选择与之匹配的隔离级别与并发策略。当这个选择是有意识做出的,事务就不再是恐惧的对象,而会成为可以信赖的基础。

参考资料

评论

还没有评论。

登录后即可发表评论