SELECT或INSERT在一个容易出现竞争条件的函数中?

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

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实现INSERT ... ON CONFLICT .. DO UPDATE ,我们可以大大简化。 SQL函数来INSERTSELECT一个标签:

 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 -- only executed if no INSERT LIMIT 1 $func$ LANGUAGE sql; 

或者与plpgsql相同:

 CREATE OR REPLACE FUNCTION f_tag_id(_tag text, OUT _tag_id int) AS $func$ BEGIN INSERT INTO tag AS t (tag) VALUES (_tag) ON CONFLICT (tag) DO NOTHING RETURNING t.tag_id INTO _tag_id; IF NOT FOUND THEN SELECT tag_id -- only if row existed before FROM tag WHERE tag = _tag INTO _tag_id; END IF; END $func$ LANGUAGE plpgsql; 

调用(对于任何一个变体都一样):

 SELECT f_tag_id('possibly_new_tag'); 

如果同一个事务中的后续命令依赖于该行的存在,并且其他事务实际上可能会同时更新或删除它,则可以锁定现有行:

  ON CONFLICT (tag) DO UPDATE SET tag = t.tag WHERE FALSE -- never executed but locks the row 

文档:

只有当这个表达式返回true的行才会被更新,但是当执行ON CONFLICT DO UPDATE动作时, 所有的行都会被锁定

与下面的解决方案相反,这将首先尝试INSERT (数据修改CTE始终执行)。 如果INSERT大部分时间冲突,那么两个单独的SQL命令(如下)可能会更快。

考虑我们避免的比赛条件的解释,以及下面的UNION ALL ... LIMIT 1

有关:

  • 从条件INSERT获取ID
  • 如何在INSERT … ON CONFLICT的RETURNING中包含排除的行

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

鉴于这个(稍微简化)表:

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

几乎100%安全的功能插入新的标签/选择现有的,可能看起来像这样。
为什么不是100%? 考虑相关UPSERT示例的手册中的注意事项 :

 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小提琴。

说明

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

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

  • 在一个查询中 SELECTINSERT之间的时间窗口是非常小的。 如果没有大量的并发负载,或者如果每年可能遇到异常,则可以忽略该情况并使用更快的SQL语句。

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

  • 在我的例子中删除FOR SHARE ,如果你通常没有并发的DELETEUPDATEtag 。 花费一点点的性能。

  • 切勿引用语言名称: 'plpgsql'plpgsql是一个标识符 。 引用可能会导致问题 ,只能向后兼容。

  • 不要使用非描述性列名,如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; 

取代整个循环。

替代方案

这个变体建立在具有LIMIT子句的UNION ALL的行为上:一找到足够的行,其余的就不会执行:

  • 尝试多个SELECT直到结果可用的方法?

在此基础上,我们可以将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 ,则会便宜一些,因为含有EXCEPTION子句的更昂贵的块很少输入。 查询也更简单。

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

  • LIMIT 1不需要(在第9.4节中进行测试)。 Postgres从INTO _tag_id派生LIMIT 1 ,并且只执行直到找到第一行。

我认为在标签已经存在的情况下,您的交易找到后可能会被其他交易删除。 使用SELECT FOR UPDATE可以解决这个问题。