Comment utiliser RETURNING avec ON CONFLICT dans PostgreSQL?

149

J'ai l'UPSERT suivant dans PostgreSQL 9.5:

INSERT INTO chats ("user", "contact", "name") 
           VALUES ($1, $2, $3), 
                  ($2, $1, NULL) 
ON CONFLICT("user", "contact") DO NOTHING
RETURNING id;

S'il n'y a pas de conflit, il renvoie quelque chose comme ceci:

----------
    | id |
----------
  1 | 50 |
----------
  2 | 51 |
----------

Mais s'il y a des conflits, il ne renvoie aucune ligne:

----------
    | id |
----------

Je veux retourner les nouvelles idcolonnes s'il n'y a pas de conflits ou renvoyer les idcolonnes existantes des colonnes en conflit.
Cela peut-il être fait? Si oui, comment?

zola
la source
1
Utilisez ON CONFLICT UPDATEpour qu'il y ait un changement dans la ligne. Puis RETURNINGle capturera.
Gordon Linoff
1
@GordonLinoff Et s'il n'y a rien à mettre à jour?
Okku
1
S'il n'y a rien à mettre à jour, cela signifie qu'il n'y a pas eu de conflit, il insère simplement les nouvelles valeurs et renvoie leur id
zola
1
Vous trouverez d'autres moyens ici . J'adorerais connaître la différence entre les deux en termes de performances.
Stanislasdrg réintègre Monica le

Réponses:

88

J'ai eu exactement le même problème, et je l'ai résolu en utilisant «faire une mise à jour» au lieu de «ne rien faire», même si je n'avais rien à mettre à jour. Dans votre cas, ce serait quelque chose comme ceci:

INSERT INTO chats ("user", "contact", "name") 
       VALUES ($1, $2, $3), 
              ($2, $1, NULL) 
ON CONFLICT("user", "contact") DO UPDATE SET name=EXCLUDED.name RETURNING id;

Cette requête renverra toutes les lignes, qu'elles viennent d'être insérées ou qu'elles aient existé auparavant.

Alextoni
la source
11
Un problème avec cette approche est que le numéro de séquence de la clé primaire est incrémenté à chaque conflit (mise à jour factice), ce qui signifie essentiellement que vous pouvez vous retrouver avec d'énormes lacunes dans la séquence. Des idées pour éviter cela?
Mischa
9
@Mischa: et alors? Les séquences ne sont jamais garanties d'être sans interruption en premier lieu et les lacunes n'ont pas d'importance (et si elles le font, une séquence n'est pas la
bonne
24
Je ne conseillerais pas de l'utiliser dans la plupart des cas. J'ai ajouté une réponse pourquoi.
Erwin Brandstetter le
4
Cette réponse ne semble pas atteindre l' DO NOTHINGaspect de la question d'origine - pour moi, elle semble mettre à jour le champ de non-conflit (ici, "nom") pour toutes les lignes.
PeterJCLaw
Comme indiqué dans la très longue réponse ci-dessous, l'utilisation de "Do Update" pour un champ qui n'a pas changé n'est pas une solution "propre" et peut entraîner d'autres problèmes.
Bill Worthington le
202

La réponse actuellement acceptée semble correcte pour une seule cible de conflit, peu de conflits, de petits tuples et aucun déclencheur. Il évite le problème de concurrence 1 (voir ci-dessous) avec la force brute. La solution simple a son attrait, les effets secondaires peuvent être moins importants.

Dans tous les autres cas, cependant, ne mettez pas à jour des lignes identiques sans nécessité. Même si vous ne voyez aucune différence en surface, il existe divers effets secondaires :

  • Il pourrait déclencher des déclencheurs qui ne devraient pas être déclenchés.

  • Il verrouille en écriture les lignes «innocentes», ce qui peut entraîner des coûts pour les transactions simultanées.

  • Cela peut donner l'impression que la ligne est nouvelle, bien qu'elle soit ancienne (horodatage de la transaction).

  • Plus important encore , avec le modèle MVCC de PostgreSQL, une nouvelle version de ligne est écrite pour chaque UPDATE, peu importe si les données de ligne ont changé. Cela entraîne une pénalité de performance pour l'UPSERT lui-même, un gonflement de table, un gonflement d'index, une pénalité de performance pour les opérations ultérieures sur la table, un VACUUMcoût. Un effet mineur pour quelques doublons, mais massif pour la plupart des dupes.

