这是 SELECT 或 INSERT 在可能的并发写入负载下反复出现的问题,与 UPSERT 相关(但不同于)(即 INSERT 或 UPDATE)。
这个 PL/pgSQL 函数使用UPSERT (INSERT ... ON CONFLICT .. DO UPDATE) 到INSERT 或SELECT 一个单行:
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
这会一直循环直到INSERT 或SELECT 成功。
调用:
SELECT f_tag_id('possibly_new_tag');
如果后续命令在同一个事务中依赖于该行的存在并且实际上有可能其他事务同时更新或删除它,您可以在SELECT语句中锁定现有行FOR SHARE.
如果该行被插入,它将被锁定(或对其他事务不可见),直到事务结束。
从常见的情况开始(INSERT vs SELECT)以使其更快。
相关:
INSERT 或 SELECT 的相关(纯 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:
所有语句都使用同一个快照执行
如果一个并发事务早些时候插入了相同的新标签,但尚未提交:
我们得到什么。不像预期的那样。这与幼稚的逻辑有悖常理(我被抓住了),但这就是 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 将竞争条件的(已经很小的)时间段最小化。
-
SELECT 和INSERT 之间的时间窗口在一个查询中 非常小。如果您没有繁重的并发负载,或者您可以忍受一年一次的异常,您可以忽略这种情况并使用 SQL 语句,这样会更快。
-
不需要FETCH FIRST ROW ONLY (= LIMIT 1)。标签名显然是UNIQUE。
-
如果您在表tag 上通常没有并发DELETE 或UPDATE,请在我的示例中删除FOR SHARE。花费一点点性能。
-
切勿引用语言名称:'plpgsql'。 plpgsql 是一个标识符。 Quoting may cause problems 并且只允许向后兼容。
-
请勿使用非描述性的列名称,例如 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;
替换整个循环。
替代解决方案
这个变体建立在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,并且只执行直到找到第一行。