sql - SELECT或INSERT函数是否容易出现竞争状况?

标签 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。

最佳答案

这是在可能的并发写入负载下 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成功。
call :
SELECT f_tag_id('possibly_new_tag');
如果同一事务中的后续命令依赖于该行的存在,并且实际上其他事务可能同时更新或删除该行,则可以使用 SELECT 锁定FOR SHARE语句中的现有行。
如果改为插入该行,则该行将一直锁定到事务结束为止。
如果大多数时间都插入了新行,请从INSERT开始以使其更快。
有关:
  • Get Id from a conditional INSERT
  • How to include excluded rows in RETURNING from INSERT ... ON CONFLICT

  • 一次针对INSERTSELECT 多行(一组)的相关(纯SQL)解决方案:
  • How to use RETURNING with ON CONFLICT in PostgreSQL?

  • 这种纯SQL解决方案有什么问题?
    我以前也建议过此SQL函数:
    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;
    
    这不是完全错误,但是它无法填补漏洞,例如@FunctorSalad worked out in his added answer。如果并发事务尝试同时执行相同的操作,则该函数可能会得出空结果。具有CTE的查询中的所有语句实际上都是在同一时间执行的。 The manual:

    All the statements are executed with the same snapshot


    如果并发事务稍早插入了相同的新标记,但尚未提交:
  • 在等待并发事务完成后,UPSERT部分变为空。 (如果并发事务应回滚,它仍将插入新标记并返回新的ID。)
  • SELECT部分​​也空了,因为它基于相同的快照,在该快照中,(尚未提交的)并发事务中的新标记不可见。

  • 我们什么也没得到。不符合预期。这与朴素的逻辑是不合常理的(我被困在那里),但这就是Postgres的MVCC模型的工作原理-必须起作用。
    因此,如果多个事务可以尝试同时插入同一标签,请不要使用此选项。 循环,直到您实际获得一行为止。在常见的工作负载中几乎不会触发该循环。
    原始答案(Postgres 9.4或更早版本)
    给定此表(略有简化):
    CREATE table tag (
      tag_id serial PRIMARY KEY
    , tag    text   UNIQUE
    );
    
    ...实际上是100%安全的函数,用于插入新标签/选择现有标签,看起来像这样。
    为什么不100%?考虑notes in the manual for the related UPSERT example:
    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;
    
    SQL Fiddle.
    说明
  • 首先尝试SELECT。这样,您可以避免99.99%的时间处理异常昂贵的异常。
  • 使用CTE可以最小化竞赛条件的(已经很小的)时隙。
  • 一个查询中SELECTINSERT之间的时间窗口非常小。如果您没有繁重的并发负载,或者您可以每年遇到一次异常,则可以忽略这种情况,而使用SQL语句,这会更快。
  • 不需要FETCH FIRST ROW ONLY(= LIMIT 1)。标签名称显然是UNIQUE
  • 如果您在表FOR SHARE上通常没有并发的DELETEUPDATE,则在我的示例中删除 tag 。花费一点点性能。
  • 请勿引用语言名称:“plpgsql”。 plpgsql是一个标识符。 Quoting may cause problems,仅允许向后兼容。
  • 不要使用诸如idname之类的非描述性列名。当联接几个表时(这是在关系数据库中执行的操作),您最终会使用多个相同的名称,并且必须使用别名。

  • 内置在您的功能中
    使用此功能,您可以在很大程度上将FOREACH LOOP简化为:
    ...
    FOREACH TagName IN ARRAY $3
    LOOP
       INSERT INTO taggings (PostId, TagId)
       VALUES   (InsertedPostId, f_tag_id(TagName));
    END LOOP;
    ...
    
    不过,使用 unnest() 作为单个SQL语句更快:
    INSERT INTO taggings (PostId, TagId)
    SELECT InsertedPostId, f_tag_id(tag)
    FROM   unnest($3) tag;
    
    替换整个循环。
    替代解决方案
    此变体基于带有UNION ALL子句的LIMIT的行为:一旦找到足够的行,就永远不会执行其余的行:
  • Way to try multiple SELECTs till a result is available?

  • 在此基础上,我们可以将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子句的EXCEPTION的更昂贵的块。查询也更简单。
  • 此处无法使用
  • FOR SHARE(在UNION查询中不允许使用)。
  • LIMIT 1是不必要的(在9.4页中进行了测试)。 Postgres从LIMIT 1派生INTO _tag_id,并且仅执行到找到第一行为止。
  • 关于sql - SELECT或INSERT函数是否容易出现竞争状况?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/15939902/

    相关文章:

    go - 如何使变量成为线程安全的

    MySQL 从 2 个不同的表更新

    sql - 查询中的表名是否不区分大小写?

    sql - 使用带有回退功能的右表对左外连接进行排序

    postgresql - 如何在路径名中插入定界符? [postgresql]

    database - 使用多个数据库和每个数据库一个模式更好,还是一个数据库有多个模式更好?

    ruby-on-rails - 验证有条件时数据库中的唯一性验证

    sql - Group By 语句中的重复列

    Java:单线程修改对象,多线程读取

    php - 如何在 PHP 中使用并发访问 mysql 表