sql >> Base de Datos >  >> RDS >> PostgreSQL

¿Es SELECCIONAR o INSERTAR en una función propensa a condiciones de carrera?

Es el problema recurrente de SELECT o INSERT bajo una posible carga de escritura simultánea, relacionada con (pero diferente de) UPSERT (que es INSERT o UPDATE ).

Esta función PL/pgSQL utiliza UPSERT (INSERT ... ON CONFLICT .. DO UPDATE ) a INSERT o SELECT una fila ú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$;

Todavía hay una pequeña ventana para una condición de carrera. Para estar absolutamente seguro obtenemos una identificación:

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<>violín aquí

Esto continúa en bucle hasta que INSERT o SELECT tiene éxito. Llamada:

SELECT f_tag_id('possibly_new_tag');

Si los comandos posteriores en la misma transacción confiar en la existencia de la fila y es posible que otras transacciones la actualicen o eliminen al mismo tiempo, puede bloquear una fila existente en SELECT declaración con FOR SHARE .
Si la fila se inserta en su lugar, se bloquea (o no es visible para otras transacciones) hasta el final de la transacción de todos modos.

Comience con el caso común (INSERT vs SELECT ) para hacerlo más rápido.

Relacionado:

  • Obtener ID de un INSERT condicional
  • Cómo incluir filas excluidas en RETURNING from INSERT... ON CONFLICT

Solución relacionada (SQL puro) para INSERT o SELECT varias filas (un conjunto) a la vez:

  • ¿Cómo usar RETURNING con ON CONFLICT en PostgreSQL?

¿Qué tiene de malo esto? solución de SQL puro?

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$;

No está del todo mal, pero no logra sellar una laguna, como funcionó @FunctorSalad. La función puede generar un resultado vacío si una transacción simultánea intenta hacer lo mismo al mismo tiempo. El manual:

Todas las declaraciones se ejecutan con la misma instantánea

Si una transacción simultánea inserta la misma etiqueta nueva un momento antes, pero aún no se ha confirmado:

  • La parte UPSERT aparece vacía, después de esperar a que finalice la transacción concurrente. (Si la transacción concurrente debe revertirse, aún inserta la nueva etiqueta y devuelve una nueva ID).

  • La parte SELECT también aparece vacía, porque se basa en la misma instantánea, donde la nueva etiqueta de la transacción concurrente (todavía no confirmada) no está visible.

No obtenemos nada . No como se pretendía. Eso es contrario a la lógica ingenua (y me atraparon allí), pero así es como funciona el modelo MVCC de Postgres:tiene que funcionar.

Por lo tanto, no use esto si varias transacciones pueden intentar insertar la misma etiqueta al mismo tiempo. O bucle hasta que realmente obtenga una fila. De todos modos, el bucle casi nunca se activará en cargas de trabajo comunes.

Postgres 9.4 o anterior

Dada esta tabla (ligeramente simplificada):

CREATE table tag (
  tag_id serial PRIMARY KEY
, tag    text   UNIQUE
);

Un casi 100% seguro función para insertar una nueva etiqueta/seleccionar una existente, podría tener este aspecto.

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<>violín aquí
Sqlfiddle antiguo

¿Por qué no al 100%? Considere las notas en el manual para el UPSERT relacionado ejemplo:

  • https://www.postgresql.org/docs/current/plpgsql-control-structures.html#PLPGSQL-UPSERT-EXAMPLE

Explicación

  • Prueba el SELECT primero . De esta manera evitas el considerablemente más caro manejo de excepciones el 99,99 % del tiempo.

  • Use un CTE para minimizar el intervalo de tiempo (ya pequeño) para la condición de carrera.

  • La ventana de tiempo entre SELECT y el INSERT dentro de una consulta es súper pequeño. Si no tiene una gran carga concurrente, o si puede vivir con una excepción una vez al año, simplemente puede ignorar el caso y usar la instrucción SQL, que es más rápida.

  • No es necesario FETCH FIRST ROW ONLY (=LIMIT 1 ). El nombre de la etiqueta es obviamente UNIQUE .

  • Eliminar FOR SHARE en mi ejemplo, si normalmente no tiene DELETE concurrentes o UPDATE en la tabla tag . Cuesta un poquito de rendimiento.

  • Nunca cite el nombre del idioma:'plpgsql' . plpgsql es un identificador . Las citas pueden causar problemas y solo se toleran por compatibilidad con versiones anteriores.

  • No use nombres de columna no descriptivos como id o name . Al unir un par de mesas (que es lo que haces en una base de datos relacional) termina con varios nombres idénticos y tiene que usar alias.

Integrado en su función

Usando esta función, podría simplificar en gran medida su FOREACH LOOP a:

...
FOREACH TagName IN ARRAY $3
LOOP
   INSERT INTO taggings (PostId, TagId)
   VALUES   (InsertedPostId, f_tag_id(TagName));
END LOOP;
...

Sin embargo, es más rápido como una declaración SQL única con unnest() :

INSERT INTO taggings (PostId, TagId)
SELECT InsertedPostId, f_tag_id(tag)
FROM   unnest($3) tag;

Reemplaza todo el ciclo.

Solución alternativa

Esta variante se basa en el comportamiento de UNION ALL con un LIMIT cláusula:tan pronto como se encuentran suficientes filas, el resto nunca se ejecuta:

  • ¿Forma de probar múltiples SELECT hasta que haya un resultado disponible?

Sobre la base de esto, podemos subcontratar el INSERT en una función separada. Solo allí necesitamos manejo de excepciones. Tan seguro como la primera solución.

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 se utiliza en la función 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$;
  • Esto es un poco más barato si la mayoría de las llamadas solo necesitan SELECT , porque el bloque más caro con INSERT que contiene la EXCEPTION Rara vez se introduce una cláusula. La consulta también es más sencilla.

  • FOR SHARE no es posible aquí (no permitido en UNION consulta).

  • LIMIT 1 no sería necesario (probado en la página 9.4). Postgres deriva LIMIT 1 desde INTO _tag_id y solo se ejecuta hasta que se encuentra la primera fila.