Skip to content

PostgreSQL 中的 MVCC — 1. 事务隔离

原文:https://habr.com/en/companies/postgrespro/articles/467437/ (作者 Egor Rogov,PostgresPro,2019-09-16,基于 PostgreSQL 11)

这是"PostgreSQL 中的 MVCC"系列的第一篇,主要讲事务隔离这个概念本身:为什么需要它、SQL 标准怎么定义它、PostgreSQL 又是怎么实现的,以及不同隔离级别在实践中会踩到哪些坑。

为什么需要隔离

数据的"正确性"其实分好几个层次。最底层是完整性(integrity),也就是数据满足预先声明的约束,比如 NOT NULL、UNIQUE。更高一层是一致性(consistency),指数据同时满足数据库约束和应用程序自身业务规则的隐含要求——这一层往往没法完全写进数据库的 CHECK 约束里,只能靠应用逻辑保证。

麻烦在于:即使每个事务单独执行时都是正确的,把它们并发地交织执行,仍然可能破坏一致性。经典例子是转账——从账户 A 扣钱、给账户 B 加钱,这两步操作如果被别的事务插进来看到中间状态,或者其中一步失败而另一步成功,账目就会对不上。

由此可以给"事务"下一个更严格的定义:事务是应用程序执行的一组操作,它把数据库从一个正确状态转换到另一个正确状态(一致性),前提是这组操作要么全部完成(原子性),要么完全不受其他事务干扰(隔离性)。

完全的隔离在工程上代价很高,会显著降低系统吞吐量。所以现实中的数据库大多提供"打折"的隔离级别,把一部分正确性保证的责任转嫁给应用开发者——这正是本文要讨论的核心张力。

SQL 标准里的隔离级别与异常现象

SQL 标准定义了四种隔离级别,通过是否允许特定"异常现象"(anomaly)来区分:

丢失更新(Lost Update):两个事务先后读到同一行的同一个值,然后各自基于这个值做更新并提交,后提交的会覆盖掉先提交的更新,导致其中一次修改凭空消失。比如两个事务都想给余额为 1000 的账户各加 100,理想结果应该是 1200,但如果都是"读旧值→算新值→写回",最终可能只加了一次,变成 1100。SQL 标准规定这种现象在任何隔离级别下都不允许发生。

脏读(Dirty Read):一个事务读到了另一个尚未提交的事务写入的数据。如果那个事务后来回滚了,读到的就是从未真正存在过的数据。只有 Read Uncommitted 级别允许脏读。

不可重复读(Non-repeatable Read):同一个事务里,两次读同一行,中间被别的已提交事务改过,两次读到的值不一样。Read Uncommitted 和 Read Committed 都允许这种现象。

幻读(Phantom Read):同一个事务里,两次执行同样条件的查询,返回的行集合不一样(多了或少了行),因为别的事务在这期间插入或删除了满足条件的行。Read Uncommitted、Read Committed、Repeatable Read 都允许幻读。

标准里其实只正式定义了这四种异常,但真实系统中还存在很多标准没有覆盖的"其他异常"。这套分类本质上是从传统的两阶段锁(Two-Phase Locking, 2PL)协议的视角设计的:对被修改的行加锁能防止脏读和丢失更新;对读过的行加锁能防止不可重复读;理论上还需要"谓词锁"才能防止幻读,但谓词锁很少被真正实现。

标准定义的四个级别与异常现象的对应关系("是"表示该级别允许该异常发生):

隔离级别丢失更新脏读不可重复读幻读其他异常
Read Uncommitted不允许
Read Committed不允许不允许
Repeatable Read不允许不允许不允许
Serializable不允许不允许不允许不允许不允许

PostgreSQL 的实现思路:快照隔离

PostgreSQL 没有照搬基于锁的 2PL 模型,而是采用了快照隔离(Snapshot Isolation, SI)协议。核心思想是:每个事务看到的都是数据库在某个时间点上的一份"一致快照"。这依赖于多版本并发控制——同一行数据可以同时存在多个版本,从而让读操作几乎不需要加锁就能得到一致的视图。

