在函数中使用SELECT或INSERT容易出现竞争条件吗?

33

我编写了一个函数,用于创建一个简单博客引擎中的文章:

CREATE FUNCTION CreatePost(VARCHAR, TEXT, VARCHAR[])
RETURNS INTEGER AS $$
    DECLARE
        InsertedPostId INTEGER;
        TagName VARCHAR;
    BEGIN
        INSERT INTO Posts (Title, Body)
        VALUES ($1, $2)
        RETURNING Id INTO InsertedPostId;

        FOREACH TagName IN ARRAY $3 LOOP
            DECLARE
                InsertedTagId INTEGER;
            BEGIN
                -- I am concerned about this part.
                BEGIN
                    INSERT INTO Tags (Name)
                    VALUES (TagName)
                    RETURNING Id INTO InsertedTagId;
                EXCEPTION WHEN UNIQUE_VIOLATION THEN
                    SELECT INTO InsertedTagId Id
                    FROM Tags
                    WHERE Name = TagName
                    FETCH FIRST ROW ONLY;
                END;

                INSERT INTO Taggings (PostId, TagId)
                VALUES (InsertedPostId, InsertedTagId);
            END;
        END LOOP;

        RETURN InsertedPostId;
    END;
$$ LANGUAGE 'plpgsql';

如果多个用户同时删除标签并创建帖子,这是否容易出现竞态条件?
具体而言,事务(以及函数)是否可以防止发生此类竞争条件?
我正在使用PostgreSQL 9.2.3。

3个回答

68
这是一个经常出现的问题,即在可能的并发写入负载下,与UPSERT(即INSERTUPDATE)相关但不同的SELECTINSERT问题。
这个PL/pgSQL函数使用UPSERT(INSERT ... ON CONFLICT ..INSERTSELECT一个单行
CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int)
  LANGUAGE plpgsql AS
$func$
BEGIN
   SELECT tag_id  -- only if row existed before
   FROM   tag
   WHERE  tag = _tag
   INTO   _tag_id;

   IF NOT FOUND THEN
      INSERT INTO tag AS t (tag)
      VALUES (_tag)
      ON     CONFLICT (tag) DO NOTHING
      RETURNING t.tag_id
      INTO   _tag_id;
   END IF;
END
$func$;

还有一个极小的时间窗口可以发生竞态条件。为了绝对确定我们获得一个ID:
CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int)
  LANGUAGE plpgsql AS
$func$
BEGIN
   LOOP
      SELECT tag_id
      FROM   tag
      WHERE  tag = _tag
      INTO   _tag_id;

      EXIT WHEN FOUND;

      INSERT INTO tag AS t (tag)
      VALUES (_tag)
      ON     CONFLICT (tag) DO NOTHING
      RETURNING t.tag_id
      INTO   _tag_id;

      EXIT WHEN FOUND;
   END LOOP;
END
$func$;

fiddle

这将循环执行,直到INSERTSELECT成功为止。 调用:

SELECT f_tag_id('possibly_new_tag');

关于EXIT

如果同一事务中的后续命令依赖于该行的存在,并且实际上可能有其他事务同时更新或删除它,您可以在SELECT语句中使用FOR SHARE锁定现有行。
如果插入了该行,则它将被锁定(或对其他事务不可见)直到事务结束。

从常见情况(INSERT vs SELECT)开始,以提高速度。

相关:

INSERTSELECT一次性插入(一组)多行的相关(纯SQL)解决方案:

这个纯SQL解决方案有什么问题?

CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int)
  LANGUAGE sql AS
$func$
WITH ins AS (
   INSERT INTO tag AS t (tag)
   VALUES (_tag)
   ON     CONFLICT (tag) DO NOTHING
   RETURNING t.tag_id
   )
SELECT tag_id FROM ins
UNION  ALL
SELECT tag_id FROM tag WHERE tag = _tag
LIMIT  1;
$func$;

不完全错误,但它未能封堵一个漏洞,就像@FunctorSalad解决的那样。如果并发事务在同一时间尝试执行相同操作,该函数可能会得到一个空结果。手册:

所有语句都在同一个快照下执行

如果一个并发事务在稍早的时候插入了相同的新标签,但尚未提交:

  • 在等待并发事务完成后,UPSERT部分会得到一个空结果。(如果并发事务回滚,它仍会插入新标签并返回一个新的ID。)

  • 选择部分也会得到一个空结果,因为它基于相同的快照,而尚未提交的并发事务中的新标签是不可见的。

