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。
这是SELECT
或INSERT
反复出现的问题,与UPSERT
(即INSERT
或UPDATE
)有关(但不同)。
Postgres 9.5+更新
使用新的UPSERT实现INSERT ... ON CONFLICT .. DO UPDATE
,我们可以大大简化。 SQL函数来INSERT
或SELECT
一个标签:
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最小化竞争条件(已经很小)的时隙。
-
在一个查询中
SELECT
和INSERT
之间的时间窗口是非常小的。 如果没有大量的并发负载,或者如果每年可能遇到异常,则可以忽略该情况并使用更快的SQL语句。 -
不需要
FETCH FIRST ROW ONLY
(=LIMIT 1
)。 标签名称显然是UNIQUE
。 -
在我的例子中删除
FOR SHARE
,如果你通常没有并发的DELETE
或UPDATE
表tag
。 花费一点点的性能。 -
切勿引用语言名称:
'plpgsql'。plpgsql
是一个标识符 。 引用可能会导致问题 ,只能向后兼容。 -
不要使用非描述性列名,如
id
或name
。 当加入一对表( 这是你在关系数据库中做的 )时,你最终会得到多个相同的名字,并且必须使用别名。
内置于您的功能
使用这个函数可以大大简化你的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可以解决这个问题。