De plus , parfois, il n'est pas pratique ou même possible à utiliser ON CONFLICT DO UPDATE. Le manuel:

Pour ON CONFLICT DO UPDATE, un conflict_targetdoit être fourni.

Une seule "cible de conflit" n'est pas possible si plusieurs index / contraintes sont impliqués.

Vous pouvez obtenir (presque) la même chose sans mises à jour vides et effets secondaires. Certaines des solutions suivantes fonctionnent également avec ON CONFLICT DO NOTHING(pas de «cible de conflit»), pour capturer tous les conflits possibles qui pourraient survenir - ce qui peut être souhaitable ou non.

Sans charge d'écriture simultanée

WITH input_rows(usr, contact, name) AS (
   VALUES
      (text 'foo1', text 'bar1', text 'bob1')  -- type casts in first row
    , ('foo2', 'bar2', 'bob2')
    -- more?
   )
, ins AS (
   INSERT INTO chats (usr, contact, name) 
   SELECT * FROM input_rows
   ON CONFLICT (usr, contact) DO NOTHING
   RETURNING id  --, usr, contact              -- return more columns?
   )
SELECT 'i' AS source                           -- 'i' for 'inserted'
     , id  --, usr, contact                    -- return more columns?
FROM   ins
UNION  ALL
SELECT 's' AS source                           -- 's' for 'selected'
     , c.id  --, usr, contact                  -- return more columns?
FROM   input_rows
JOIN   chats c USING (usr, contact);           -- columns of unique index

La sourcecolonne est un ajout facultatif pour montrer comment cela fonctionne. Vous en aurez peut-être besoin pour faire la différence entre les deux cas (un autre avantage par rapport aux écritures vides).

La dernière JOIN chatsfonctionne car les lignes nouvellement insérées à partir d'un CTE de modification de données attaché ne sont pas encore visibles dans la table sous-jacente. (Toutes les parties de la même instruction SQL voient les mêmes instantanés des tables sous-jacentes.)

Puisque l' VALUESexpression est autonome (pas directement attachée à un INSERT), Postgres ne peut pas dériver de types de données à partir des colonnes cibles et vous devrez peut-être ajouter des transtypages de types explicites. Le manuel:

Lorsque VALUESest utilisé dans INSERT, les valeurs sont toutes automatiquement forcées au type de données de la colonne de destination correspondante. Lorsqu'il est utilisé dans d'autres contextes, il peut être nécessaire de spécifier le type de données correct. Si les entrées sont toutes des constantes littérales entre guillemets, il suffit de forcer la première pour déterminer le type supposé pour tous.

La requête elle-même (sans compter les effets secondaires) peut être un peu plus chère pour quelques dupes, en raison de la surcharge du CTE et du supplément SELECT(qui devrait être bon marché puisque l'index parfait est là par définition - une contrainte unique est implémentée avec Un index).

Peut être (beaucoup) plus rapide pour de nombreux doublons. Le coût effectif des écritures supplémentaires dépend de nombreux facteurs.

Mais il y a de toute façon moins d'effets secondaires et de coûts cachés . C'est probablement moins cher dans l'ensemble.

Les séquences attachées sont encore avancées, car les valeurs par défaut sont renseignées avant de tester les conflits.

À propos des CTE:

Avec charge d'écriture simultanée

En supposant l' READ COMMITTEDisolation de transaction par défaut . En relation:

La meilleure stratégie pour se défendre contre les conditions de course dépend des exigences exactes, du nombre et de la taille des lignes du tableau et des UPSERT, du nombre de transactions simultanées, de la probabilité de conflits, des ressources disponibles et d'autres facteurs ...

Problème de concurrence 1

Si une transaction simultanée a été écrite sur une ligne que votre transaction essaie maintenant de UPSERT, votre transaction doit attendre que l'autre se termine.

Si l'autre transaction se termine par ROLLBACK(ou toute erreur, c'est-à-dire automatique ROLLBACK), votre transaction peut se dérouler normalement. Effet secondaire possible mineur: lacunes dans les nombres séquentiels. Mais pas de lignes manquantes.

