Bagaimana cara menggunakan RETURNING dengan ON CONFLICT di PostgreSQL?

149

Saya memiliki UPSERT berikut di PostgreSQL 9.5:

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

Jika tidak ada konflik, ia mengembalikan sesuatu seperti ini:

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

Tetapi jika ada konflik itu tidak mengembalikan baris:

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

Saya ingin mengembalikan idkolom baru jika tidak ada konflik atau mengembalikan idkolom yang ada dari kolom yang bertentangan.
Bisakah ini dilakukan? Jika ya, bagaimana caranya?

zola
sumber
1
Gunakan ON CONFLICT UPDATEsehingga ada perubahan pada baris. Maka RETURNINGakan menangkapnya.
Gordon Linoff
1
@GordonLinoff Bagaimana jika tidak ada yang diperbarui?
Okku
1
Jika tidak ada yang diperbarui, itu berarti tidak ada konflik sehingga hanya memasukkan nilai-nilai baru dan mengembalikan id mereka
zola
1
Anda akan menemukan cara lain di sini . Saya ingin tahu perbedaan antara keduanya dalam hal kinerja.
Stanislasdrg Reinstate Monica

Jawaban:

88

Saya memiliki masalah yang persis sama, dan saya menyelesaikannya menggunakan 'do update' bukannya 'do nothing', meskipun saya tidak ada pembaruan. Dalam kasus Anda itu akan menjadi seperti ini:

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;

Kueri ini akan mengembalikan semua baris, terlepas dari apakah mereka baru saja dimasukkan atau sudah ada sebelumnya.

Alextoni
sumber
11
Satu masalah dengan pendekatan ini adalah, bahwa nomor urut kunci primer bertambah pada setiap konflik (pembaruan palsu), yang pada dasarnya berarti bahwa Anda mungkin berakhir dengan kesenjangan besar dalam urutan. Adakah cara untuk menghindarinya?
Mischa
9
@Mischa: jadi apa? Urutan tidak pernah dijamin menjadi gapless di tempat pertama dan kesenjangan tidak masalah (dan jika mereka melakukannya, urutan adalah hal yang salah untuk dilakukan)
a_horse_with_no_name
24
Saya tidak akan menyarankan untuk menggunakan ini dalam banyak kasus. Saya menambahkan jawaban mengapa.
Erwin Brandstetter
4
Jawaban ini tampaknya tidak mencapai DO NOTHINGaspek pertanyaan awal - bagi saya tampaknya memperbarui bidang non-konflik (di sini, "nama") untuk semua baris.
PeterJCLaw
Seperti dibahas dalam jawaban yang sangat panjang di bawah ini, menggunakan "Lakukan Pembaruan" untuk bidang yang belum berubah bukanlah solusi "bersih" dan dapat menyebabkan masalah lain.
Bill Worthington
202

Jawaban yang saat ini diterima tampaknya ok untuk target konflik tunggal, beberapa konflik, tupel kecil dan tidak ada pemicu. Ini menghindari masalah konkurensi 1 (lihat di bawah) dengan kekerasan. Solusi sederhana memiliki daya tariknya, efek sampingnya mungkin kurang penting.

Untuk semua kasus lain, meskipun, tidak memperbarui baris identik tanpa perlu. Bahkan jika Anda tidak melihat perbedaan di permukaan, ada berbagai efek samping :

  • Mungkin memicu pemicu yang seharusnya tidak dipecat.

  • Itu menulis-mengunci baris "tidak bersalah", mungkin menimbulkan biaya untuk transaksi bersamaan.

  • Mungkin membuat baris tampak baru, meskipun sudah lama (stempel waktu transaksi).

  • Yang paling penting , dengan model MVCC PostgreSQL versi baris baru ditulis untuk setiap UPDATE, tidak peduli apakah data baris diubah. Ini menimbulkan penalti kinerja untuk UPSERT itu sendiri, tabel mengasapi, indeks mengasapi, penalti kinerja untuk operasi selanjutnya di atas meja, VACUUMbiaya. Efek kecil untuk beberapa duplikat, tetapi besar untuk sebagian besar dupes.

