sql >> Databasteknik >  >> RDS >> PostgreSQL

Är SELECT eller INSERT i en funktion utsatt för tävlingsförhållanden?

Det är det återkommande problemet med SELECT eller INSERT under möjlig samtidig skrivbelastning, relaterad till (men skiljer sig från) UPSERT (som är INSERT eller UPDATE ).

Den här PL/pgSQL-funktionen använder UPSERT (INSERT ... ON CONFLICT .. DO UPDATE ) för att INSERT eller SELECT en en rad :

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

Det finns fortfarande ett litet fönster för ett tävlingstillstånd. För att vara helt säker vi får ett 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<>spela här

Detta fortsätter att loopa tills antingen INSERT eller SELECT lyckas. Ring:

SELECT f_tag_id('possibly_new_tag');

Om efterföljande kommandon i samma transaktion lita på radens existens och det är faktiskt möjligt att andra transaktioner uppdaterar eller raderar den samtidigt, du kan låsa en befintlig rad i SELECT uttalande med FOR SHARE .
Om raden infogas istället är den låst (eller inte synlig för andra transaktioner) till slutet av transaktionen ändå.

Börja med det vanliga fallet (INSERT kontra SELECT ) för att göra det snabbare.

Relaterat:

  • Hämta ID från en villkorlig INSERT
  • Hur man inkluderar uteslutna rader i RETURNING from INSERT ... ON CONFLICT

Relaterad (ren SQL) lösning till INSERT eller SELECT flera rader (en uppsättning) på en gång:

  • Hur använder man RETURNING med ON CONFLICT i PostgreSQL?

Vad är det för fel med det här ren SQL-lösning?

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

Inte helt fel, men det lyckas inte täta ett kryphål, som @FunctorSalad utarbetade. Funktionen kan komma med ett tomt resultat om en samtidig transaktion försöker göra samma sak samtidigt. Manualen:

Alla satser exekveras med samma ögonblicksbild

Om en samtidig transaktion infogar samma nya tagg ett ögonblick tidigare, men inte har genomförts ännu:

  • UPSERT-delen kommer upp tom efter att ha väntat på att den samtidiga transaktionen ska avslutas. (Om den samtidiga transaktionen skulle gå tillbaka, infogar den fortfarande den nya taggen och returnerar ett nytt ID.)

  • SELECT-delen visas också tom, eftersom den är baserad på samma ögonblicksbild, där den nya taggen från den (ännu oengagerade) samtidiga transaktionen inte är synlig.

Vi får inget . Inte som tänkt. Det är kontraintuitivt mot naiv logik (och jag fastnade där), men det är så MVCC-modellen av Postgres fungerar - måste fungera.

Så använd inte detta om flera transaktioner kan försöka infoga samma tagg samtidigt. Eller slinga tills du faktiskt får en rad. Slingan kommer nästan aldrig att triggas i vanliga arbetsbelastningar ändå.

Postgres 9.4 eller äldre

Med tanke på denna (något förenklade) tabell:

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

En nästan 100 % säker funktion för att infoga ny tagg/välja befintlig, kan se ut så här.

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<>spela här
Gammal sqlfiddle

Varför inte 100%? Tänk på anmärkningarna i manualen för den relaterade UPSERT exempel:

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

Förklaring

  • Försök med SELECT först . På så sätt slipper du det avsevärt dyrare undantagshantering 99,99 % av tiden.

  • Använd en CTE för att minimera den (redan lilla) tidsluckan för tävlingsförhållandena.

  • Tidsfönstret mellan SELECT och INSERT inom en fråga är super liten. Om du inte har tung samtidig belastning, eller om du kan leva med ett undantag en gång om året, kan du bara ignorera fallet och använda SQL-satsen, som är snabbare.

  • Inget behov av FETCH FIRST ROW ONLY (=LIMIT 1 ). Taggnamnet är uppenbarligen UNIQUE .

  • Ta bort FOR SHARE i mitt exempel om du vanligtvis inte har samtidig DELETE eller UPDATE på tabellen tag . Kostar lite prestanda.

  • Citera aldrig språknamnet:'plpgsql' . plpgsql är en identifierare . Citering kan orsaka problem och tolereras endast för bakåtkompatibilitet.

  • Använd inte icke-beskrivande kolumnnamn som id eller name . När du går med i ett par bord (vilket är vad du gör i en relations-DB) får du flera identiska namn och måste använda alias.

Inbyggd i din funktion

Genom att använda den här funktionen kan du till stor del förenkla din FOREACH LOOP till:

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

Snabbare, dock som en enda SQL-sats med unnest() :

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

Ersätter hela slingan.

Alternativ lösning

Denna variant bygger på beteendet hos UNION ALL med en LIMIT klausul:så snart tillräckligt många rader hittas, exekveras resten aldrig:

  • Sätt att prova flera SELECT tills ett resultat är tillgängligt?

Med utgångspunkt i detta kan vi lägga ut INSERT på entreprenad till en separat funktion. Bara där behöver vi undantagshantering. Lika säker som den första lösningen.

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

Som används i huvudfunktionen:

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$;
  • Detta är lite billigare om de flesta samtalen bara behöver SELECT , eftersom det dyrare blocket med INSERT som innehåller EXCEPTION klausul skrivs sällan in. Frågan är också enklare.

  • FOR SHARE är inte möjligt här (inte tillåtet i UNION fråga).

  • LIMIT 1 skulle inte vara nödvändigt (testade på sidan 9.4). Postgres härleder LIMIT 1 från INTO _tag_id och körs bara tills den första raden hittas.



  1. Hur man väljer med hjälp av WITH RECURSIVE-satsen

  2. Stöder din ODBC-drivrutin användardatakällor?

  3. Hur byter man namn på ett kolumnnamn i SQL?

  4. Hur kan jag kombinera flera rader till en kommaavgränsad lista i Oracle?