Si l'autre transaction se termine normalement (implicite ou explicite COMMIT), vous INSERTdétecterez un conflit (l' UNIQUEindex / la contrainte est absolue) et DO NOTHING, par conséquent, vous ne retournerez pas non plus la ligne. (Il ne peut pas non plus verrouiller la ligne, comme illustré dans le problème de concurrence 2 ci-dessous, car il n'est pas visible .) Le SELECTvoit le même instantané depuis le début de la requête et ne peut pas non plus retourner la ligne encore invisible.

De telles lignes sont absentes du jeu de résultats (même si elles existent dans la table sous-jacente)!

Cela peut être correct tel quel . Surtout si vous ne renvoyez pas de lignes comme dans l'exemple et que vous êtes satisfait de savoir que la ligne est là. Si cela ne suffit pas, il existe différentes façons de contourner le problème.

Vous pouvez vérifier le nombre de lignes de la sortie et répéter l'instruction si elle ne correspond pas au nombre de lignes de l'entrée. Peut être assez bon pour le cas rare. Le but est de démarrer une nouvelle requête (peut être dans la même transaction), qui verra alors les lignes nouvellement validées.

Ou vérifiez les lignes de résultats manquantes dans la même requête et écrasez celles avec l'astuce de force brute démontrée dans la réponse d'Alextoni .

WITH input_rows(usr, contact, name) AS ( ... )  -- see above
, ins AS (
   INSERT INTO chats AS c (usr, contact, name) 
   SELECT * FROM input_rows
   ON     CONFLICT (usr, contact) DO NOTHING
   RETURNING id, usr, contact                   -- we need unique columns for later join
   )
, sel AS (
   SELECT 'i'::"char" AS source                 -- 'i' for 'inserted'
        , id, usr, contact
   FROM   ins
   UNION  ALL
   SELECT 's'::"char" AS source                 -- 's' for 'selected'
        , c.id, usr, contact
   FROM   input_rows
   JOIN   chats c USING (usr, contact)
   )
, ups AS (                                      -- RARE corner case
   INSERT INTO chats AS c (usr, contact, name)  -- another UPSERT, not just UPDATE
   SELECT i.*
   FROM   input_rows i
   LEFT   JOIN sel   s USING (usr, contact)     -- columns of unique index
   WHERE  s.usr IS NULL                         -- missing!
   ON     CONFLICT (usr, contact) DO UPDATE     -- we've asked nicely the 1st time ...
   SET    name = c.name                         -- ... this time we overwrite with old value
   -- SET name = EXCLUDED.name                  -- alternatively overwrite with *new* value
   RETURNING 'u'::"char" AS source              -- 'u' for updated
           , id  --, usr, contact               -- return more columns?
   )
SELECT source, id FROM sel
UNION  ALL
TABLE  ups;

C'est comme la requête ci-dessus, mais nous ajoutons une étape de plus avec le CTE ups, avant de renvoyer l' ensemble de résultats complet . Ce dernier CTE ne fera rien la plupart du temps. Ce n'est que si des lignes manquent dans le résultat renvoyé, nous utilisons la force brute.

Encore plus de frais généraux. Plus il y a de conflits avec des lignes préexistantes, plus il est probable que cela surclassera l'approche simple.

Un effet secondaire: le 2ème UPSERT écrit les lignes dans le désordre, donc il réintroduit la possibilité de blocages (voir ci-dessous) si trois transactions ou plus écrivant sur les mêmes lignes se chevauchent. Si c'est un problème, vous avez besoin d'une solution différente - comme répéter l'ensemble de la déclaration comme mentionné ci-dessus.

Problème de concurrence 2

Si des transactions simultanées peuvent écrire dans les colonnes impliquées des lignes affectées et que vous devez vous assurer que les lignes que vous avez trouvées sont toujours là à un stade ultérieur de la même transaction, vous pouvez verrouiller les lignes existantes à moindre coût dans le CTE ins(qui autrement seraient déverrouillées) avec:

...
ON CONFLICT (usr, contact) DO UPDATE
SET name = name WHERE FALSE  -- never executed, but still locks the row
...

Et ajouter une clause de verrouillage à la SELECTainsi, commeFOR UPDATE .

Cela oblige les opérations d'écriture concurrentes à attendre la fin de la transaction, lorsque tous les verrous sont libérés. Alors soyez bref.

Plus de détails et d'explications:

Des blocages?

Protégez-vous contre les blocages en insérant des lignes dans un ordre cohérent . Voir:

Types de données et casts

Tableau existant comme modèle pour les types de données ...

Des casts de type explicite pour la première ligne de données dans l' VALUESexpression autonome peuvent être peu pratiques. Il existe des moyens de contourner cela. Vous pouvez utiliser n'importe quelle relation existante (table, vue, ...) comme modèle de ligne. La table cible est le choix évident pour le cas d'utilisation. Les données d'entrée sont automatiquement forcées aux types appropriés, comme dans la VALUESclause d'un INSERT:

WITH input_rows AS (
  (SELECT usr, contact, name FROM chats LIMIT 0)  -- only copies column names and types
   UNION ALL
   VALUES
      ('foo1', 'bar1', 'bob1')  -- no type casts here
    , ('foo2', 'bar2', 'bob2')
   )
   ...

Cela ne fonctionne pas pour certains types de données. Voir:

... et noms

Cela fonctionne également pour tous les types de données.

Lors de l'insertion dans toutes les colonnes (de début) du tableau, vous pouvez omettre les noms de colonne. En supposant que le tableau chatsde l'exemple ne comprend que les 3 colonnes utilisées dans UPSERT:

WITH input_rows AS (
   SELECT * FROM (
      VALUES
      ((NULL::chats).*)         -- copies whole row definition
      ('foo1', 'bar1', 'bob1')  -- no type casts needed
    , ('foo2', 'bar2', 'bob2')
      ) sub
   OFFSET 1
   )
   ...

A part: n'utilisez pas de mots réservés comme "user"identifiant. C'est une arme à pied chargée. Utilisez des identifiants légaux, en minuscules et sans guillemets. Je l'ai remplacé par usr.

Erwin Brandstetter
la source
2
Vous impliquez que cette méthode ne créera pas de lacunes dans les séries, mais elles sont: INSERT ... ON CONFLICT DO RIEN n'incrémente la série à chaque fois à partir de ce que je peux voir
Harmic
1
ce n'est pas si important, mais pourquoi est-ce que les séries sont incrémentées? et n'y a-t-il aucun moyen d'éviter cela?
saillant
1
@salient: comme je l'ai ajouté ci-dessus: les valeurs par défaut de la colonne sont remplies avant le test des conflits et les séquences ne sont jamais annulées, pour éviter les conflits avec les écritures simultanées.
Erwin Brandstetter
7
Incroyable. Fonctionne comme un charme et facile à comprendre une fois que vous le regardez attentivement. Je souhaite toujours ON CONFLICT SELECT...où une chose cependant :)
Roshambo
3
Incroyable. Les créateurs de Postgres semblent torturer les utilisateurs. Pourquoi ne pas tout simplement faire revenir la clause de retour toujours des valeurs, peu importe qu'il y avait des inserts ou non?
Anatoly Alekseev
16

