Chris van Eijk:因并发更新导致无法序列化访问

Chris van Eijk:因并发更新导致无法序列化访问

💡 原文英文,约1400词,阅读约需5分钟。
📝

内容提要

PostgreSQL在REPEATABLE READ或SERIALIZABLE隔离级别下,事务更新或锁定另一事务已提交修改的行会报40001错误,需回滚并重试整个事务。快照始于首个查询而非BEGIN;SKIP LOCKED无法避免该错误,因为行已被修改而非仅被锁定。实测重试可挽回全部丢失更新,但需限制重试次数。READ COMMITTED无此错误,但可能覆盖他人修改。

🔎

延伸解读

快照起点决定冲突窗口

文章实测表明,REPEATABLE READ 的快照并非在 BEGIN 时建立,而是在事务内第一条非事务控制语句执行时建立。若会话 A 先 BEGIN,之后 B 提交修改,A 再首次读取或更新该行,A 能看到 B 的新版本,更新不会报 40001。因此,判断冲突风险时不能只看 BEGIN 时刻,而要看首个查询或更新语句的时机。

SKIP LOCKED 在 REPEATABLE READ 下失效的原因

SKIP LOCKED 只跳过当前被锁定的行。文章实测中,B 已提交对 job 1 的修改,该行不再被锁,但已被更改;A 在 REPEATABLE READ 下执行 FOR UPDATE SKIP LOCKED 仍收到 40001,而不是跳到 job 2。只有 B 的事务尚未提交、行仍处于锁定状态时,SKIP LOCKED 才会跳过。队列 worker 因此常运行在 READ COMMITTED。

重试必须覆盖整个事务

文章强调,40001 后应回滚并重试整个事务,包括决定 SQL 和取值的全部逻辑,而非只重试失败语句。实测中,两个 worker 各执行 200 次事务,REPEATABLE READ 不重试时 35 次 40001 导致丢失 35 次递增;重试后 400 次递增全部到达,共经历 44 次失败尝试。作者提醒高竞争下可能需多次重试,应限制尝试次数。

READ COMMITTED 不报错但可能覆盖修改

READ COMMITTED 下同一行被并发修改不会触发 40001,但可能用旧值覆盖他人已提交的修改。文章示例中,A 将 job 1 设为 done,覆盖了 B 已提交的 taken。若更新语句基于事务早先读取或计算的值,这种覆盖不会报错。条件更新如 status = 'new' 在 READ COMMITTED 下可匹配 0 行,避免覆盖,但在 REPEATABLE READ 下仍可能报 40001。

❓

Q&A

PostgreSQL 报错 could not serialize access due to concurrent update 是什么原因?

在 REPEATABLE READ 或 SERIALIZABLE 隔离级别下,事务尝试更新、删除或锁定一行,而该行在事务快照之后被另一个事务修改并提交。由于事务的快照仍显示旧版本,PostgreSQL 无法安全地应用更改,因此抛出 SQLSTATE 40001 错误并回滚事务。

为什么在 REPEATABLE READ 下使用 SELECT ... FOR UPDATE SKIP LOCKED 仍然会报 40001 错误?

SKIP LOCKED 只跳过当前被锁定的行,而报错的行是已被其他事务修改并提交的行,并非处于锁定状态。在 REPEATABLE READ 中,事务的快照看不到该行的新版本,因此尝试锁定或更新它会触发 40001 错误,而不是跳过。

遇到 40001 错误时应该重试整个事务还是只重试失败的语句?

必须重试整个事务,包括所有决定 SQL 逻辑和值的代码。因为重试时事务需要获取包含其他事务更改的新快照,仅重试失败语句无法获得正确快照。PostgreSQL 不提供自动重试,需应用层实现,并建议限制重试次数。

READ COMMITTED 隔离级别下会出现 could not serialize access due to concurrent update 错误吗?

不会。在 READ COMMITTED 下,同样的并发更新不会报错,后一个更新会直接覆盖前一个更新,或者条件更新匹配不到行。但这也可能导致丢失更新,例如一个事务的修改被另一个事务覆盖。

REPEATABLE READ 事务的快照是在什么时候建立的?

快照在事务的第一个非事务控制语句(即第一个查询)执行时建立,而不是在 BEGIN 时。因此,如果在 BEGIN 之后、第一个查询之前有其他事务提交了修改,那么本事务的第一个查询会看到这些修改,后续更新不会报错。

SERIALIZABLE 隔离级别下出现的 could not serialize access due to read/write dependencies among transactions 是什么情况?

这是 SERIALIZABLE 特有的变体,即使两个事务没有更新同一行也可能发生。例如两个事务分别读取总和并各自扣减不同账户,导致总和为负,第二个提交时会因读写依赖冲突而失败,并提示可重试。

🏷️

标签

➡️

继续阅读