【问题标题】:Is SELECT or INSERT in a function prone to race conditions?函数中的 SELECT 或 INSERT 是否容易出现竞争条件?
【发布时间】:2013-04-03 02:58:28
【问题描述】:

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

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。

【问题讨论】:

    标签: sql postgresql concurrency plpgsql upsert


    【解决方案1】:

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

    【讨论】:

    • select for update 仅适用于更新,当插入的行不存在所以没有什么可以锁定。
    【解决方案2】:

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

    这个 PL/pgSQL 函数使用UPSERT (INSERT ... ON CONFLICT .. DO UPDATE)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$;
    

    db小提琴here

    这会一直循环直到INSERTSELECT 成功。 调用:

    SELECT f_tag_id('possibly_new_tag');
    

    如果后续命令在同一个事务中依赖于该行的存在并且实际上有可能其他事务同时更新或删除它,您可以在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 worked out。如果并发事务尝试同时执行相同操作,该函数可能会得出一个空结果。 The manual:

    所有语句都使用同一个快照执行

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

    • 在等待并发事务完成后,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小提琴here
    sqlfiddle

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

    说明

    • 试试SELECT首先。这样,您可以在 99.99% 的时间内避免相当昂贵的异常处理。

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

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

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

    • 如果您在表tag 上通常没有并发DELETEUPDATE,请在我的示例中删除FOR SHARE。花费一点点性能。

    • 切勿引用语言名称:'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 子句:只要找到足够多的行,其余的就永远不会执行:

    在此基础上,我们可以将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$;
    

    在main函数中使用:

    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,并且只执行直到找到第一行。

    【讨论】:

    • 这是一篇精彩的文章,@Erwin。我正在使用 Postgres 9.6 并基于我的 SELECTINSERT 解决方案基于您的第一个代码(以及锁定),只是我将其作为独立语句执行,而不是作为函数执行。但是,有时我会从语句中得到空的结果。这是在其他一些事务已经插入冲突行的情况下。但是,随后从表中选择会产生该行。我认为这不是预期的行为,不是吗?
    • @twoflower:当您的事务尝试SELECT 行时,这表明并发事务尚未提交。可以使用ON CONFLICT (tag) DO UPDATE SET tag = t.tag WHERE FALSE 避免该问题,就像上面建议和解释的那样。你试过了吗?
    • 是的,我正在使用它。我将尝试准备一些这种行为的最小样本。
    • @twoflower, ErwinBrandstetter:我遇到了与@twoflower 类似的问题;我在下面添加了一个示例作为答案(对不起,评论-y 的答案,但这不会很容易被格式化为评论,而且我没有开始一个新问题,因为这似乎与原始问题非常相关)。在这种情况下,DO UPDATE SET tag = t.tag WHERE FALSE 似乎没有什么不同。
    • @Kudi:锁定整个表有效地禁用了并发,任务变得微不足道。但相对而言,这非常昂贵,因此您通常希望避免使用它。 (它也可能会引入新的死锁问题。)
    【解决方案3】:

    即使使用 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 一样对待死锁并在这种情况下重试?

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

    【讨论】:

    • 非常有用的补充! (是的,我会将其视为序列化失败并在SERIALIZABLE 事务隔离中重试。但在默认READ COMMITTED 中处理此问题要便宜得多。)
    猜你喜欢
    • 2014-04-23
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-11-02
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多