web-dev-qa-db-fra.com

Comment UPSERT (FUSIONNER, INSERER ... SUR UNE MISE À JOUR DOUBLE) dans PostgreSQL?

Une question très fréquemment posée ici est la suivante: comment faire une upsert? C'est ce que MySQL appelle INSERT ... ON DUPLICATE UPDATE et que la norme prend en charge dans le cadre de l'opération MERGE.

Etant donné que PostgreSQL ne le supporte pas directement (avant la page 9.5), comment procédez-vous? Considérer ce qui suit:

CREATE TABLE testtable (
    id integer PRIMARY KEY,
    somedata text NOT NULL
);

INSERT INTO testtable (id, somedata) VALUES
(1, 'fred'),
(2, 'bob');

Imaginons maintenant que vous souhaitiez "insérer" les tuples (2, 'Joe'), (3, 'Alan'), de sorte que le nouveau contenu de la table serait:

(1, 'fred'),
(2, 'Joe'),    -- Changed value of existing Tuple
(3, 'Alan')    -- Added new Tuple

C'est ce dont les gens parlent lorsqu'ils discutent d'une upsert. De manière cruciale, toute approche doit être sûre en présence de plusieurs transactions travaillant sur la même table - soit en utilisant un verrouillage explicite, soit en se défendant contre les conditions de concurrence qui en résultent.

Ce sujet est traité en détail dans Insérer, sur la mise à jour en double dans PostgreSQL? , mais il s’agit d’alternatives à la syntaxe MySQL, et il a acquis une quantité de détails sans rapport avec le temps. Je travaille sur des réponses définitives.

Ces techniques sont également utiles pour "insérer sinon, sinon ne rien faire", c'est-à-dire "insérer ... sur la clé dupliquée, ignorer".

215
Craig Ringer

9.5 et plus récent:

PostgreSQL 9.5 et les versions plus récentes supportent INSERT ... ON CONFLICT UPDATE (et ON CONFLICT DO NOTHING), c’est-à-dire upsert.

Comparaison avec ON DUPLICATE KEY UPDATE .

Explication rapide .

Pour l’utilisation, voir le manuel - plus précisément la clause conflict_action du diagramme de syntaxe, et le texte explicatif .

Contrairement aux solutions ci-dessous pour la version 9.4 et les versions antérieures, cette fonctionnalité fonctionne avec plusieurs lignes en conflit et ne nécessite pas de verrouillage exclusif ni de boucle de nouvelle tentative.

Le commit en ajoutant la fonctionnalité est ici et la discussion autour de son développement est ici .


_ {Si vous utilisez la version 9.5 et que vous n'avez pas besoin d'être rétro-compatible, vous pouvez arrêter de lire maintenant}.


9.4 et plus:

PostgreSQL n'a pas de fonction UPSERT (ou MERGE) intégrée, et le faire efficacement en cas d'utilisation simultanée est très difficile. 

Cet article décrit le problème en détail utile .

En général, vous devez choisir entre deux options:

  • Opérations d’insertion/mise à jour individuelles dans une boucle de nouvelle tentative; ou
  • Verrouillage de la table et fusion par lots

Boucle de nouvelle tentative de ligne individuelle

L'utilisation d'uerts de lignes individuelles dans une boucle de nouvelle tentative est l'option raisonnable si vous souhaitez que plusieurs connexions essaient simultanément d'effectuer des insertions. 

La documentation de PostgreSQL contient une procédure utile vous permettant de le faire en boucle dans la base de données . Il protège contre les mises à jour perdues et les courses d'insertion, contrairement à la plupart des solutions naïves. Cependant, cela ne fonctionnera qu'en mode READ COMMITTED et ne sera sécurisé que si c'est la seule chose que vous faites dans la transaction. La fonction ne fonctionnera pas correctement si des déclencheurs ou des clés uniques secondaires provoquent des violations uniques.

Cette stratégie est très inefficace. Dans la mesure du possible, vous devez mettre en file d'attente les travaux et procéder à une upsert en masse comme décrit ci-dessous.

De nombreuses tentatives de solutions à ce problème ne prennent pas en compte les restaurations et entraînent des mises à jour incomplètes. Deux transactions se font la course; l'un d'entre eux avec succès INSERTs; l'autre obtient une erreur de clé en double et fait UPDATE à la place. Les blocs UPDATE attendent que la INSERT soit annulée ou validée. Lorsqu'elle est restaurée, la nouvelle vérification de la condition UPDATE correspond à zéro ligne. Ainsi, même si la variable UPDATE est validée, elle n'a pas réellement effectué la mise à niveau attendue. Vous devez vérifier le nombre de lignes de résultats et réessayer si nécessaire.

Certaines solutions tentées échouent également à prendre en compte les races SELECT. Si vous essayez ce qui est évident et simple:

-- THIS IS WRONG. DO NOT COPY IT. It's an EXAMPLE.

BEGIN;