Plus , kadang tidak praktis atau bahkan mungkin digunakan ON CONFLICT DO UPDATE. Manual:

Untuk ON CONFLICT DO UPDATE, conflict_targetharus disediakan.

Sebuah tunggal "Target konflik" tidak mungkin jika beberapa indeks / kendala yang terlibat.

Anda dapat mencapai (hampir) sama tanpa pembaruan dan efek samping yang kosong. Beberapa solusi berikut juga bekerja dengan ON CONFLICT DO NOTHING(tidak ada "target konflik"), untuk menangkap semua kemungkinan konflik yang mungkin timbul - yang mungkin atau mungkin tidak diinginkan.

Tanpa memuat tulis bersamaan

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

The sourcekolom tambahan opsional untuk menunjukkan bagaimana ini bekerja. Anda mungkin benar-benar membutuhkannya untuk memberi tahu perbedaan antara kedua kasus (keuntungan lain dari penulisan kosong).

Final JOIN chatsberfungsi karena baris yang baru dimasukkan dari CTE pengubah data terlampir belum terlihat dalam tabel di bawahnya. (Semua bagian dari pernyataan SQL yang sama melihat snapshot yang sama dari tabel yang mendasarinya.)

Karena VALUESekspresi itu berdiri sendiri (tidak melekat langsung ke suatu INSERT) Postgres tidak dapat memperoleh tipe data dari kolom target dan Anda mungkin harus menambahkan gips tipe eksplisit. Manual:

Ketika VALUESdigunakan INSERT, nilai-nilai semua secara otomatis dipaksa ke tipe data dari kolom tujuan yang sesuai. Saat digunakan dalam konteks lain, mungkin perlu menentukan tipe data yang benar. Jika entri semua dikutip konstanta literal, paksaan yang pertama sudah cukup untuk menentukan jenis asumsi untuk semua.

Permintaan itu sendiri (tidak menghitung efek samping) mungkin sedikit lebih mahal untuk beberapa dupe, karena overhead CTE dan tambahan SELECT(yang seharusnya murah karena indeks sempurna ada menurut definisi - batasan unik diterapkan dengan sebuah indeks).

Mungkin (jauh) lebih cepat untuk banyak duplikat. Biaya efektif untuk menulis tambahan tergantung pada banyak faktor.

Tetapi ada sedikit efek samping dan biaya tersembunyi dalam kasus apa pun. Secara keseluruhan mungkin lebih murah.

Urutan terlampir masih lanjut, karena nilai default diisi sebelum pengujian untuk konflik.

Tentang CTE:

Dengan beban tulis bersamaan

Dengan asumsi READ COMMITTEDisolasi transaksi standar . Terkait:

Strategi terbaik untuk bertahan terhadap kondisi balapan tergantung pada persyaratan yang tepat, jumlah dan ukuran baris dalam tabel dan dalam UPSERT, jumlah transaksi bersamaan, kemungkinan konflik, sumber daya yang tersedia, dan faktor lainnya ...

Masalah konkurensi 1

Jika transaksi konkuren telah menulis ke baris yang sekarang coba Anda transaksikan oleh transaksi, transaksi Anda harus menunggu yang lain selesai.

Jika transaksi lain berakhir dengan ROLLBACK(atau kesalahan, yaitu otomatis ROLLBACK), transaksi Anda dapat berjalan secara normal. Kemungkinan kecil efek samping: kesenjangan dalam angka berurutan. Tapi tidak ada baris yang hilang.

