我写了一个函数为一个简单的博客引擎创建帖子:
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。
答案 0 :(得分:38)
在可能的并发写入加载下, SELECT
或INSERT
的重复出现问题,与(但不同)相关UPSERT
( INSERT
或UPDATE
)。
使用新的UPSERT implementation INSERT ... ON CONFLICT .. DO UPDATE
,我们可以在很大程度上简化。 PL / pgSQL函数为INSERT
或SELECT
单 行(标记):
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;
这会一直循环,直到INSERT
或SELECT
成功。
拨打:
SELECT f_tag_id('possibly_new_tag');
如果同一事务中的后续命令依赖于该行的存在,并且实际上其他事务可能同时更新或删除它,则可以锁定{{1}中的现有行} FOR SHARE
声明
如果该行被插入,则它将被锁定,直到事务结束为止。
如果在大多数时间插入新行,请从SELECT
开始,以使其更快。
相关:
一次性INSERT
或INSERT
多行 (一套)的相关(纯SQL)解决方案:
我以前也曾建议过这个SQL函数:
SELECT
这并非完全错误,但它无法像@FunctorSalad worked out in his added answer那样堵塞漏洞。如果并发事务尝试同时执行相同操作,则该函数可以得到空结果。具有CTE的查询中的所有语句实际上是同时执行的。 The manual:
所有语句都使用相同的快照执行
如果并发事务稍前插入相同的新标记,但尚未提交,则:
等待并发事务完成后,UPSERT部分为空。 (如果并发事务应该回滚,它仍然会插入新标记并返回一个新ID。)
SELECT部分也是空的,因为它基于相同的快照,其中来自(尚未提交的)并发事务的新标记不可见。
我们没有。不是预期的。这与天真逻辑的反直觉(我被抓住了),但是Postgres的MVCC模型的工作方式 - 必须有效。
因此,如果多个事务可以尝试同时插入相同的标记,请不要使用此方法。 或循环,直到您真正获得一行。在常见的工作负荷中几乎不会触发循环。
鉴于此(略微简化)表:
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;
... 几乎100%安全功能,可插入新标签/选择现有标签,可能如下所示。
为什么不100%?考虑notes in the manual for the related UPSERT
example:
CREATE table tag (
tag_id serial PRIMARY KEY
, tag text UNIQUE
);
首先尝试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;
。通过这种方式,您可以避免99.99%的更昂贵的异常处理。
使用CTE最小化竞争条件的(已经很小的)时段。
一个查询中SELECT
和SELECT
之间的时间窗口非常小。如果您没有繁重的并发负载,或者您可以每年使用一次异常,那么您可以忽略这种情况并使用更快的SQL语句。
无需INSERT
(= FETCH FIRST ROW ONLY
)。标签名称显然是LIMIT 1
。
如果您在表UNIQUE
上通常没有DELETE
或UPDATE
,请在我的示例中移除FOR SHARE
。花费很少的成本。
永远不要引用语言名称:' plpgsql' 。 tag
是标识符。 Quoting may cause problems并且只能容忍向后兼容性。
请勿使用非plpgsql
或id
等非描述性列名。当连接几个表(你在关系数据库中执行)时,你最终会得到多个相同的名称,并且必须使用别名。
使用此功能可以大大简化name
到:
FOREACH LOOP
更快,作为带有unnest()
的单个SQL语句:
...
FOREACH TagName IN ARRAY $3
LOOP
INSERT INTO taggings (PostId, TagId)
VALUES (InsertedPostId, f_tag_id(TagName));
END LOOP;
...
取代整个循环。
此变体建立在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 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
没有必要(在第9.4页测试)。 Postgres从LIMIT 1
派生LIMIT 1
,只执行直到找到第一行。
答案 1 :(得分:2)
即使使用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 read
或serializable
,如果会话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
一样处理死锁并在这种情况下重试?
或者,以一致的顺序插入标签,但如果不将它们全部添加到一个地方,这并不容易。
答案 2 :(得分:-1)
我认为,当标记已经存在时,它很可能会在您的事务找到后被另一个事务删除。使用SELECT FOR UPDATE可以解决这个问题。