PostgreSQL
 sql >> Base de Dados >  >> RDS >> PostgreSQL

O SELECT ou INSERT está em uma função propensa a condições de corrida?


É 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 o SELECT 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 o SELECT e o INSERT 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 de FETCH FIRST ROW ONLY (=LIMIT 1 ). O nome da tag é obviamente UNIQUE .

  • Remover FOR SHARE no meu exemplo, se você geralmente não tiver DELETE simultâneo ou UPDATE na tabela tag . 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 como id ou name . 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 de SELECT , porque o bloco mais caro com INSERT contendo a EXCEPTION cláusula raramente é inserida. A consulta também é mais simples.

  • FOR SHARE não é possível aqui (não é permitido em UNION inquerir).

  • LIMIT 1 não seria necessário (testado na página 9.4). Postgres deriva LIMIT 1 de INTO _tag_id e só é executado até que a primeira linha seja encontrada.