PostgreSQL
 sql >> Database >  >> RDS >> PostgreSQL

SELECT o INSERT in una funzione è soggetta a condizioni di gara?

È il problema ricorrente di SELECT o INSERT sotto possibile carico di scrittura simultaneo, relativo a (ma diverso da) UPSERT (che è INSERT o UPDATE ).

Questa funzione PL/pgSQL usa UPSERT (INSERT ... ON CONFLICT .. DO UPDATE ) a INSERT o SELECT una riga singola :

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

C'è ancora una piccola finestra per una condizione di gara. Per essere assolutamente sicuro otteniamo un 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<>gioca qui

Questo continua a scorrere finché non INSERT o SELECT riesce.Chiama:

SELECT f_tag_id('possibly_new_tag');

Se comandi successivi nella stessa transazione fare affidamento sull'esistenza della riga ed è effettivamente possibile che altre transazioni la aggiornino o la eliminino contemporaneamente, puoi bloccare una riga esistente in SELECT dichiarazione con FOR SHARE .
Se invece la riga viene inserita, è comunque bloccata (o non visibile per altre transazioni) fino al termine della transazione.

Inizia con il caso comune (INSERT vs SELECT ) per renderlo più veloce.

Correlati:

  • Ottieni l'ID da un INSERT condizionale
  • Come includere le righe escluse in RETURNING from INSERT ... ON CONFLICT

Soluzione correlata (SQL puro) a INSERT o SELECT più righe (un set) subito:

  • Come utilizzare RETURNING con ON CONFLICT in PostgreSQL?

Cosa c'è che non va in questo pura soluzione 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$;

Non del tutto sbagliato, ma non riesce a sigillare una scappatoia, come ha funzionato @FunctorSalad. La funzione può fornire un risultato vuoto se una transazione simultanea tenta di fare lo stesso contemporaneamente. Il manuale:

Tutte le istruzioni vengono eseguite con la stessa istantanea

Se una transazione simultanea inserisce lo stesso nuovo tag un momento prima, ma non ha ancora eseguito il commit:

  • La parte UPSERT risulta vuota, dopo aver atteso il termine della transazione simultanea. (Se la transazione simultanea deve tornare indietro, inserisce comunque il nuovo tag e restituisce un nuovo ID.)

  • Anche la parte SELECT risulta vuota, perché si basa sulla stessa istantanea, in cui il nuovo tag della transazione simultanea (ancora non vincolata) non è visibile.

Non riceviamo niente . Non come previsto. Questo è contro-intuitivo alla logica ingenua (e sono stato catturato lì), ma è così che funziona il modello MVCC di Postgres:deve funzionare.

Quindi non usarlo se più transazioni possono provare a inserire lo stesso tag contemporaneamente. Oppure ciclo finché non ottieni effettivamente una riga. In ogni caso, il ciclo non verrà quasi mai attivato nei carichi di lavoro comuni.

Postgres 9.4 o precedente

Data questa tabella (leggermente semplificata):

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

Un sicuro quasi al 100% funzione per inserire un nuovo tag / selezionarne uno esistente, potrebbe assomigliare a questo.

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<>gioca qui
Sqlfiddle vecchio

Perché non al 100%? Considera le note nel manuale per il relativo UPSERT esempio:

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

Spiegazione

  • Prova il SELECT prima . In questo modo eviti i notevolmente più costosi gestione delle eccezioni il 99,99% delle volte.

  • Usa un CTE per ridurre al minimo la fascia oraria (già minuscola) per la condizione di gara.

  • La finestra temporale tra SELECT e il INSERT entro una query è super piccolo. Se non hai un carico simultaneo pesante o se puoi vivere con un'eccezione una volta all'anno, puoi semplicemente ignorare il caso e utilizzare l'istruzione SQL, che è più veloce.

  • Non c'è bisogno di FETCH FIRST ROW ONLY (=LIMIT 1 ). Il nome del tag è ovviamente UNIQUE .

  • Rimuovi FOR SHARE nel mio esempio se di solito non hai DELETE simultaneo o UPDATE sul tavolo tag . Costa un pochino di prestazioni.

  • Non citare mai il nome della lingua:'plpgsql' . plpgsql è un identificatore . La citazione può causare problemi ed è tollerata solo per compatibilità con le versioni precedenti.

  • Non utilizzare nomi di colonna non descrittivi come id o name . Quando ti unisci a un paio di tavoli (che è quello che fai in un DB relazionale) ti ritrovi con più nomi identici e devi usare alias.

Integrato nella tua funzione

Usando questa funzione potresti semplificare ampiamente il tuo FOREACH LOOP a:

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

Più veloce, però, come singola istruzione SQL con unnest() :

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

Sostituisce l'intero ciclo.

Soluzione alternativa

Questa variante si basa sul comportamento di UNION ALL con un LIMIT clausola:non appena vengono trovate righe sufficienti, il resto non viene mai eseguito:

  • Come provare più SELECT finché non è disponibile un risultato?

Basandoci su questo, possiamo esternalizzare INSERT in una funzione separata. Solo lì abbiamo bisogno della gestione delle eccezioni. Sicuro come la prima soluzione.

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

Che viene utilizzato nella funzione principale:

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$;
  • Questo è un po' più economico se la maggior parte delle chiamate richiede solo SELECT , perché il blocco più costoso con INSERT contenente l'EXCEPTION la clausola viene inserita raramente. Anche la query è più semplice.

  • FOR SHARE non è possibile qui (non consentito in UNION interrogazione).

  • LIMIT 1 non sarebbe necessario (testata a pag. 9.4). Postgres deriva LIMIT 1 da INTO _tag_id e viene eseguito solo fino a quando non viene trovata la prima riga.