Bagaimana cara UPSERT (MERGE, INSERT ... ON DUPLICATE UPDATE) di PostgreSQL?

268

Pertanyaan yang sangat sering diajukan di sini adalah bagaimana melakukan upert, yang disebut dengan panggilan MySQL INSERT ... ON DUPLICATE UPDATEdan standar sebagai bagian dari MERGEoperasi.

Mengingat PostgreSQL tidak mendukungnya secara langsung (sebelum pg 9.5), bagaimana Anda melakukan ini? Pertimbangkan yang berikut ini:

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

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

Sekarang bayangkan bahwa Anda ingin "upsert" tuple (2, 'Joe'), (3, 'Alan')sehingga isi tabel baru akan:

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

Itulah yang dibicarakan orang ketika mendiskusikan sebuah upsert. Yang terpenting, pendekatan apa pun harus aman di hadapan beberapa transaksi yang bekerja di meja yang sama - baik dengan menggunakan penguncian eksplisit, atau mempertahankan terhadap kondisi balapan yang dihasilkan.

Topik ini dibahas secara luas di Sisipkan, tentang pembaruan duplikat di PostgreSQL? , tapi itu tentang alternatif untuk sintaks MySQL, dan itu tumbuh sedikit detail yang tidak terkait dari waktu ke waktu. Saya sedang mengerjakan jawaban yang pasti.

Teknik-teknik ini juga berguna untuk "masukkan jika tidak ada, jika tidak lakukan apa-apa", yaitu "masukkan ... pada kunci duplikat abaikan".

Craig Ringer
sumber
1
kemungkinan duplikat Sisipan, pada pembaruan duplikat di PostgreSQL?
Michael Hampton
8
@MichaelHampton tujuannya di sini adalah untuk membuat versi definitif yang tidak dikacaukan oleh beberapa jawaban yang sudah ketinggalan zaman - dan terkunci, sehingga tidak ada yang bisa melakukan apa-apa tentang itu. Saya tidak setuju dengan closevote.
Craig Ringer
Mengapa, maka ini akan segera menjadi usang - dan terkunci, sehingga tidak ada yang bisa berbuat apa-apa.
Michael Hampton
2
@MichaelHampton Jika Anda khawatir, mungkin Anda bisa menandai yang Anda tautkan dan memintanya untuk dibuka sehingga dapat dibersihkan, maka kita bisa menggabungkan ini. Saya hanya muak dengan satu-satunya penutup yang jelas- as-dup untuk upsert menjadi kekacauan yang membingungkan dan salah.
Craig Ringer
1
T&J itu tidak dikunci!
Michael Hampton

Jawaban:

396

9.5 dan yang lebih baru:

PostgreSQL 9.5 dan dukungan yang lebih baru INSERT ... ON CONFLICT UPDATE(dan ON CONFLICT DO NOTHING), yaitu upert.

Perbandingan denganON DUPLICATE KEY UPDATE .

Penjelasan cepat .

Untuk penggunaan melihat manual - khususnya conflict_action klausul dalam diagram sintaks, dan teks jelas .

Berbeda dengan solusi untuk 9.4 dan lebih lama yang diberikan di bawah ini, fitur ini bekerja dengan beberapa baris yang saling bertentangan dan tidak memerlukan penguncian eksklusif atau coba lagi loop.

Komit yang menambahkan fitur ada di sini dan diskusi seputar pengembangannya ada di sini .


Jika Anda menggunakan 9.5 dan tidak perlu kompatibel-mundur Anda dapat berhenti membaca sekarang .


9.4 dan lebih lama:

PostgreSQL tidak memiliki fasilitas built-in UPSERT(atau MERGE), dan melakukannya secara efisien dalam menghadapi penggunaan bersamaan sangat sulit.

Artikel ini membahas masalah dengan detail yang bermanfaat .

Secara umum Anda harus memilih antara dua opsi:

  • Operasi memasukkan / memperbarui secara individu dalam loop coba lagi; atau
  • Mengunci tabel dan melakukan penggabungan batch

Pengulangan coba baris individual

Menggunakan masing-masing baris upert dalam loop coba adalah pilihan yang masuk akal jika Anda ingin banyak koneksi secara bersamaan mencoba melakukan sisipan.

Dokumentasi PostgreSQL berisi prosedur bermanfaat yang memungkinkan Anda melakukan ini dalam satu lingkaran di dalam basis data . Ini melindungi terhadap pembaruan yang hilang dan menyisipkan balapan, tidak seperti kebanyakan solusi naif. Ini hanya akan bekerja dalam READ COMMITTEDmode dan hanya aman jika itu adalah satu-satunya hal yang Anda lakukan dalam transaksi. Fungsi tidak akan berfungsi dengan benar jika pemicu atau kunci unik sekunder menyebabkan pelanggaran unik.