Upsert, étant une extension de la INSERTrequête peut être défini avec deux comportements différents en cas de conflit de contraintes: DO NOTHINGou DO UPDATE.

INSERT INTO upsert_table VALUES (2, 6, 'upserted')
   ON CONFLICT DO NOTHING RETURNING *;

 id | sub_id | status
----+--------+--------
 (0 rows)

Notez également que cela RETURNINGne renvoie rien, car aucun n-uplet n'a été inséré . Désormais avec DO UPDATE, il est possible d'effectuer des opérations sur le tuple avec lequel il y a un conflit. Notez tout d'abord qu'il est important de définir une contrainte qui sera utilisée pour définir qu'il y a un conflit.

INSERT INTO upsert_table VALUES (2, 2, 'inserted')
   ON CONFLICT ON CONSTRAINT upsert_table_sub_id_key
   DO UPDATE SET status = 'upserted' RETURNING *;

 id | sub_id |  status
----+--------+----------
  2 |      2 | upserted
(1 row)
Jaumzera
la source
2
Belle façon de toujours obtenir l'identifiant de la ligne affectée et de savoir s'il s'agissait d'un insert ou d'un upsert. Juste ce dont j'avais besoin.
Moby Duck
Ceci utilise toujours le "Do Update", dont les inconvénients ont déjà été discutés.
Bill Worthington le
4

