涉及子选择和外键的 Postgres 竞争条件

Postgres race condition involving subselect and foreign key

我们有 2 个表定义如下

CREATE TABLE foo (
  id BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL UNIQUE
);

CREATE TABLE bar (
  foo_id BIGINT UNIQUE,
  foo_name TEXT NOT NULL UNIQUE REFERENCES foo (name)
);

我注意到在同时执行以下两个查询时

INSERT INTO foo (name) VALUES ('BAZ')
INSERT INTO bar (foo_name, foo_id) VALUES ('BAZ', (SELECT id FROM foo WHERE name = 'BAZ'))

在某些情况下,最终可能会在 bar 中插入一行,其中 foo_idNULL。这两个查询由两个完全不同的进程在不同的事务中执行。

这怎么可能?我希望第二条语句由于外键违规而失败(如果 foo 中的记录不存在),或者以 foo_id 的非空值成功(如果存在) .

是什么导致了这种争用情况?是subselect的原因,还是检查外键约束的时机问题?

我们正在使用隔离级别“read committed”和 postgres 版本 10.3。

编辑

我认为这个问题不是特别清楚是什么让我感到困惑。问题是关于在执行单个语句期间如何以及为什么观察到数据库的两种不同状态。子选择观察到 foo 中的记录不存在,而 fk 检查认为它存在。如果只是没有规则阻止这种竞争条件,那么这本身就是一个有趣的问题——为什么不能使用事务 ID 来确保两者都观察到相同的数据库状态?

INSERT INTO bar 中的子查询无法看到 foo 中并发插入的新行,因为后者尚未提交。

但是在执行检查外键约束的查询时,INSERT INTO foo已经提交,所以外键约束没有报错。

解决此问题的一个简单方法是对 INSERT INT bar 使用 REPEATABLE READ 隔离级别。然后外键检查使用与INSERT相同的快照,它不会看到新提交的行,并且会抛出约束违反错误。

逻辑表明,命令的顺序(包括 sub-query)与 Postgres 检查约束(不一定是即时的)的时间相结合可能会导致问题。因此你可以

  • 先启动第二个命令
  • 具有 SELECT 组件 运行 和 return NULL
  • 第一个命令开始并插入行
  • 第二个命令插入行(带有 'name' 字段和 NULL)
  • FK 参考检查成功,因为 'name' 存在

可延迟约束请参阅 https://www.postgresql.org/docs/13/sql-set-constraints.html and https://begriffs.com/posts/2017-08-27-deferrable-sql-constraints.html

建议的答案

  • 对 Foo_Id 的 BAR 进行非空检查,或作为外键检查的一部分包含在内
  • 连续而不是同时(如果可能)将两个命令重写为运行

您确实存在竞争条件。如果没有某种锁定或使用事务来对事件进行排序,则没有排除顺序的规则

  1. 执行 bar INSERT 的子 select,产生 NULL
  2. 插入 foo
  3. INSERT into bar,现在没有任何 FK 违规,但确实有 NULL。

因为这当然是您真实程序的玩具版本,所以我无法推荐如何最好地修复它。如果按特定顺序要求这些事件有意义,那么它们可以在单个线程上的事务中。在某些其他情况下,您可能会禁止直接插入 foobar(必要时取消权限)并仅允许通过 function/procedure 或通过具有触发器的视图(可能是规则).

匿名 plpgsql 块将帮助您避免竞争条件(通过确保在同一事务中按顺序插入 运行)而无需深入 Postgres 内部:

do language plpgsql
$$
declare
 v_foo_id bigint;
begin
 INSERT into foo (name) values ('BAZ') RETURNING id into v_foo_id;
 INSERT into bar (foo_name, foo_id) values ('BAZ', v_foo_id);
end;
$$;

或将普通 SQL 与 CTE 一起使用以避免切换上下文 to/from plpgsql:

with t(id) as 
(
 INSERT into foo (name) values ('BAZ') RETURNING id
) 
INSERT into bar (foo_name, foo_id) values ('BAZ', (select id from t));

而且,顺便说一句,您确定您的示例中的两个插入是在同一事务中以正确的顺序执行的吗?如果不是,那么对你的问题的简短回答是“MVCC”,因为第二个语句不是原子的。

这似乎更有可能是两个查询一个接一个地执行但事务未提交的情况。

进程 1

INSERT INTO foo (name) VALUES ('BAZ')

事务未提交但进程 2 执行下一个查询

INSERT INTO bar (foo_name, foo_id) VALUES ('BAZ', (SELECT id FROM foo WHERE name = 'BAZ'))

在这种情况下,进程 2 查询将等到进程 1 事务未提交。

来自 PostgreSQL 文档:

UPDATE、DELETE、SELECT FOR UPDATE 和 SELECT FOR SHARE 命令在搜索目标行方面的行为与 SELECT 相同:它们只会找到目标行从命令开始时间开始提交。但是,这样的目标行在被发现时可能已经被另一个并发事务更新(或删除或锁定)。在这种情况下,would-be 更新程序将等待第一个更新事务提交或回滚(如果它仍在进行中)。