我们什么都得不到。这不是预期的结果。这与直觉逻辑相悖(我也曾陷入其中),但这是Postgres的MVCC模型的工作方式 - 必须如此。

所以如果多个事务尝试在同一时间插入相同的标签,请不要使用此方法。或者循环直到实际获得一行。在常见的工作负载中,循环几乎不会触发。
Postgres 9.4或更早版本
给定这个(稍微简化的)表:
CREATE table tag (
  tag_id serial PRIMARY KEY
, tag    text   UNIQUE
);

一个几乎100%安全的函数,用于插入新标签/选择现有标签,可能是这样的。
CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT tag_id int)
  LANGUAGE plpgsql AS
$func$
BEGIN
   LOOP
      BEGIN
      WITH sel AS (SELECT t.tag_id FROM tag t WHERE t.tag = _tag FOR SHARE)
         , ins AS (INSERT INTO tag(tag)
                   SELECT _tag
                   WHERE  NOT EXISTS (SELECT 1 FROM sel)  -- only if not found
                   RETURNING tag.tag_id)       -- qualified so no conflict with param
      SELECT sel.tag_id FROM sel
      UNION  ALL
      SELECT ins.tag_id FROM ins
      INTO   tag_id;

      EXCEPTION WHEN UNIQUE_VIOLATION THEN     -- insert in concurrent session?
         RAISE NOTICE 'It actually happened!'; -- hardly ever happens
      END;

      EXIT WHEN tag_id IS NOT NULL;            -- else keep looping
   END LOOP;
END
$func$;

db<>fiddle 这里
旧版 sqlfiddle

为什么不是100%?请考虑手册中相关UPSERT示例的注释:

解释

  • 先尝试使用SELECT。这样你可以99.99%的时间避免使用代价更高的异常处理。

  • 使用CTE来最小化(已经很小的)竞争条件的时间窗口。

  • SELECTINSERT之间的时间窗口在一个查询中非常短暂。如果你没有大量并发负载,或者如果你可以接受一年一次的异常,你可以忽略这种情况并使用更快的SQL语句。

  • 不需要使用FETCH FIRST ROW ONLY(= LIMIT 1)。标签名明显是UNIQUE

  • 如果你的表tag通常没有并发的DELETEUPDATE,可以在我的示例中去掉FOR SHARE。这会稍微降低一点性能。

  • 不要引用语言名称:'plpgsql'plpgsql是一个标识符。引用可能会引起问题,只有为了向后兼容才被容忍。

  • 不要使用非描述性的列名,比如idname。当在关系型数据库中连接多个表时,你会得到多个相同的列名,必须使用别名。

嵌入到您的函数中

使用此函数,您可以大大简化您的FOREACH LOOP为:

...
FOREACH TagName IN ARRAY $3
LOOP
   INSERT INTO taggings (PostId, TagId)
   VALUES   (InsertedPostId, f_tag_id(TagName));
END LOOP;
...

更快的方法是使用一个单独的SQL语句,使用unnest()函数:
INSERT INTO taggings (PostId, TagId)
SELECT InsertedPostId, f_tag_id(tag)
FROM   unnest($3) tag;

替换整个循环。
替代方案
这个变体基于带有LIMIT子句的UNION ALL的行为:一旦找到足够的行,剩下的部分就不会执行:
- [多个SELECT尝试直到有结果的方法](link1)
在此基础上,我们可以将INSERT外包到一个单独的函数中。只有在那里我们需要异常处理。与第一个解决方案一样安全。
CREATE OR REPLACE FUNCTION f_insert_tag(_tag text, OUT tag_id int)
  RETURNS int
  LANGUAGE plpgsql AS
$func$
BEGIN
   INSERT INTO tag(tag) VALUES (_tag) RETURNING tag.tag_id INTO tag_id;

   EXCEPTION WHEN UNIQUE_VIOLATION THEN  -- catch exception, NULL is returned
END
$func$;

主函数中使用了哪个?
CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int)
   LANGUAGE plpgsql AS
$func$
BEGIN
   LOOP
      SELECT tag_id FROM tag WHERE tag = _tag
      UNION  ALL
      SELECT f_insert_tag(_tag)  -- only executed if tag not found
      LIMIT  1  -- not strictly necessary, just to be clear
      INTO   _tag_id;

      EXIT WHEN _tag_id IS NOT NULL;  -- else keep looping
   END LOOP;