Strategi ini sangat tidak efisien. Kapan pun Anda praktis, Anda harus mengantri kerja dan melakukan upert massal seperti yang dijelaskan di bawah ini.

Banyak upaya solusi untuk masalah ini gagal untuk mempertimbangkan rollback, sehingga menghasilkan pembaruan yang tidak lengkap. Dua transaksi saling bersaing; salah satunya berhasil INSERT; yang lain mendapat kesalahan kunci duplikat dan melakukan UPDATEsebaliknya. The UPDATEblok menunggu INSERTuntuk rollback atau melakukan. Ketika bergulir kembali, UPDATEkondisi pemeriksaan ulang cocok dengan baris nol, jadi meskipun UPDATEkomit tidak benar-benar melakukan upert yang Anda harapkan. Anda harus memeriksa jumlah baris hasil dan mencoba kembali jika perlu.

Beberapa solusi yang dicoba juga gagal untuk mempertimbangkan ras SELECT. Jika Anda mencoba yang jelas dan sederhana:

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

maka ketika dua dijalankan sekaligus ada beberapa mode kegagalan. Salah satunya adalah masalah yang sudah dibahas dengan pembaruan ulang. Lain adalah di mana keduanya UPDATEpada saat yang sama, mencocokkan nol baris dan melanjutkan. Kemudian mereka berdua melakukan EXISTStes, yang terjadi sebelum itu INSERT. Keduanya mendapatkan nol baris, jadi keduanya melakukan INSERT. Satu gagal dengan kesalahan kunci duplikat.

Inilah sebabnya mengapa Anda perlu mencoba ulang lingkaran. Anda mungkin berpikir bahwa Anda dapat mencegah kesalahan kunci duplikat atau kehilangan pembaruan dengan SQL pintar, tetapi Anda tidak bisa. Anda perlu memeriksa jumlah baris atau menangani kesalahan kunci duplikat (tergantung pada pendekatan yang dipilih) dan coba lagi.

Tolong jangan roll solusi Anda sendiri untuk ini. Seperti halnya antrian pesan, itu mungkin salah.

Upert massal dengan kunci

Kadang-kadang Anda ingin melakukan bulk upsert, di mana Anda memiliki kumpulan data baru yang ingin Anda gabungkan ke dalam kumpulan data lama yang sudah ada. Ini jauh lebih efisien daripada baris individual dan harus lebih disukai jika praktis.

Dalam hal ini, Anda biasanya mengikuti proses berikut:

  • CREATEsebuah TEMPORARYmeja

  • COPY atau massal-memasukkan data baru ke tabel temp

  • LOCKtabel target IN EXCLUSIVE MODE. Ini memungkinkan transaksi lain untuk SELECT, tetapi tidak membuat perubahan apa pun pada tabel.

  • Lakukan UPDATE ... FROMcatatan yang ada menggunakan nilai-nilai dalam tabel temp;

  • Lakukan INSERTbaris yang belum ada di tabel target;

  • COMMIT, melepaskan kunci.

Misalnya, untuk contoh yang diberikan dalam pertanyaan, menggunakan multi-nilai INSERTuntuk mengisi tabel temp:

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;

Bacaan terkait

Bagaimana dengan MERGE?

SQL-standar MERGEsebenarnya memiliki semantik konkurensi yang tidak jelas dan tidak cocok untuk memasang tanpa mengunci meja terlebih dahulu.

Ini adalah pernyataan OLAP yang sangat berguna untuk penggabungan data, tetapi sebenarnya bukan solusi yang berguna untuk uperturrency yang aman. Ada banyak saran untuk orang-orang yang menggunakan DBMS lain untuk digunakan MERGEuntuk upert, tetapi sebenarnya itu salah.

DB lain:

Craig Ringer
sumber
Dalam bulk upsert, apakah ada nilai yang mungkin dihapus dari yang baru daripada memfilter INSERT? Misalnya DENGAN upd AS (PEMBARUAN ... MENGEMBALIKAN newvals.id) HAPUS DARI pendatang baru MENGGUNAKAN updERE WHERE newvals.id = upd.id, diikuti dengan tulisan INSERT INTO testtable SELECT * FROM newvals? Ide saya dengan ini: alih-alih memfilter dua kali dalam INSERT (untuk JOIN / WHERE dan untuk batasan unik), gunakan kembali hasil pemeriksaan keberadaan dari UPDATE, yang sudah dalam RAM, dan mungkin jauh lebih kecil. Ini mungkin menang jika beberapa baris yang cocok dan / atau yang baru jauh lebih kecil dari yang dapat diuji.
Gunnlaugur Briem
1
Masih ada masalah yang belum terselesaikan dan untuk vendor lain tidak jelas apa yang berhasil dan apa yang tidak. 1. Solusi pengulangan Postgres seperti disebutkan tidak berfungsi jika ada beberapa kunci unik. 2. Kunci duplikat on untuk mysql juga tidak berfungsi untuk beberapa kunci unik. 3. Apakah solusi lain untuk MySQL, SQL Server dan Oracle yang diposting di atas berfungsi? Apakah pengecualian mungkin dalam kasus-kasus itu dan apakah kita harus mengulang?
dan b
@danb Ini hanya tentang PostgreSQL. Tidak ada solusi lintas-vendor. Solusi untuk PostgreSQL tidak berfungsi untuk banyak baris, sayangnya Anda harus melakukan satu transaksi per baris. "Solusi" yang digunakan MERGEuntuk SQL Server dan Oracle tidak benar dan rentan terhadap kondisi balapan, seperti disebutkan di atas. Anda harus melihat ke dalam setiap DBMS secara khusus untuk mengetahui cara menanganinya, saya benar-benar hanya dapat memberikan saran tentang PostgreSQL. Satu-satunya cara untuk melakukan upsert multi-baris yang aman pada PostgreSQL adalah jika dukungan untuk native upsert ditambahkan ke server inti.
Craig Ringer
Bahkan untuk PostGresQL solusinya tidak berfungsi dalam kasus di mana tabel memiliki beberapa kunci unik (hanya memperbarui satu baris). Dalam hal ini Anda perlu menentukan kunci mana yang sedang diperbarui. Mungkin ada solusi lintas-vendor menggunakan jdbc misalnya.
dan b
2
Postgres sekarang mendukung UPSERT - git.postgresql.org/gitweb/…
Chris
32

Saya mencoba berkontribusi dengan solusi lain untuk masalah penyisipan tunggal dengan versi pre-9.5 PostgreSQL. Idenya adalah hanya untuk mencoba melakukan penyisipan terlebih dahulu, dan jika ada catatan, untuk memperbaruinya:

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

Perhatikan bahwa solusi ini hanya dapat diterapkan jika tidak ada penghapusan baris tabel .

Saya tidak tahu tentang efisiensi solusi ini, tetapi menurut saya cukup masuk akal.

Renzo
sumber
3
Terima kasih, itulah tepatnya yang saya cari. Tidak dapat mengerti mengapa itu sangat sulit ditemukan.
isapir
4
Ya. Penyederhanaan ini berfungsi jika dan hanya jika tidak ada penghapusan.
Craig Ringer
@CraigRinger Bisakah Anda menjelaskan apa yang sebenarnya akan terjadi jika ada penghapusan?
Turbanoff
@turbanoff Sisipan dapat gagal karena catatan sudah ada di sana, lalu dihapus secara bersamaan, dan pembaruan kemudian memengaruhi baris nol karena baris telah dihapus.
Craig Ringer
@CraigRinger Jadi. Penghapusan terjadi secara bersamaan . Apa yang mungkin outways jika ini adalah bekerja dengan baik? Jika penghapusan bekerja bersamaan - maka itu dapat dieksekusi tepat setelah blok kami. Apa yang saya coba katakan - jika kita memiliki penghapusan bersamaan - maka kode ini bekerja dengan cara yang samainsert on update
turbanoff
30