Pour les insertions d'un seul élément, j'utiliserais probablement une fusion lors du retour de l'identifiant:

WITH new_chats AS (
    INSERT INTO chats ("user", "contact", "name")
    VALUES ($1, $2, $3)
    ON CONFLICT("user", "contact") DO NOTHING
    RETURNING id
) SELECT COALESCE(
    (SELECT id FROM new_chats),
    (SELECT id FROM chats WHERE user = $1 AND contact = $2)
);
João Haas
la source
2
WITH e AS(
    INSERT INTO chats ("user", "contact", "name") 
           VALUES ($1, $2, $3), 
                  ($2, $1, NULL) 
    ON CONFLICT("user", "contact") DO NOTHING
    RETURNING id
)
SELECT * FROM e
UNION
    SELECT id FROM chats WHERE user=$1, contact=$2;

Le but principal de l'utilisation ON CONFLICT DO NOTHINGest d'éviter de lancer une erreur, mais cela ne provoquera aucun retour de ligne. Nous avons donc besoin d'un autre SELECTpour obtenir l'identifiant existant.

Dans ce SQL, s'il échoue sur les conflits, il ne retournera rien, alors le second SELECTobtiendra la ligne existante; s'il s'insère avec succès, il y aura deux mêmes enregistrements, nous devons UNIONalors fusionner le résultat.

Yu Huang
la source
Cette solution fonctionne bien et évite de faire une écriture inutile (mise à jour) dans la base de données !! Agréable!
Simon C le
0

J'ai modifié la réponse étonnante d'Erwin Brandstetter, qui n'incrémentera pas la séquence et ne verrouillera aucune ligne en écriture. Je suis relativement nouveau dans PostgreSQL, alors n'hésitez pas à me le faire savoir si vous voyez des inconvénients à cette méthode:

WITH input_rows(usr, contact, name) AS (
   VALUES
      (text 'foo1', text 'bar1', text 'bob1')  -- type casts in first row
    , ('foo2', 'bar2', 'bob2')
    -- more?
   )
, new_rows AS (
   SELECT 
     c.usr
     , c.contact
     , c.name
     , r.id IS NOT NULL as row_exists
   FROM input_rows AS r
   LEFT JOIN chats AS c ON r.usr=c.usr AND r.contact=c.contact
   )
INSERT INTO chats (usr, contact, name)
SELECT usr, contact, name
FROM new_rows
WHERE NOT row_exists
RETURNING id, usr, contact, name

Cela suppose que la table chatsa une contrainte unique sur les colonnes (usr, contact).

Mise à jour: ajout des révisions suggérées de spatar (ci-dessous). Merci!

ChoNuff
la source
1
Au lieu d' CASE WHEN r.id IS NULL THEN FALSE ELSE TRUE END AS row_existsécrire simplement r.id IS NOT NULL as row_exists. Au lieu d' WHERE row_exists=FALSEécrire simplement WHERE NOT row_exists.
spatar le