UPDATE testtable
SET somedata = 'blah'
WHERE id = 2;

-- Remember, this is WRONG. Do NOT COPY IT.

INSERT INTO testtable (id, somedata)
SELECT 2, 'blah'
WHERE NOT EXISTS (SELECT 1 FROM testtable WHERE testtable.id = 2);

COMMIT;

puis quand deux courent à la fois il y a plusieurs modes d'échec. L'un est le problème déjà discuté avec une nouvelle vérification de mise à jour. Une autre solution est la suivante: UPDATE au même moment, correspondant à zéro ligne et continu. Ensuite, ils font tous les deux le test EXISTS, qui se produit avant la INSERT. Les deux n'ont pas de lignes, alors les deux font INSERT. On échoue avec une erreur de clé en double.

C'est pourquoi vous avez besoin d'une nouvelle boucle. Vous penserez peut-être que vous pouvez éviter les erreurs de doublons ou les mises à jour perdues avec SQL intelligent, mais vous ne pouvez pas. Vous devez vérifier le nombre de lignes ou gérer les erreurs de clé en double (selon l'approche choisie), puis réessayer.

S'il vous plaît, ne lancez pas votre propre solution pour cela. Comme avec la file d'attente de messages, c'est probablement faux.

Vrac upsert avec serrure

Parfois, vous souhaitez effectuer une ascension en bloc, dans laquelle vous souhaitez fusionner un nouvel ensemble de données avec un ancien ensemble de données existant. Ceci est énormément plus efficace que les upserts de lignes individuelles et doit être préféré chaque fois que cela est possible.

Dans ce cas, vous suivez généralement le processus suivant:

  • CREATE une table TEMPORARY

  • COPY ou en bloc-insérer les nouvelles données dans la table temporaire

  • LOCK la table cible IN EXCLUSIVE MODE. Cela permet à d'autres transactions de SELECT, mais n'apporte aucune modification à la table.

  • Faites un UPDATE ... FROM d'enregistrements existants en utilisant les valeurs de la table temporaire; 

  • Faites une INSERT de lignes qui n'existent pas déjà dans la table cible;

  • COMMIT, libérant le verrou.

Par exemple, pour l'exemple donné dans la question, utilisez INSERT à plusieurs valeurs pour renseigner la table temporaire:

BEGIN;

CREATE TEMPORARY TABLE newvals(id integer, somedata text);

INSERT INTO newvals(id, somedata) VALUES (2, 'Joe'), (3, 'Alan');

LOCK TABLE testtable IN EXCLUSIVE MODE;

UPDATE testtable
SET somedata = newvals.somedata
FROM newvals
WHERE newvals.id = testtable.id;

INSERT INTO testtable
SELECT newvals.id, newvals.somedata
FROM newvals
LEFT OUTER JOIN testtable ON (testtable.id = newvals.id)
WHERE testtable.id IS NULL;

COMMIT;

Lecture connexe

Qu'en est-il de MERGE?

Le standard SQL MERGE a en fait une sémantique de concurrence mal définie et ne convient pas pour l'upertérisation sans verrouiller d'abord une table.

Il s'agit d'une instruction OLAP vraiment utile pour la fusion de données, mais ce n'est pas une solution utile pour l'upert sécurisé contre la concurrence. Il existe de nombreux conseils aux personnes utilisant d'autres SGBD d'utiliser MERGE pour les upserts, mais c'est en fait une erreur.

Autres DB:

345
Craig Ringer

J'essaie de contribuer avec une autre solution au problème d'insertion unique avec les versions antérieures à la version 9.5 de PostgreSQL. L’idée est simplement d’essayer d’effectuer d’abord l’insertion, puis de la mettre à jour si la fiche est déjà présente:

do $$
begin 
  insert into testtable(id, somedata) values(2,'Joe');
exception when unique_violation then
  update testtable set somedata = 'Joe' where id = 2;
end $$;

Notez que cette solution peut être appliquée uniquement s'il n'y a pas de suppression de lignes de la table .

Je ne connais pas l'efficacité de cette solution, mais elle me semble suffisamment raisonnable.

26
Renzo

Voici quelques exemples pour insert ... on conflict ... (pg 9.5+): 

  • Insérer, en cas de conflit - ne rien faire .
    insert into dummy(id, name, size) values(1, 'new_name', 3) on conflict do nothing; 

  • Insérer, en cas de conflit - do update , spécifier la cible du conflit via column .
    insert into dummy(id, name, size) values(1, 'new_name', 3) on conflict(id) do update set name = 'new_name', size = 3; 

  • Insérer, en cas de conflit - do update , spécifier la cible du conflit via nom de contrainte .
    insert into dummy(id, name, size) values(1, 'new_name', 3) on conflict on constraint dummy_pkey do update set name = 'new_name', size = 4;

7
Eric Wang
WITH UPD AS (UPDATE TEST_TABLE SET SOME_DATA = 'Joe' WHERE ID = 2 
RETURNING ID),
INS AS (SELECT '2', 'Joe' WHERE NOT EXISTS (SELECT * FROM UPD))
INSERT INTO TEST_TABLE(ID, SOME_DATA) SELECT * FROM INS

Testé sur Postgresql 9.3

3
aristar

SQLAlchemy upsert pour Postgres> = 9.5

Étant donné que le message ci-dessus couvre de nombreuses approches SQL différentes pour les versions de Postgres (pas seulement la version non-9.5 comme dans la question), j'aimerais ajouter comment procéder dans SQLAlchemy si vous utilisez Postgres 9.5. Au lieu d'implémenter votre propre upsert, vous pouvez également utiliser les fonctions de SQLAlchemy (ajoutées à SQLAlchemy 1.1). Personnellement, je recommanderais de les utiliser, si possible. Non seulement pour des raisons de commodité, mais aussi parce que cela permet à PostgreSQL de gérer toutes les conditions de concurrence pouvant survenir.

Cross-posting d'une autre réponse que j'ai donnée hier ( https://stackoverflow.com/a/44395983/2156909 )

SQLAlchemy supporte maintenant ON CONFLICT avec deux méthodes on_conflict_do_update() et on_conflict_do_nothing():

Copier de la documentation:

from sqlalchemy.dialects.postgresql import insert

stmt = insert(my_table).values(user_email='[email protected]', data='inserted data')
stmt = stmt.on_conflict_do_update(
    index_elements=[my_table.c.user_email],
    index_where=my_table.c.user_email.like('%@gmail.com'),
    set_=dict(data=stmt.excluded.data)
    )
conn.execute(stmt)

http://docs.sqlalchemy.org/en/latest/dialects/postgresql.html?highlight=conflict#insert-on-conflict-upsert

2
P.R.

Puisque cette question était fermée, je vous explique comment utiliser SQLAlchemy. Par la récursivité, il réessaie une insertion en bloc ou une mise à jour pour combattre les conditions de course et les erreurs de validation.

D'abord les importations 

import itertools as it

from functools import partial
from operator import itemgetter

from sqlalchemy.exc import IntegrityError
from app import session
from models import Posts

Maintenant, quelques fonctions d'assistance

def chunk(content, chunksize=None):
    """Groups data into chunks each with (at most) `chunksize` items.
    https://stackoverflow.com/a/22919323/408556
    """
    if chunksize:
        i = iter(content)
        generator = (list(it.islice(i, chunksize)) for _ in it.count())
    else:
        generator = iter([content])

    return it.takewhile(bool, generator)


def gen_resources(records):
    """Yields a dictionary if the record's id already exists, a row object 
    otherwise.
    """
    ids = {item[0] for item in session.query(Posts.id)}

    for record in records:
        is_row = hasattr(record, 'to_dict')

        if is_row and record.id in ids:
            # It's a row but the id already exists, so we need to convert it 
            # to a dict that updates the existing record. Since it is duplicate,
            # also yield True
            yield record.to_dict(), True
        Elif is_row:
            # It's a row and the id doesn't exist, so no conversion needed. 
            # Since it's not a duplicate, also yield False
            yield record, False
        Elif record['id'] in ids:
            # It's a dict and the id already exists, so no conversion needed. 
            # Since it is duplicate, also yield True
            yield record, True
        else:
            # It's a dict and the id doesn't exist, so we need to convert it. 
            # Since it's not a duplicate, also yield False
            yield Posts(**record), False

Et enfin la fonction upsert

def upsert(data, chunksize=None):
    for records in chunk(data, chunksize):
        resources = gen_resources(records)
        sorted_resources = sorted(resources, key=itemgetter(1))

        for dupe, group in it.groupby(sorted_resources, itemgetter(1)):
            items = [g[0] for g in group]

            if dupe:
                _upsert = partial(session.bulk_update_mappings, Posts)
            else:
                _upsert = session.add_all

            try:
                _upsert(items)
                session.commit()
            except IntegrityError:
                # A record was added or deleted after we checked, so retry
                # 
                # modify accordingly by adding additional exceptions, e.g.,
                # except (IntegrityError, ValidationError, ValueError)
                db.session.rollback()
                upsert(items)
            except Exception as e:
                # Some other error occurred so reduce chunksize to isolate the 
                # offending row(s)
                db.session.rollback()
                num_items = len(items)

                if num_items > 1:
                    upsert(items, num_items // 2)
                else:
                    print('Error adding record {}'.format(items[0]))

Voici comment vous l'utilisez

>>> data = [
...     {'id': 1, 'text': 'updated post1'}, 
...     {'id': 5, 'text': 'updated post5'}, 
...     {'id': 1000, 'text': 'new post1000'}]
... 
>>> upsert(data)

L'avantage de ceci sur bulk_save_objects est qu'il peut gérer les relations, la vérification des erreurs, etc. lors de l'insertion (contrairement à les opérations en bloc ).

0
reubano