END
$func$;

这样做会更便宜一些,如果大部分的调用只需要SELECT,因为包含EXCEPTION子句的较昂贵的INSERT块很少被执行。查询也更简单。
这里不允许使用FOR SHARE(在UNION查询中不允许)。 LIMIT 1在这里是不必要的(在pg 9.4中进行了测试)。Postgres会根据INTO _tag_id推导出LIMIT 1,并且只执行直到找到第一行为止。

1
这是一篇很棒的文章,@Erwin。我正在使用Postgres 9.6,并基于您的第一段代码(以及锁定)来解决SELECTINSERT问题,只不过我将其作为独立语句执行,而不是作为函数执行。然而,有时我会从语句中得到空结果。在其他事务已经插入冲突行的情况下,就会出现这种情况。但是,从表中选择后续行却能得到结果。我认为这不是一种预期行为,难道不是吗? - twoflower
@twoflower:这意味着当您的事务尝试SELECT行时,并发事务尚未提交。可以通过像上面建议和解释的那样使用ON CONFLICT (tag) DO UPDATE SET tag = t.tag WHERE FALSE来避免该问题。您试过了吗? - Erwin Brandstetter
1
@twoflower,ErwinBrandstetter:我遇到了与@twoflower类似的问题;我在下面添加了一个示例作为答案(抱歉这是一个评论式的答案,但如果格式化为评论,这将不太可读,并且我没有提出新问题,因为这似乎与原始问题非常相关)。在这种情况下,“DO UPDATE SET tag = t.tag WHERE FALSE”似乎没有任何区别。 - FunctorSalad
@FunctorSalad:非常好的观点。你的补充答案应该得到更多的投票。我更新了我的答案并添加了解释。 - Erwin Brandstetter
1
@Kudi:锁定整张表会有效地禁用并发,使任务变得琐碎。但相对而言,这非常昂贵,因此通常要避免使用它。(它也可能引入死锁的新问题。) - Erwin Brandstetter
显示剩余8条评论

5
即使在Postgres 9.5中引入了ON CONFLICT子句,仍然需要注意某些问题。使用与@Erwin Brandstetter答案中相同的函数和示例表,如果执行以下操作:
Session 1: begin;

Session 2: begin;

Session 1: select f_tag_id('a');
 f_tag_id 
----------
       11
(1 row)

Session 2: select f_tag_id('a');
[Session 2 blocks]

Session 1: commit;

[Session 2 returns:]
 f_tag_id 
----------
        NULL
(1 row)

在第二个会话中,f_tag_id 返回了 NULL,这在单线程世界中是不可能的!如果我们将事务隔离级别提高到 repeatable read(或更强的 serializable),则第二个会话将抛出错误信息:ERROR: could not serialize access due to concurrent update。至少没有“不可能”的结果,但不幸的是,我们现在需要准备重试事务。

编辑:使用 repeatable readserializable,如果会话1插入标签 a,然后会话2插入 b,然后会话1尝试插入 b,会话2尝试插入 a,那么一个会话就会检测到死锁:

ERROR:  deadlock detected
DETAIL:  Process 14377 waits for ShareLock on transaction 1795501; blocked by process 14363.
Process 14363 waits for ShareLock on transaction 1795503; blocked by process 14377.
HINT:  See server log for query details.
CONTEXT:  while inserting index tuple (0,3) in relation "tag"
SQL function "f_tag_id" statement 1

当收到死锁错误的会话回滚后,其他会话继续进行。所以,我认为在这种情况下,我们应该像处理 serialization_failure 一样重试。

或者,按照一致的顺序插入标签,但如果它们不是在一个地方全部添加,则这并不容易。


1
非常有用的补充!(是的,我会将其视为序列化失败,并在“SERIALIZABLE”事务隔离中重试。但在默认的“READ COMMITTED”中处理此问题而不使用异常要便宜得多。) - Erwin Brandstetter

-1

我认为存在一种可能性,即当标签已经存在时,在您的事务发现它之后,它可能会被另一个事务删除。使用SELECT FOR UPDATE应该可以解决这个问题。


"select for update" 仅适用于更新操作,当插入的行不存在时,就没有需要锁定的内容。 - nash

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接