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 deINSERT
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 duidelijkUNIQUE
. -
Verwijder
FOR SHARE
in mijn voorbeeld als je normaal gesproken geen gelijktijdigeDELETE
hebt ofUPDATE
op de tafeltag
. 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
ofname
. 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 metINSERT
met deEXCEPTION
clausule wordt zelden ingevoerd. De vraag is ook eenvoudiger. -
FOR SHARE
is hier niet mogelijk (niet toegestaan inUNION
vraag). -
LIMIT 1
zou niet nodig zijn (getest in pg 9.4). Postgres leidtLIMIT 1
. af vanINTO _tag_id
en wordt alleen uitgevoerd totdat de eerste rij is gevonden.