É o problema recorrente de
SELECT
ou INSERT
sob possível carga de gravação simultânea, relacionada a (mas diferente de) UPSERT
(que é INSERT
ou UPDATE
). Esta função PL/pgSQL usa UPSERT (
INSERT ... ON CONFLICT .. DO UPDATE
) para INSERT
ou SELECT
uma linha única :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$;
Ainda há uma pequena janela para uma condição de corrida. Para ter certeza absoluta obtemos um 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<>mexa aqui
Isso continua em loop até que
INSERT
ou SELECT
sucesso.Ligue:SELECT f_tag_id('possibly_new_tag');
Se comandos subsequentes na mesma transação confiar na existência da linha e é realmente possível que outras transações a atualizem ou excluam simultaneamente, você pode bloquear uma linha existente no
SELECT
declaração com FOR SHARE
.Se a linha for inserida, ela ficará bloqueada (ou não visível para outras transações) até o final da transação.
Comece com o caso comum (
INSERT
vs SELECT
) para torná-lo mais rápido. Relacionado:
- Obter ID de um INSERT condicional
- Como incluir linhas excluídas em RETURNING from INSERT ... ON CONFLICT
Solução relacionada (SQL puro) para
INSERT
ou SELECT
várias linhas (um conjunto) de uma só vez:- Como usar RETURNING com ON CONFLICT no PostgreSQL?
O que há de errado com isto solução SQL pura?
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$;
Não totalmente errado, mas não consegue selar uma brecha, como @FunctorSalad funcionou. A função pode apresentar um resultado vazio se uma transação concorrente tentar fazer o mesmo ao mesmo tempo. O manual:
Todas as instruções são executadas com o mesmo snapshot
Se uma transação simultânea insere a mesma nova tag um momento antes, mas ainda não foi confirmada:
-
A parte UPSERT aparece vazia, após aguardar a conclusão da transação simultânea. (Se a transação simultânea deve ser revertida, ela ainda insere a nova tag e retorna um novo ID.)
-
A parte SELECT também aparece vazia, porque é baseada no mesmo snapshot, onde a nova tag da transação concorrente (ainda não confirmada) não é visível.
Não recebemos nada . Não como pretendido. Isso é contra-intuitivo para a lógica ingênua (e eu fui pego lá), mas é assim que o modelo MVCC do Postgres funciona - tem que funcionar.
Portanto, não use isso se várias transações puderem tentar inserir a mesma tag ao mesmo tempo. Ou loop até que você realmente obtenha uma linha. O loop dificilmente será acionado em cargas de trabalho comuns de qualquer maneira.
Postgres 9.4 ou anterior
Dada esta tabela (ligeiramente simplificada):
CREATE table tag (
tag_id serial PRIMARY KEY
, tag text UNIQUE
);
Um quase 100% seguro função para inserir uma nova tag/selecionar uma existente, pode ficar assim.
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<>mexa aqui
antigo sqlfiddle
Por que não 100%? Considere as notas no manual para o
UPSERT
relacionado exemplo:- https://www.postgresql.org/docs/current/plpgsql-control-structures.html#PLPGSQL-UPSERT-EXAMPLE
Explicação
-
Experimente oSELECT
primeiro . Dessa forma, você evita o consideravelmente mais caro tratamento de exceção 99,99% do tempo.
-
Use um CTE para minimizar o intervalo de tempo (já pequeno) para a condição de corrida.
-
A janela de tempo entre oSELECT
e oINSERT
em uma consulta é super pequenino. Se você não tem uma carga concorrente pesada, ou se você pode viver com uma exceção uma vez por ano, você pode simplesmente ignorar o caso e usar a instrução SQL, que é mais rápida.
-
Não há necessidade deFETCH FIRST ROW ONLY
(=LIMIT 1
). O nome da tag é obviamenteUNIQUE
.
-
RemoverFOR SHARE
no meu exemplo, se você geralmente não tiverDELETE
simultâneo ouUPDATE
na tabelatag
. Custa um pouco de desempenho.
-
Nunca cite o nome do idioma:'plpgsql'.plpgsql
é um identificador . A citação pode causar problemas e só é tolerada para compatibilidade com versões anteriores.
-
Não use nomes de coluna não descritivos comoid
ouname
. Ao juntar algumas tabelas (que é o que você faz em um banco de dados relacional) você acaba com vários nomes idênticos e precisa usar aliases.
Incorporado à sua função
Usando esta função, você pode simplificar amplamente seu
FOREACH LOOP
para:...
FOREACH TagName IN ARRAY $3
LOOP
INSERT INTO taggings (PostId, TagId)
VALUES (InsertedPostId, f_tag_id(TagName));
END LOOP;
...
Mais rápido, porém, como uma única instrução SQL com
unnest()
:INSERT INTO taggings (PostId, TagId)
SELECT InsertedPostId, f_tag_id(tag)
FROM unnest($3) tag;
Substitui todo o loop.
Solução alternativa
Esta variante baseia-se no comportamento de
UNION ALL
com um LIMIT
cláusula:assim que linhas suficientes são encontradas, o resto nunca é executado:- Como tentar vários SELECTs até que um resultado esteja disponível?
Com base nisso, podemos terceirizar o
INSERT
em uma função separada. Só aí precisamos de tratamento de exceção. Tão seguro quanto a primeira solução. 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$;
Que é usado na função principal:
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$;
-
Isso é um pouco mais barato se a maioria das chamadas precisar apenas deSELECT
, porque o bloco mais caro comINSERT
contendo aEXCEPTION
cláusula raramente é inserida. A consulta também é mais simples.
-
FOR SHARE
não é possível aqui (não é permitido emUNION
inquerir).
-
LIMIT 1
não seria necessário (testado na página 9.4). Postgres derivaLIMIT 1
deINTO _tag_id
e só é executado até que a primeira linha seja encontrada.