这套机制带来几个重要后果:

  • 脏读被自动杜绝,因此 PostgreSQL 里 Read Uncommitted 和 Read Committed 实际行为完全一样(PostgreSQL 里没有真正的 Read Uncommitted)。
  • 写事务只会阻塞后续对同一行的写操作。
  • 读事务永远不会阻塞写事务,写事务也永远不会阻塞读事务——这是 MVCC 相对于纯锁方案的最大优势。

PostgreSQL 实际提供的隔离级别与异常对照表:

隔离级别丢失更新脏读不可重复读幻读其他异常
Read Uncommitted不允许不允许
Read Committed不允许不允许
Repeatable Read不允许不允许不允许不允许
Serializable不允许不允许不允许不允许不允许

值得注意的是,PostgreSQL 的 Repeatable Read 比 SQL 标准要求得更严格——标准允许 Repeatable Read 出现幻读,但 PostgreSQL 的实现杜绝了幻读。

下文所有示例都基于这张测试表:

sql
CREATE TABLE accounts(
  id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
  number text UNIQUE,
  client text,
  amount numeric
);

INSERT INTO accounts VALUES
  (1, '1001', 'alice', 1000.00),
  (2, '2001', 'bob', 100.00),
  (3, '2002', 'bob', 900.00);

Alice 一个账户 1000 元,Bob 两个账户合计 1000 元(100+900)。

Read Committed:默认级别,坑最多

Read Committed 的规则是:每条 SQL 语句执行时都会重新拍一张快照(而不是整个事务共用一张)。它保证不会看到脏数据,但会暴露出好几种一致性问题。

不可重复读的直观演示:事务 1 修改了账户 1 的余额并提交,事务 2 在此期间对同一行执行了两次 SELECT,两次结果不同:

sql
-- 事务 1:
BEGIN;
UPDATE accounts SET amount = amount - 200 WHERE id = 1;
COMMIT;

-- 事务 2(与事务 1 并发):
BEGIN;
SELECT * FROM accounts WHERE client = 'alice';  -- 第一次:1000.00
-- 等事务 1 提交后再查一次:
SELECT * FROM accounts WHERE client = 'alice';  -- 第二次:800.00

比不可重复读更隐蔽的是跨行的不一致读取:如果一个事务要通过循环或多条语句分别读取几行数据再汇总,中途某些行被并发事务改动,读到的组合就会是一个数据库里从未真实存在过的状态。原文举例:统计 Bob 的总资产时,账户 2 读到的是转账前的旧值,账户 3 读到的是转账后的新值,两者拼起来算出的总额比实际值多出 100 元。

还有一个容易被忽视的陷阱是 VOLATILE 函数。因为 Read Committed 下每条语句一张快照,如果在一条 SELECT 语句里调用了标记为 VOLATILE 的函数,这个函数内部执行的子查询会拿到自己的、可能更晚的快照,导致函数返回值和外层查询的其余部分处于不同的时间点:

sql
CREATE FUNCTION get_amount(id integer) RETURNS numeric AS $$
  SELECT amount FROM accounts a WHERE a.id = get_amount.id;
$$ VOLATILE LANGUAGE sql;

SELECT get_amount(id), pg_sleep(2)
FROM accounts WHERE client = 'bob';

原文演示中,pg_sleep 造成的延迟给了并发事务修改数据的窗口,函数返回的金额和最终一致的账本对不上,凭空"丢"了 100 元。

另一种更隐蔽的情况是更新语句执行期间的不一致读取:如果一条 UPDATE 语句要修改多行,其中某一行正被别的事务锁定(比如那行也在被并发修改),UPDATE 会等待锁释放。锁释放后,PostgreSQL 会重新读取那一行的最新值来判断是否仍满足 WHERE 条件——但这意味着同一条 UPDATE 语句里,不同的行可能是基于不同时间点的数据做出的决策,产生了内部不一致。原文用一个"给余额超过 1000 的账户加息"的例子说明:某行原本满足条件,但在等锁期间被并发事务改到不再满足条件的状态,UPDATE 重新读取后仍然可能对这行加息,逻辑上有点"过期"。

