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

问题描述

我们有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'))

在某些情况下,有可能最终在barfoo_id的{​​{1}}中插入一行。这两个查询是通过两个完全不同的过程在不同的事务中执行的。

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

是什么原因导致这种比赛状况?是由于子选择,还是由于检查外键约束的时间?

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

编辑

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

解决方法

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

但是在执行检查外键约束的查询时,INSERT INTO foo已提交,因此外键约束不会报告错误。

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

,

逻辑表明,命令的排序(包括子查询)以及Postgres约束检查(不一定是立即执行)时,可能会导致此问题。因此,您可以

  • 第二条命令先开始
  • SELECT组件运行并返回NULL
  • 第一个命令启动并插入行
  • 第二个命令插入行(带有“名称”字段和NULL)
  • 由于存在“名称”,因此FK参考检查成功

可延期的约束,请参见https://www.postgresql.org/docs/13/sql-set-constraints.htmlhttps://begriffs.com/posts/2017-08-27-deferrable-sql-constraints.html

建议答案

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

您确实有比赛条件。如果没有某种锁定或使用事务对事件进行排序,那么就没有排除序列的规则

  1. 执行bar插入的子选择,产生NULL
  2. 插入foo
  3. bar插入到INSERT中,它现在没有任何FK违例,但是确实为NULL。

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

,

匿名的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;
$$;

或使用带有CTE的普通SQL,以避免将上下文切换到plpgsql /从plpgsql切换上下文:

with t(id) as 
(
 INSERT into foo (name) values ('BAZ') RETURNING id
) 
INSERT into bar (foo_name,(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相同:它们将仅查找在命令开始时已提交的目标行。但是,这样的目标行在被发现时可能已经被另一个并发事务更新(或删除或锁定)。在这种情况下,可能的更新程序将等待第一个更新事务提交或回滚(如果仍在进行中)。