Jika transaksi lain berakhir secara normal (implisit atau eksplisit COMMIT), Anda INSERTakan mendeteksi konflik ( UNIQUEindeks / kendala absolut) dan DO NOTHING, karenanya juga tidak mengembalikan baris. (Juga tidak dapat mengunci baris seperti yang ditunjukkan dalam masalah konkurensi 2 di bawah, karena tidak terlihat .) The SELECTmelihat snapshot yang sama dari awal permintaan dan juga tidak dapat mengembalikan baris yang belum terlihat.

Setiap baris seperti itu tidak ada pada set hasil (meskipun mereka ada di tabel yang mendasarinya)!

Ini mungkin baik-baik saja . Terutama jika Anda tidak mengembalikan baris seperti pada contoh dan puas mengetahui baris ada di sana. Jika itu tidak cukup baik, ada berbagai cara di sekitarnya.

Anda dapat memeriksa jumlah baris output dan mengulangi pernyataan jika tidak cocok dengan jumlah baris input. Mungkin cukup baik untuk kasus yang jarang terjadi. Intinya adalah memulai kueri baru (bisa dalam transaksi yang sama), yang kemudian akan melihat baris yang baru dikomit.

Atau periksa baris hasil yang hilang dalam kueri yang sama dan timpa yang dengan trik brute force yang ditunjukkan dalam jawaban 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;

Ini seperti kueri di atas, tetapi kami menambahkan satu langkah lagi dengan CTE ups, sebelum kami mengembalikan set hasil yang lengkap . CTE terakhir tidak akan melakukan apa-apa hampir sepanjang waktu. Hanya jika baris hilang dari hasil yang dikembalikan, kami menggunakan brute force.

Lebih banyak overhead. Semakin banyak konflik dengan baris yang sudah ada, semakin besar kemungkinan ini akan mengungguli pendekatan sederhana.

Satu efek samping: UPSERT ke-2 menulis baris yang tidak sesuai, sehingga memperkenalkan kembali kemungkinan deadlock (lihat di bawah) jika tiga atau lebih transaksi menulis ke baris yang sama tumpang tindih. Jika itu masalah, Anda memerlukan solusi yang berbeda - seperti mengulangi seluruh pernyataan seperti disebutkan di atas.

Masalah konkurensi 2

Jika transaksi bersamaan dapat menulis ke kolom yang terlibat dari baris yang terpengaruh, dan Anda harus memastikan bahwa baris yang Anda temukan masih ada pada tahap selanjutnya dalam transaksi yang sama, Anda dapat mengunci baris yang ada dengan murah di CTE ins(yang kalau tidak akan terkunci) dengan:

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

Dan menambahkan klausa penguncian ke SELECTjuga, sepertiFOR UPDATE .

Hal ini membuat operasi penulisan yang bersaing menunggu hingga akhir transaksi, ketika semua kunci dilepaskan. Jadi singkat saja.

Lebih detail dan penjelasan:

Kebuntuan?

Bertahan dari kebuntuan dengan memasukkan baris dalam urutan yang konsisten . Lihat:

Tipe data dan gips

Tabel yang ada sebagai templat untuk tipe data ...

Tipe gips yang eksplisit untuk baris pertama data dalam VALUESekspresi yang berdiri sendiri mungkin tidak nyaman. Ada beberapa cara untuk mengatasinya. Anda dapat menggunakan hubungan apa pun yang ada (tabel, tampilan, ...) sebagai templat baris. Tabel target adalah pilihan yang jelas untuk use case. Data input dipaksa untuk jenis yang sesuai secara otomatis, seperti dalam VALUESklausa dari 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')
   )
   ...

Ini tidak berfungsi untuk beberapa tipe data. Lihat:

... dan nama

Ini juga berfungsi untuk semua tipe data.

Saat menyisipkan ke semua (memimpin) kolom tabel, Anda bisa menghilangkan nama kolom. Dengan asumsi tabel chatsdalam contoh hanya terdiri dari 3 kolom yang digunakan dalam 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
   )
   ...