由此得出一条重要的反模式:"先检查再操作"(check-then-act)在 Read Committed 下是不安全的:

sql
-- 错误示范:
IF (SELECT amount FROM accounts WHERE id = 1) >= 1000 THEN
  UPDATE accounts SET amount = amount - 1000 WHERE id = 1;
END IF;
-- 检查和更新之间,别的事务可能已经把余额改变了

针对这一类问题,原文给出三种应对思路:

  1. 把业务规则直接变成表上的 CHECK 约束,由数据库强制保证,而不是依赖应用层的先读后写逻辑。
  2. 把检查和修改合并成一条 SQL 语句(比如用 CTE 配合 INSERT/UPDATE/DELETE,或者 INSERT ... ON CONFLICT),让整个操作在一条语句内原子完成。
  3. 显式加锁(SELECT ... FOR UPDATELOCK TABLE),代价是牺牲一部分并发度。

Repeatable Read:整个事务共享一张快照

在这个级别,快照在事务开始时创建一次,之后一直沿用到事务结束,所以同一个事务里多次读同一行、或多次执行同一个查询,结果都是稳定的:

sql
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM accounts ORDER BY id;  -- 返回 3 行
-- 即使别的事务在这期间提交了增删改,再查一次:
SELECT * FROM accounts ORDER BY id;  -- 仍然是原来那 3 行,内容不变

代价是:如果这个事务本身要修改数据,且它的修改与并发事务的修改产生了冲突,PostgreSQL 不会像 Read Committed 那样"重新读取最新值再判断",而是直接报错终止事务:

ERROR: could not serialize access due to concurrent update

这意味着应用程序必须自己捕获这类"序列化错误"并重试整个事务。

即便如此,Repeatable Read 仍然不能杜绝所有异常,原文详细讲了两种:

写偏斜(Write Skew,原文称为"不一致写入"):两个事务各自基于同一个聚合值(比如客户名下账户总额)做判断,然后分别更新不同的行,两边判断时看到的聚合值都是"合法"的,但两个更新叠加起来就违反了业务约束。原文示例:约束是"客户名下账户余额之和不能为负",Bob 的两个账户合计 900 元。两个事务并发地各自读到"总额 900",都认为可以各扣 600 元(分别扣不同的账户),各自看来都不违反约束,但两笔一起生效后 Bob 的总资产变成了 -300,实际上违反了约束——因为 Repeatable Read 不会检测"读到的聚合值在提交时是否已经过期"这类跨行、跨事务的依赖。

只读事务异常:这是更微妙的一种,需要三个事务共同构造出来——一个基于聚合值做修改的事务、一个并发提交了修改的事务,以及一个只读的事务。只读事务读到的数据组合,会呈现出一种在任何"事务串行执行顺序"下都不可能出现的状态:它同时看到了修改事务尚未感知的并发变更,却又看到了修改事务基于旧聚合值算出的结果。原文用利息计算的例子说明——事务 1 基于 900 元计算利息,事务 2 并发地把余额减少了 100 元并提交,只读事务 3 看到的结果显示:利息是按 900 算的,但账户已经是扣减后的状态,两者拼在一起在逻辑上"对不上号"。

Serializable:最严格,但有代价

Serializable 构建在快照隔离的基础上,额外增加了对读写依赖关系的检测,效果是让并发事务的最终结果,等价于按某种顺序串行执行这些事务的结果——因此可以杜绝包括写偏斜、只读事务异常在内的所有已知异常。

同样的写偏斜场景,在 Serializable 下会被检测出来并报错:

sql
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT sum(amount) FROM accounts WHERE client = 'bob';  -- 910

--(并发的另一个事务)
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT sum(amount) FROM accounts WHERE client = 'bob';  -- 910

UPDATE accounts SET amount = amount - 600 WHERE id = 2;

