SELECT或INSERT是否容易出现竞争条件?

时间:2013-04-11 02:51:24

标签: sql postgresql concurrency plpgsql upsert

我写了一个函数为一个简单的博客引擎创建帖子:

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 个答案:

答案 0 :(得分:38)

在可能的并发写入加载下, SELECTINSERT 的重复出现问题,与(但不同)相关UPSERT INSERTUPDATE )。

对于Postgres 9.5或更高版本

使用新的UPSERT implementation INSERT ... ON CONFLICT .. DO UPDATE,我们可以在很大程度上简化。 PL / pgSQL函数为INSERTSELECT 行(标记):

CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int) 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$ LANGUAGE plpgsql;

竞争状况仍有一个小窗口。要使 完全确定 ,您会收到一个ID:

CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int) 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$ LANGUAGE plpgsql;

这会一直循环,直到INSERTSELECT成功。 拨打:

SELECT f_tag_id('possibly_new_tag');

如果同一事务中的后续命令依赖于该行的存在,并且实际上其他事务可能同时更新或删除它,则可以锁定{{1}中的现有行} FOR SHARE声明 如果该行被插入,则它将被锁定,直到事务结束为止。

如果在大多数时间插入新行,请从SELECT开始,以使其更快。

相关:

一次性INSERTINSERT 多行 (一套)的相关(纯SQL)解决方案:

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

我以前也曾建议过这个SQL函数:

SELECT

这并非完全错误,但它无法像@FunctorSalad worked out in his added answer那样堵塞漏洞。如果并发事务尝试同时执行相同操作,则该函数可以得到空结果。具有CTE的查询中的所有语句实际上是同时执行的。 The manual:

  

所有语句都使用相同的快照执行

如果并发事务稍前插入相同的新标记,但尚未提交,则:​​

  • 等待并发事务完成后,UPSERT部分为空。 (如果并发事务应该回滚,它仍然会插入新标记并返回一个新ID。)

  • SELECT部分​​也是空的,因为它基于相同的快照,其中来自(尚未提交的)并发事务的新标记不可见。

我们没有。不是预期的。这与天真逻辑的反直觉(我被抓住了),但是Postgres的MVCC模型的工作方式 - 必须有效。

因此,如果多个事务可以尝试同时插入相同的标记,请不要使用此方法。 循环,直到您真正获得一行。在常见的工作负荷中几乎不会触发循环。

原始答案(Postgres 9.4或更早版本)

鉴于此(略微简化)表:

CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int) 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$ LANGUAGE sql;

... 几乎100%安全功能,可插入新标签/选择现有标签,可能如下所示。
为什么不100%?考虑notes in the manual for the related UPSERT example

CREATE table tag (
  tag_id serial PRIMARY KEY
, tag    text   UNIQUE
);

SQL Fiddle.

解释

  • 首先尝试CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT tag_id int) 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$ LANGUAGE plpgsql; 。通过这种方式,您可以避免99.99%的更昂贵的异常处理。

  • 使用CTE最小化竞争条件的(已经很小的)时段。

  • 一个查询SELECTSELECT 之间的时间窗口非常小。如果您没有繁重的并发负载,或者您可以每年使用一次异常,那么您可以忽略这种情况并使用更快的SQL语句。

  • 无需INSERT(= FETCH FIRST ROW ONLY)。标签名称显然是LIMIT 1

  • 如果您在表UNIQUE上通常没有DELETEUPDATE,请在我的示例中移除FOR SHARE。花费很少的成本。

  • 永远不要引用语言名称:' plpgsql' tag标识符Quoting may cause problems并且只能容忍向后兼容性。

  • 请勿使用非plpgsqlid等非描述性列名。当连接几个表(你在关系数据库中执行)时,你最终会得到多个相同的名称,并且必须使用别名。

内置于您的功能

使用此功能可以大大简化name到:

FOREACH LOOP

更快,作为带有unnest()的单个SQL语句:

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

取代整个循环。

替代解决方案

此变体建立在INSERT INTO taggings (PostId, TagId) SELECT InsertedPostId, f_tag_id(tag) FROM unnest($3) tag; 带有UNION ALL子句的行为的基础上:只要找到足够的行,其余行就永远不会执行:

在此基础上,我们可以将LIMIT外包给一个单独的函数。只有那里我们需要异常处理。和第一个解决方案一样安全。

INSERT

在主要功能中使用:

CREATE OR REPLACE FUNCTION f_insert_tag(_tag text, OUT tag_id int)
  RETURNS int 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$ LANGUAGE plpgsql;
  • 如果大多数调用只需要CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int) 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$ LANGUAGE plpgsql; ,这会便宜一些,因为很少输入包含SELECT子句的INSERT的更昂贵的块。查询也更简单。

  • 此处无法
  • EXCEPTIONFOR SHARE查询中不允许)。

  • UNION没有必要(在第9.4页测试)。 Postgres从LIMIT 1派生LIMIT 1,只执行直到找到第一行。

答案 1 :(得分:2)

即使使用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在会话2中返回NULL,这在单线程世界中是不可能的!

如果我们将事务隔离级别提升到repeatable read(或更强serializable),则会话2会抛出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一样处理死锁并在这种情况下重试?

或者,以一致的顺序插入标签,但如果不将它们全部添加到一个地方,这并不容易。

答案 2 :(得分:-1)

我认为,当标记已经存在时,它很可能会在您的事务找到后被另一个事务删除。使用SELECT FOR UPDATE可以解决这个问题。