剖析数据库默认隔离级别与开发者预期之间的Gap,解释丢失更新如何在小概率并发场景下悄然发生,以及READ COMMITTED/REPEATABLE READ/SERIALIZABLE各自能挡住哪些并发陷阱。
两个更新,同一行,没有达成一致
两个应用服务器在同一时刻接收到同一个客户操作。两者都读取了客户的余额,都加上了一笔信用,都写入了结果。其中一笔信用消失了。在各自的视角里,两个服务器都没有做错任何事——每者都读取了一个一致的值,每者都写入了新值,数据库也都没有报错地接受了这两次写入。
这就是丢失更新(lost update),它不是通常意义上的应用 bug。对于只有一个写入者的世界来说,应用代码是正确的。数据库默认并不承诺那个世界,它承诺的是每条单独语句看到的是一个一致的快照,而这个承诺远比代码所假设的要弱。两者之间的差距,就是事务隔离级别这一整件事的核心。
隔离级别是应用与数据库之间的一份契约,约定了一个事务允许观察到怎样的并发活动。SQL 标准定义了四个隔离级别,每个严肃的数据库至少实现了三个。实际问题不是哪个级别"最好"——而是每个级别实际承诺了什么保证,因为这些保证比大多数开发者以为的要窄,而且默认级别并不是最安全的那个。
隔离本应防止的四种异常
标准通过允许或防止哪些异常来定义隔离级别。有四种异常在实际中值得关注。
脏读(dirty read)是指读取了一个尚未提交的事务所写的行。危险不在于读取本身——而在于那个写入事务可能回滚,而读取者已经在了一个从未存在过的值之上建立了逻辑。
不可重复读(non-repeatable read)是指在同一个事务中两次读取同一行,却得到了不同的值,因为另一个事务在这之间提交了变更。第一次读取的是一个已提交的值,第二次也是。两者只是互相矛盾。
幻读(phantom read)是同行数版本的同类问题:带过滤条件的查询在第二次执行时返回了一组不同的行,因为另一个事务在两次查询之间插入了或删除了匹配该过滤条件的行。
丢失更新发生在两个事务读取了同一个值,都基于所读取的值进行了修改,第二次写入悄悄覆盖了第一次时。没有错误被抛出,没有约束被违反,数据库只是保留了最后一次写入,而较早事务的工作就此蒸发。
每个隔离级别都是频谱上的一个位置:数据库被允许放过的四种异常是哪些。最安全的级别防止全部四种。最弱的级别只防止第一种。两者之间的一切都是权衡。
Read Uncommitted:PostgreSQL 拒绝推出的级别
最弱的标准级别——read uncommitted,允许脏读。这个级别的事务可能看到另一个事务修改了但尚未提交的行——包括片刻后即将回滚的行。
PostgreSQL 没有实现它。这不是疏忽,而是一个架构后果。PostgreSQL 的并发控制建立在快照之上,快照在每条语句开始时获取,而快照在构造上只包含在快照获取之前已提交的行。不存在任何代码路径能让一个事务看到未提交的行,因为根本不存在在快照之外读取的代码路径。
在 PostgreSQL 中将级别设置为 read uncommitted 是合法的,但会被静默地等同于 read committed。这一点值得了解,因为它改变了你阅读其他数据库的文档和建议的方式:在 MySQL 中,read uncommitted 是真实的、可达到的;在 PostgreSQL 中它只是一个没有效果的标签。依赖于读取未提交数据来绕过锁问题的代码在这里不会起作用,而解决方案不是找一个更低的级别——没有更低的级别了。
Read Committed:你已经在运行的默认级别
Read committed 是 PostgreSQL 的默认级别,也是大多数应用在不知不觉中使用的级别。每条语句获得自己的快照,在语句开始时获取。该语句看到的是那一刻之前提交的所有行,以及那一刻之后没有提交的任何内容。
保证是按语句而非按事务的。同一个事务中的两个 SELECT,如果另一个事务在它们之间提交了,结果可能不同。这就是不可重复读的来源,对大多数应用来说这是不可见的,因为大多数事务都很短,而且大多数应用不会在同一个事务内用相同过滤条件重新读取同一行。
Read committed 也不能防止丢失更新。两个事务都可以在 read committed 下读取同一行,都计算一个新值,都写入它;写入是串行的,但读取不是,而较早的计算被丢弃了。这几乎是每个 PostgreSQL 安装的默认行为,意味着开头那个场景——两笔信用,一个余额——是普通结果,而不是配置错误。
在 read committed 下防止丢失更新的方法不是换一个隔离级别,而是让更新本身变成有条件的;语句在其自己的快照内重新读取该行,并基于当前值进行计算,这就关闭了针对这个特定模式的读-改-写间隙:
UPDATE accounts
SET balance = balance + 50
WHERE account_id = 42;
同样的技巧延伸到 upsert 形式,它在语句执行时基于行状态在 insert 和 update 之间原子地决定:
INSERT INTO counters (key, value)
VALUES ('visits', 1)
ON CONFLICT (key)
DO UPDATE SET value = counters.value + 1;
两种形式都不需要事务边界来防止丢失更新,因为冲突检查和写入发生在一条原子语句中。一旦应用将操作拆分为 SELECT 后再执行单独的 UPDATE,就重新踏上了读-改-写的路径,而隔离级别决定了下一步发生什么。
Repeatable Read:超越语句的快照
Repeatable read 是语义开始发生质变的级别。PostgreSQL 通过在事务的第一条语句时获取一次快照并在整个事务中复用它来实现它。事务中的每条语句看到的是同一个世界,包括同一组已提交的行以及这些行的同一版本。
不可重复读消失了:在同一个事务中两次读取同一行返回相同的值,因为只有一个快照。幻读也因此被防止了——原因相同,行集合在快照获取时就已经固定,所以带过滤条件的查询每次都返回相同的行,无论其他事务在这之间提交了什么。
然而丢失更新没有被防止——而是被检测到了。当一个 repeatable read 级别的事务试图更新一行,而一个并发已提交事务也更新了同一行时,PostgreSQL 不会静默覆盖,而是抛出序列化失败(serialization failure)。应用收到一个错误,必须重试事务。这是一个关键的区分:异常不允许静默通过,但事务也不会自动变得安全。从不处理序列化失败的应用会在数据即将被破坏的那一刻崩溃。
真正需要 repeatable read 语义的应用——必须在某个时间点一致视图上进行聚合的报表查询,或者在用户持续写入的同时读取稳定行集合的批处理作业——应该主动设置这个级别,并在序列化失败周围添加重试逻辑,因为在默认级别下这些查询会悄悄地混入不同时刻的数据。
Serializable:数据库开始说不的地方
Serializable 是最强的标准级别:事务的行为必须表现得如同它们按某种顺序一个接一个地运行,即使它们实际上是并发执行的。PostgreSQL 用串行化快照隔离(serializable snapshot isolation)来实现它,它在 repeatable read 快照下运行事务,并跟踪它们之间的读写依赖关系。当依赖图出现环时——意味着这个并发调度无法被线性化——数据库会以序列化失败中止其中之一的事务。
实际形态与 repeatable read 加更严格的失败检测完全相同。应用仍然必须重试被中止的事务;区别在于可中止的调度集合更大,因为 serializable 也会捕获那些两个事务以某种串行顺序不可能发生的读写重叠交叉。
诚实的权衡是吞吐量。Serializable 事务中止得更频繁,每次中止都强制应用重放工作。默认使用 serializable 的数据库是在选择正确性而非并发性;PostgreSQL 默认选择了另一个方向,而想要 serializable 行为的操作者必须按会话或按事务设置它,并构建使它可用的重试循环。
选择 serializable 是正确的场景是:业务不变量的正确性取决于不存在任何交叉——座位分配、库存递减、跨账户余额转账。对于这些情况,重试循环的代价低于在静默丢失更新之后进行审计的代价。
不必猜测地选择级别
四个级别形成了一棵实用的决策树,答案通常由两个问题决定:事务是否会重新读取已经读取过的数据?它是否会基于读取的值计算一个新值?
对于只读一次写一次的短事务,read committed 是正确的,而更新应使用原子条件形式来关闭读-改-写间隙。对于多次读取相同行或相同过滤结果集、必须看到一致世界的事务,repeatable read 是正确的级别——并在序列化失败周围加上重试处理器。对于与其他事务的交叉会破坏业务不变量的那些事务,serializable 是正确的级别——同样带上重试处理器,只是应用得更频繁。
在连接字符串或会话配置中设置级别,而不是到处散落每语句提示,因为级别是事务的属性,而事务是连接的属性:
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- all statements in this transaction share one snapshot
COMMIT;
一个不同事务运行不同级别的混合应用,只有在每条代码路径都在其事务的第一条语句之前设置自己的级别时才是正确的;一旦有一条路径忘记了,它就会静默地继承默认级别。
一个能展示你当前处于哪个级别的测试
内化这些差异的最快方式是自己观察它。两个会话,一张表,一行数据,在读和写之间故意停顿——每个保证都直接展示出来。
在会话一中,开始一个事务并读取一行。在会话二中,更新那行并提交。在会话一中再次读取。在 read committed 下,第二次读取显示新值;在 repeatable read 下,它显示旧值——快照赢了。用两个会话都从一个读取的值更新同一行来运行相同序列,在 repeatable read 下第二次提交会抛出序列化失败,而在 read committed 下它会静默成功并丢弃第一次更新。
这种对比——错误对静默——就是沿着隔离级别频谱向上移动的全部意义。数据库无法让并发逻辑变得正确;它只能拒绝执行会打破契约的交叉。较低的级别让交叉发生并保留结果。较高的级别中止事务并要求应用重试。你选择的隔离级别不是一个性能旋钮。它是在问:数据库是否被允许悄悄地出错,而应用的重试循环就是回答"否"的代价。
Originally published on Dispatch.