--(并发事务)
UPDATE accounts SET amount = amount - 600 WHERE id = 3;
COMMIT;

COMMIT;
-- ERROR: could not serialize access due to read/write dependencies among transactions
-- DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt.
-- HINT: The transaction might succeed if retried.

对于只读事务,PostgreSQL 提供了 DEFERRABLE 关键字:声明为 SERIALIZABLE READ ONLY DEFERRABLE 的事务会主动等待,直到系统能保证它读到的数据处于某个安全的、可串行化的时间点,才真正开始执行查询,从而避免只读事务异常,且不需要重试:

sql
BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
-- 事务会阻塞,直到找到一个安全的执行时刻
SELECT * FROM accounts WHERE client = 'alice';

但 Serializable 也有明显的限制,原文总结了几条需要特别注意的地方:

  • 应用必须处理并重试因序列化冲突而失败的事务,这一点和 Repeatable Read 类似但发生频率可能更高。
  • 不能把 Serializable 事务和 Read Committed / Repeatable Read 事务混在同一个工作负载里——如果这样做,Serializable 事务的冲突检测会因为无法获得其他事务的完整依赖信息而失效,退化成 Repeatable Read 的行为,且不会有任何警告。
  • 实现上存在"假阳性":由于内部资源限制(比如用于跟踪谓词锁的内存或索引结构有限),一些其实并不冲突的"无辜"事务也可能被误判并中止。
  • 整体性能开销显著,吞吐量会下降。
  • 只读查询在只读副本(standby)上不能使用 Serializable 语义。
  • 在 PostgreSQL 11 中,Serializable 事务不能使用并行查询计划(这一限制在 PostgreSQL 12 中被解除)。

可以通过如下方式把默认隔离级别设为 Serializable:

sql
ALTER SYSTEM SET default_transaction_isolation = 'serializable';

三种级别怎么选

原文最后给出了一个实践取舍的总结:

Read Committed(默认):优点是事务几乎不会因为并发冲突而被中止(只有基础设施故障才会失败),吞吐量最高。缺点是可能出现的异常种类最多,测试困难、问题难以复现,开发者必须自己小心处理不可重复读、幻读、以及前面提到的"更新期间的不一致读取"等问题,通常要求把逻辑收敛成单条 SQL 语句或显式加锁。适合能容忍偶尔轻微不一致、且对吞吐量要求很高的系统。

Repeatable Read:能消除不可重复读和幻读,一致性明显优于 Read Committed,尤其适合需要执行多条查询语句、但只读取数据的报表类事务(这类场景完全不会遇到写偏斜问题)。缺点是写事务必须自己处理序列化错误并实现重试逻辑,同时依然可能出现写偏斜和只读事务异常这两类"漏网之鱼"。适合愿意实现重试逻辑、又不想承受 Serializable 全部开销的场景。

Serializable:开发者完全不需要考虑并发执行带来的各种异常,一致性保证最强。缺点是会有假阳性导致无辜事务被中止、性能开销明显、standby 副本不适用、不能与其它隔离级别混用、同样需要实现重试逻辑。适合对一致性要求最高、并发量相对可控、宁可牺牲一些吞吐量也要保证正确性的场景。

小结

PostgreSQL 没有采用 SQL 标准所依赖的锁协议模型,而是用多版本快照隔离实现了隔离性,这使得它在 Read Committed/Read Uncommitted 之间没有区别,同时让 Repeatable Read 比标准规定的更严格(杜绝幻读)。但即便是最高的 Repeatable Read 和 Serializable 级别,也各自有自己的代价——前者会放过写偏斜和只读事务异常并要求应用重试,后者虽然理论完备却有性能开销和假阳性问题。理解这套隔离机制的边界,是后续几篇文章讨论行版本、快照、清理(vacuum)等底层实现细节的基础。

系列后续文章包括:数据的物理存储(分支、文件、页面)、行版本与子事务、数据快照与可见性判断、页内清理与 HOT 更新、常规 VACUUM、自动清理(autovacuum),以及事务 ID 回卷与冻结机制。