31

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

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。

4

3 回答 3

54

SELECT这是可能的并发写入负载或INSERT在可能的并发写入负载下反复出现的问题,与(但不同于)UPSERT(即INSERTUPDATE)有关。

此 PL/pgSQL 函数使用UPSERTINSERT ... ON CONFLICT .. DO UPDATEINSERT ( )单行SELECT

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$;

db<>在这里摆弄

这会一直循环,直到其中一个INSERTSELECT成功。称呼:

SELECT f_tag_id('possibly_new_tag');

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

从常见情况(INSERTvs SELECT)开始,以使其更快。

有关的:

INSERT一次或多SELECT (一组)的相关(纯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。)

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

我们一无所获。不像预期的那样。这对幼稚的逻辑是违反直觉的(我被抓住了),但这就是 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 here
sqlfiddle

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

解释

  • 尝试第SELECT 一个。通过这种方式,您可以在 99.99% 的时间内避免成本更高的异常处理。

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

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

  • 不需要FETCH FIRST ROW ONLY(= LIMIT 1)。标签名称显然是UNIQUE.

  • FOR SHARE如果您通常没有 concurrentDELETEUPDATEon the table ,请在我的示例中删除tag。花费一点点性能。

  • 永远不要引用语言名称:'plpgsql'plpgsql是一个标识符引用可能会导致问题,并且只允许向后兼容。

  • 不要使用非描述性的列名,如idor name。当连接几个表时(这是您在关系数据库中所做的),您最终会得到多个相同的名称并且必须使用别名。

内置到您的函数中

使用此功能,您可以在很大程度上简化您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;

替换整个循环。

替代解决方案

这个变体建立在UNION ALLwithLIMIT子句的行为之上:只要找到足够多的行,其余的就永远不会执行:

在此基础上,我们可以将其外包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的更昂贵的块。查询也更简单。INSERTEXCEPTION

  • FOR SHARE在这里是不可能的(不允许在UNION查询中)。

  • LIMIT 1没有必要(在第 9.4 页测试)。Postgres 派生LIMIT 1INTO _tag_id并且仅在找到第一行之前执行。

于 2013-04-11T13:39:43.227 回答
5

即使使用ON CONFLICTPostgres 9.5 中引入的子句,仍然需要注意一些事情。使用与@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)

所以在会话 2 中f_tag_id返回NULL,这在单线程世界中是不可能的!

如果我们将事务隔离级别提高到repeatable read(或更高serializable),会话 2 会ERROR: could not serialize access due to concurrent update改为抛出。所以至少没有“不可能”的结果,但不幸的是,我们现在需要准备重试事务。

编辑:使用repeatable reador serializable,如果会话 1 插入 tag 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试,在这种情况下?

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

于 2017-11-20T22:16:16.440 回答
-1

我认为当标签已经存在时,它可能会在您的交易找到它后被另一笔交易删除。使用 SELECT FOR UPDATE 应该可以解决这个问题。

于 2013-04-11T04:53:34.983 回答