sql >> Database >  >> RDS >> PostgreSQL

Is SELECT of INSERT in een functie die vatbaar is voor race-omstandigheden?

Het is het terugkerende probleem van SELECT of INSERT onder mogelijke gelijktijdige schrijfbelasting, gerelateerd aan (maar verschillend van) UPSERT (dat is INSERT of UPDATE ).

Deze PL/pgSQL-functie gebruikt UPSERT (INSERT ... ON CONFLICT .. DO UPDATE ) naar INSERT of SELECT een enkele rij :

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

Er is nog een klein venster voor een raceconditie. Om absoluut zeker te zijn we krijgen een 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<>viool hier

Dit blijft herhalen tot een van beide INSERT of SELECT slaagt.Bel:

SELECT f_tag_id('possibly_new_tag');

Als volgende opdrachten in dezelfde transactie vertrouwen op het bestaan ​​van de rij en het is eigenlijk mogelijk dat andere transacties deze tegelijkertijd bijwerken of verwijderen, u kunt een bestaande rij vergrendelen in de SELECT statement met FOR SHARE .
Als de rij in plaats daarvan wordt ingevoegd, is deze sowieso vergrendeld (of niet zichtbaar voor andere transacties) tot het einde van de transactie.

Begin met het algemene geval (INSERT vs SELECT ) om het sneller te maken.

Gerelateerd:

  • Id uit een voorwaardelijke INSERT halen
  • Uitgesloten rijen opnemen in RETURNING from INSERT ... ON CONFLICT

Gerelateerde (pure SQL) oplossing voor INSERT of SELECT meerdere rijen (een set) tegelijk:

  • Hoe RETURNING gebruiken met ON CONFLICT in PostgreSQL?

Wat is er mis met dit pure SQL-oplossing?

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

Niet helemaal verkeerd, maar het dicht een maas in de wet niet af, zoals @FunctorSalad heeft uitgewerkt. De functie kan een leeg resultaat opleveren als een gelijktijdige transactie hetzelfde probeert te doen. De handleiding:

Alle instructies worden uitgevoerd met dezelfde snapshot

Als een gelijktijdige transactie dezelfde nieuwe tag een moment eerder invoegt, maar nog niet is vastgelegd:

  • Het UPSERT-gedeelte wordt leeg weergegeven, na te hebben gewacht tot de gelijktijdige transactie is voltooid. (Als de gelijktijdige transactie moet worden teruggedraaid, wordt nog steeds de nieuwe tag ingevoegd en wordt een nieuwe ID geretourneerd.)

  • Het SELECT-gedeelte komt ook leeg te staan, omdat het gebaseerd is op dezelfde snapshot, waarbij de nieuwe tag van de (nog niet vastgelegde) gelijktijdige transactie niet zichtbaar is.

We krijgen niets . Niet zoals bedoeld. Dat is contra-intuïtief voor naïeve logica (en daar werd ik betrapt), maar zo werkt het MVCC-model van Postgres - het moet werken.

Gebruik dit dus niet als meerdere transacties tegelijkertijd kunnen proberen dezelfde tag in te voegen. Of lus totdat je daadwerkelijk een rij krijgt. De lus zal sowieso bijna nooit worden geactiveerd in gewone werkbelastingen.

Postgres 9.4 of ouder

Gezien deze (enigszins vereenvoudigde) tabel:

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

Een bijna 100% veilige functie om een ​​nieuwe tag in te voegen / een bestaande te selecteren, zou er zo uit kunnen zien.

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<>viool hier
Oude sqlfiddle

Waarom niet 100%? Overweeg de opmerkingen in de handleiding voor de gerelateerde UPSERT voorbeeld:

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

Uitleg

  • Probeer de SELECT eerste . Zo vermijd je de aanzienlijk duurdere uitzonderingen worden 99,99% van de tijd afgehandeld.

  • Gebruik een CTE om het (al kleine) tijdslot voor de raceconditie te minimaliseren.

  • Het tijdvenster tussen de SELECT en de INSERT binnen één zoekopdracht is super klein. Als je geen zware gelijktijdige belasting hebt, of als je één keer per jaar met een uitzondering kunt leven, kun je de zaak gewoon negeren en de SQL-instructie gebruiken, die sneller is.

  • FETCH FIRST ROW ONLY (=LIMIT 1 ). De tagnaam is duidelijk UNIQUE .

  • Verwijder FOR SHARE in mijn voorbeeld als je normaal gesproken geen gelijktijdige DELETE hebt of UPDATE op de tafel tag . Kost een klein beetje prestatie.

  • Citeer nooit de taalnaam:'plpgsql' . plpgsql is een identificatie . Citeren kan problemen veroorzaken en wordt alleen getolereerd voor achterwaartse compatibiliteit.

  • Gebruik geen niet-beschrijvende kolomnamen zoals id of name . Bij het samenvoegen van een paar tafels (wat je doet in een relationele DB) krijg je meerdere identieke namen en moet je aliassen gebruiken.

Ingebouwd in uw functie

Met deze functie kunt u uw FOREACH LOOP grotendeels vereenvoudigen naar:

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

Maar sneller als een enkele SQL-instructie met unnest() :

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

Vervangt de hele lus.

Alternatieve oplossing

Deze variant bouwt voort op het gedrag van UNION ALL met een LIMIT clausule:zodra er genoeg rijen zijn gevonden, wordt de rest nooit uitgevoerd:

  • Manier om meerdere SELECT's te proberen totdat een resultaat beschikbaar is?

Hierop voortbouwend kunnen we de INSERT . uitbesteden in een aparte functie. Alleen daar hebben we exception handling nodig. Net zo veilig als de eerste oplossing.

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

Welke wordt gebruikt in de hoofdfunctie:

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$;
  • Dit is iets goedkoper als de meeste oproepen alleen SELECT . nodig hebben , omdat het duurdere blok met INSERT met de EXCEPTION clausule wordt zelden ingevoerd. De vraag is ook eenvoudiger.

  • FOR SHARE is hier niet mogelijk (niet toegestaan ​​in UNION vraag).

  • LIMIT 1 zou niet nodig zijn (getest in pg 9.4). Postgres leidt LIMIT 1 . af van INTO _tag_id en wordt alleen uitgevoerd totdat de eerste rij is gevonden.



  1. SQL-query uitvoeren zonder resultaten weer te geven

  2. Failover-opties voor multi-cloud volledige databasecluster voor MariaDB-cluster

  3. Een gebruiker aanmaken op MySQL

  4. Oracle Sequence beginnend met 2 in plaats van 1