Selain itu: jangan gunakan kata - kata khusus seperti "user"pengidentifikasi. Itu footgun yang dimuat. Gunakan pengidentifikasi legal, huruf kecil, tanda kutip. Saya menggantinya dengan usr.

Erwin Brandstetter
sumber
2
Anda menyiratkan metode ini tidak akan membuat celah dalam serial, tetapi mereka adalah: INSERT ... ON CONFLICT DO NOTHING meningkatkan increment serial setiap kali dari apa yang saya lihat
berbahaya
1
bukankah itu penting, tapi mengapa serial-serial itu bertambah? dan adakah cara untuk menghindari ini?
menonjol
1
@salient: Seperti yang saya tambahkan di atas: nilai default kolom diisi sebelum pengujian untuk konflik dan urutan tidak pernah dibatalkan, untuk menghindari konflik dengan penulisan bersamaan.
Erwin Brandstetter
7
Luar biasa. Bekerja seperti pesona dan mudah dimengerti setelah Anda melihatnya dengan hati-hati. Saya masih berharap ON CONFLICT SELECT...ada apa :)
Roshambo
3
Luar biasa. Pencipta Postgres tampaknya menyiksa pengguna. Mengapa tidak hanya cukup membuat kembali klausul selalu kembali nilai-nilai, terlepas dari apakah ada sisipan atau tidak?
Anatoly Alekseev
16

Upsert, menjadi ekstensi dari INSERTkueri dapat didefinisikan dengan dua perilaku berbeda dalam kasus konflik kendala: DO NOTHINGatau DO UPDATE.

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

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

Perhatikan juga bahwa RETURNINGtidak mengembalikan apa-apa, karena tidak ada tupel yang dimasukkan . Sekarang dengan DO UPDATE, adalah mungkin untuk melakukan operasi pada tuple ada konflik dengan. Catatan pertama bahwa penting untuk mendefinisikan batasan yang akan digunakan untuk mendefinisikan bahwa ada konflik.

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
sumber
2
Cara yang bagus untuk selalu mendapatkan id baris yang terpengaruh, dan tahu apakah itu insert atau upsert. Apa yang saya butuhkan.
Moby Duck
Ini masih menggunakan "Lakukan Pembaruan", yang kerugiannya telah dibahas.
Bill Worthington
4

Untuk penyisipan satu item, saya mungkin akan menggunakan gabungan ketika mengembalikan id:

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

Tujuan utama penggunaan ON CONFLICT DO NOTHINGadalah untuk menghindari kesalahan melempar, tetapi tidak akan menyebabkan baris kembali. Jadi kita perlu yang lain SELECTuntuk mendapatkan id yang ada.

Dalam SQL ini, jika gagal pada konflik, tidak akan mengembalikan apa pun, maka yang kedua SELECTakan mendapatkan baris yang ada; jika berhasil dimasukkan, maka akan ada dua catatan yang sama, maka kita perlu UNIONmenggabungkan hasilnya.

Yu Huang
sumber
Solusi ini berfungsi dengan baik dan menghindari melakukan penulisan (pembaruan) yang tidak perlu pada DB !! Bagus!
Simon C
0

Saya mengubah jawaban yang luar biasa dari Erwin Brandstetter, yang tidak akan menambah urutan, dan juga tidak akan menulis-mengunci setiap baris. Saya relatif baru di PostgreSQL, jadi silakan beri tahu saya jika Anda melihat ada kekurangan pada metode ini:

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

Ini mengasumsikan bahwa tabel chats memiliki batasan unik pada kolom (usr, contact).

Pembaruan: menambahkan revisi yang disarankan dari spatar (di bawah). Terima kasih!

ChoNuff
sumber
1
Alih-alih CASE WHEN r.id IS NULL THEN FALSE ELSE TRUE END AS row_existshanya menulis r.id IS NOT NULL as row_exists. Alih-alih WHERE row_exists=FALSEhanya menulis WHERE NOT row_exists.
spatar