Berikut adalah beberapa contoh untuk insert ... on conflict ...( hal 9.5+ ):

  • Sisipkan, saat konflik - jangan lakukan apa pun .
    insert into dummy(id, name, size) values(1, 'new_name', 3)
    on conflict do nothing;`  
  • Sisipkan, saat konflik - perbarui , tentukan target konflik melalui kolom .
    insert into dummy(id, name, size) values(1, 'new_name', 3)
    on conflict(id)
    do update set name = 'new_name', size = 3;  
  • Sisipkan, saat konflik - perbarui , tentukan target konflik melalui nama kendala .
    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;
Eric Wang
sumber
jawaban yang bagus - pertanyaan: mengapa atau dalam situasi apa seseorang harus menggunakan spesifikasi target melalui kolom atau nama kendala? Apakah ada keuntungan / kerugian untuk berbagai kasus penggunaan?
Nathan Benton
1
@ NathanBenton Saya pikir setidaknya ada 2 perbedaan: (1) nama kolom ditentukan oleh programmer, sedangkan nama kendala mungkin ditentukan oleh programmer, atau dihasilkan oleh database sesuai dengan nama tabel / kolom. (2) setiap kolom mungkin memiliki beberapa kendala. Yang mengatakan, itu tergantung pada kasus Anda untuk memilih mana yang akan digunakan.
Eric Wang
8

Upayakan SQLAlchemy untuk Postgres> = 9.5

Karena postingan besar di atas mencakup banyak pendekatan SQL yang berbeda untuk versi Postgres (tidak hanya non-9.5 seperti pada pertanyaan), saya ingin menambahkan cara melakukannya dalam SQLAlchemy jika Anda menggunakan Postgres 9.5. Alih-alih mengimplementasikan upsert Anda sendiri, Anda juga dapat menggunakan fungsi SQLAlchemy (yang ditambahkan dalam SQLAlchemy 1.1). Secara pribadi, saya akan merekomendasikan menggunakan ini, jika memungkinkan. Bukan hanya karena kenyamanan, tetapi juga karena memungkinkan PostgreSQL menangani semua kondisi lomba yang mungkin terjadi.

Posting silang dari jawaban lain yang saya berikan kemarin ( https://stackoverflow.com/a/44395983/2156909 )

SQLAlchemy mendukung ON CONFLICTsekarang dengan dua metode on_conflict_do_update()danon_conflict_do_nothing() :

Menyalin dari dokumentasi:

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

PR
sumber
4
Python dan SQLAlchemy tidak disebutkan dalam pertanyaan.
Alexander Emelianov
Saya sering menggunakan Python dalam solusi yang saya tulis. Tapi saya belum melihat ke SQLAlchemy (atau menyadarinya). Ini sepertinya pilihan yang elegan. Terima kasih. Jika check out, saya akan menyajikan ini ke organisasi saya.
Robert
3
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

Diuji pada Postgresql 9.3

aristar
sumber
@CraigRinger: bisakah Anda menguraikan ini? bukankah cte atomic?
parisni
2
@parisni No. Setiap istilah CTE mendapatkan snapshot sendiri jika melakukan penulisan. Juga tidak ada semacam penguncian predikat dilakukan pada baris yang tidak ditemukan sehingga mereka masih dapat dibuat bersamaan dengan sesi lain. Jika Anda menggunakan SERIALIZABLEisolasi Anda akan dibatalkan dengan kegagalan serialisasi, jika tidak, Anda mungkin akan mendapatkan pelanggaran unik. Jangan menemukan kembali upert, reinvention akan salah. Gunakan INSERT ... ON CONFLICT .... Jika PostgreSQL Anda terlalu lama, perbarui.
Craig Ringer
@CraigRinger INSERT ... ON CLONFLICT ...tidak dimaksudkan untuk memuat massal. Dari pos Anda, LOCK TABLE testtable IN EXCLUSIVE MODE;dalam CTE adalah solusi untuk mendapatkan hal-hal atom. Tidak ?
parisni
@parisni Ini tidak dimaksudkan untuk memuat massal? Kata siapa? postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT . Tentu, ini jauh lebih lambat daripada pemuatan massal tanpa perilaku mirip-upert, tapi itu jelas dan akan menjadi kasus tidak peduli apa yang Anda lakukan. Itu jauh lebih cepat daripada menggunakan subtransaksi, itu sudah pasti. Pendekatan tercepat adalah mengunci tabel target kemudian melakukan insert ... where not exists ...atau serupa, tentu saja.
Craig Ringer
1

Karena pertanyaan ini sudah ditutup, saya memposting di sini untuk bagaimana Anda melakukannya menggunakan SQLAlchemy. Melalui rekursi, ia mencoba kembali memasukkan atau memperbarui massal untuk memerangi kondisi balapan dan kesalahan validasi.

Pertama impor

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

Sekarang fungsi pembantu pasangan

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

Dan akhirnya fungsi upert

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]))

Begini cara Anda menggunakannya

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

Keuntungan dari ini bulk_save_objectsadalah bahwa ia dapat menangani hubungan, pengecekan kesalahan, dll saat penyisipan (tidak seperti operasi massal ).

Ruben
sumber
Itu juga terlihat salah bagi saya. Bagaimana jika sesi bersamaan menyisipkan baris setelah Anda mengumpulkan daftar ID Anda? Atau menghapus satu?
Craig Ringer
good point @CraigRinger Saya melakukan sesuatu yang mirip dengan ini tetapi hanya memiliki 1 sesi melakukan pekerjaan. Apa cara terbaik untuk menangani beberapa sesi? Suatu transaksi mungkin?
reubano
Transaksi bukanlah solusi ajaib untuk semua masalah konkurensi. Anda dapat menggunakan SERIALIZABLE transaksi dan menangani kegagalan serialisasi tetapi lambat. Anda perlu penanganan kesalahan dan coba lagi loop. Lihat jawaban saya dan bagian "bacaan terkait" di dalamnya.
Craig Ringer
@CraigRinger mengerti. Saya sebenarnya menerapkan coba lagi dalam kasus saya sendiri karena kegagalan validasi lainnya. Saya akan memperbarui jawaban ini sesuai.
reubano