Cegah duplikat akibat pemeriksaan-lalu-penyisipan secara bersamaan dengan batasan unik pada kunci logis artikel dan INSERT ... ON CONFLICT. Jika Anda juga memerlukan baris yang sudah ada, gunakan strategi pengambilan yang sesuai dengan tingkat isolasi transaksi Anda.

Tutorial ini ditujukan bagi pengembang yang bekerja di Terminal macOS dan memerlukan fungsi Node.js untuk membuat artikel sintetis satu kali lalu mengembalikan barisnya kepada pemanggil yang berjalan bersamaan. Contoh-contohnya mempertahankan judul artikel pertama. Contoh tersebut menggunakan dokumentasi PostgreSQL terkini dan dokumentasi resmi node-postgres.

Mengapa transaksi saja tidak mencegah duplikat

Bayangkan dua pemanggilan mencoba membuat artikel dengan slug paper-shapes. Masing-masing terlebih dahulu memeriksa apakah slug tersebut ada, lalu menyisipkan baris jika kueri tidak mengembalikan hasil. Pada tingkat isolasi Read Committed bawaan PostgreSQL, kueri biasa melihat data yang telah di-commit pada saat snapshot pernyataannya dibuat. Karena itu, kedua pemeriksaan dapat melaporkan bahwa artikel tersebut belum ada. PostgreSQL: Isolasi Transaksi

Jadwal ilustratif ini menunjukkan kondisi balapan pada tingkat aplikasi:

Langkah Pemanggilan A Pemanggilan B
1 Memeriksa paper-shapes: tidak ada
2 Memeriksa paper-shapes: tidak ada
3 Menyisipkan artikel
4 Mencoba menyisipkan slug yang sama
5 Melakukan commit Mencoba melakukan commit

Tanpa batasan unik pada slug, penyisipan tersebut dapat membuat baris terpisah. Kunci utama pada ID yang dibuat secara otomatis tidak menyatakan aturan bahwa slug harus unik. PostgreSQL mendukung batasan unik pada satu kolom maupun gabungan beberapa kolom. PostgreSQL: Batasan

Membungkus pemeriksaan dan penyisipan dengan BEGIN dan COMMIT tidak akan memesan slug yang belum ada. Menambahkan FOR UPDATE ke pencarian kosong juga tidak menyelesaikan masalah: perintah ini mengunci baris yang ditemukan, sehingga artikel yang belum ada tidak menyediakan baris untuk dikunci. Hal ini sesuai dengan perilaku penguncian baris yang didokumentasikan PostgreSQL. PostgreSQL: Penguncian Eksplisit

Tentukan dengan tepat apa yang dianggap duplikat

Untuk contoh ini, satu slug yang sama persis mengidentifikasi satu artikel. Judul adalah konten, sehingga permintaan kedua dengan slug yang sama seharusnya menerima artikel yang tersimpan, bukan mengganti judulnya.

Mulailah dengan instalasi PostgreSQL lokal yang sudah ada, alat baris perintahnya, dan Node.js beserta npm. Di Terminal macOS, buat basis data demonstrasi baru lalu buka prompt SQL-nya:

createdb article_concurrency_demo
psql -d article_concurrency_demo

Jalankan skema ini satu kali di basis data baru:

CREATE TABLE articles (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    slug text NOT NULL,
    title text NOT NULL,
    CONSTRAINT articles_slug_key UNIQUE (slug)
);

Batasan UNIQUE (slug) yang terpisah memberlakukan aturan artikel. NOT NULL juga penting: secara bawaan, batasan unik biasa mengizinkan beberapa nilai null. PostgreSQL secara otomatis membuat indeks unik untuk batasan ini. PostgreSQL: Batasan

Jika menyesuaikan tabel yang sudah ada, periksa terlebih dahulu kunci yang berulang alih-alih langsung menghapus baris. Kueri diagnostik ini menggunakan skema di atas dan tidak mengubah konten apa pun:

SELECT slug, count(*) AS row_count
FROM articles
GROUP BY slug
HAVING count(*) > 1;

Tentukan konten tersimpan yang akan dipertahankan sebelum menambahkan batasan ke tabel yang sudah ada. Tetapkan juga kunci yang diinginkan: jika dua artikel boleh memiliki slug yang sama di koleksi berbeda, aturan tersebut memerlukan kunci gabungan seperti koleksi dan slug. Selaraskan pencarian dan target konflik dengan aturan yang sama.

Pilih perilaku konflik yang diperlukan

Konflik adalah cabang normal dalam tugas ini. Tentukan maknanya sebelum memilih SQL. PostgreSQL mendukung melewati penyisipan yang berkonflik atau memperbarui baris yang sudah ada; RETURNING mengembalikan baris yang benar-benar disisipkan atau diperbarui. PostgreSQL: INSERT

Hasil yang diperlukan Pendekatan Hasil yang dikembalikan
Buat jika belum ada; jika tidak, lewati ON CONFLICT (slug) DO NOTHING Dengan RETURNING, penyisipan yang dilewati tidak mengembalikan baris
Buat jika belum ada; jika tidak, ganti judul ON CONFLICT (slug) DO UPDATE Dengan RETURNING, baris yang disisipkan atau diperbarui
Buat jika belum ada; jika tidak, pertahankan dan ambil DO NOTHING, lalu pencarian terpisah Diimplementasikan pada bagian berikutnya

Untuk operasi yang hanya menyisipkan, pernyataan SQL lengkap ini dapat digunakan pada skema demonstrasi:

INSERT INTO articles (slug, title)
VALUES ('paper-shapes', 'Paper Shapes')
ON CONFLICT (slug) DO NOTHING
RETURNING id, slug, title;

Jika mengganti judul memang merupakan perilaku yang diinginkan, gunakan pernyataan alternatif ini:

INSERT INTO articles (slug, title)
VALUES ('paper-shapes', 'Paper Shapes — Revised')
ON CONFLICT (slug) DO UPDATE
SET title = EXCLUDED.title
RETURNING id, slug, title;

Di sini, EXCLUDED.title merujuk pada judul yang diusulkan. DO UPDATE tanpa syarat memberikan hasil atomik berupa penyisipan atau pembaruan yang didokumentasikan, dengan tunduk pada kesalahan independen dan penanganan kegagalan tingkat isolasi. Pembaruan bersyarat dengan kondisi WHERE yang menolak pembaruan mungkin tidak mengembalikan apa pun. Pilih pembaruan hanya jika mengubah artikel tersimpan memang sesuai. PostgreSQL: INSERT

Implementasikan buat-atau-kembalikan-yang-sudah-ada pada Read Committed

Ada jebakan visibilitas: DO NOTHING dapat melewati penyisipan karena ada baris bersamaan yang tidak terlihat dalam snapshot pernyataan. Akibatnya, pencarian alternatif dalam pernyataan yang sama juga bisa gagal menemukannya. Kueri Read Committed yang terpisah mendapatkan snapshot baru. Inilah dasar implementasi berikut. PostgreSQL: Isolasi Transaksi

Keluar dari prompt SQL dengan \q. Di folder kerja baru, instal dependensi Node.js:

mkdir article-concurrency-demo
cd article-concurrency-demo
npm init -y
npm install pg

Simpan program lengkap berikut sebagai demo.mjs. Program ini mengasumsikan bahwa basis data dan tabel di atas sudah dibuat. Pool-nya menggunakan nama basis data serta pengaturan koneksi standar lainnya; node-postgres mendukung variabel lingkungan koneksi PostgreSQL jika pengaturan lokal berbeda. node-postgres: Menghubungkan

import pg from 'pg';
import assert from 'node:assert/strict';

const { Pool } = pg;
const pool = new Pool({
  database: 'article_concurrency_demo',
  max: 8
});

async function getOrCreateArticle(slug, title) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');

    for (let attempt = 0; attempt < 5; attempt += 1) {
      const inserted = await client.query(`
        INSERT INTO articles (slug, title)
        VALUES ($1, $2)
        ON CONFLICT (slug) DO NOTHING
        RETURNING id, slug, title
      `, [slug, title]);

      if (inserted.rows.length === 1) {
        await client.query('COMMIT');
        return inserted.rows[0];
      }

      const existing = await client.query(`
        SELECT id, slug, title
        FROM articles
        WHERE slug = $1
        FOR UPDATE
      `, [slug]);

      if (existing.rows.length === 1) {
        await client.query('COMMIT');
        return existing.rows[0];
      }
    }

    throw new Error('Article kept disappearing; retry limit reached');
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

try {
  const results = await Promise.allSettled(
    Array.from({ length: 8 }, () =>
      getOrCreateArticle('paper-shapes', 'Paper Shapes'))
  );

  const failures = results.filter(result => result.status === 'rejected');
  if (failures.length) {
    throw new AggregateError(failures.map(result => result.reason));
  }

  const rows = results.map(result => result.value);
  assert.equal(new Set(rows.map(row => row.id)).size, 1);

  const count = await pool.query(
    'SELECT count(*)::integer AS n FROM articles WHERE slug = $1',
    ['paper-shapes']
  );
  assert.equal(count.rows[0].n, 1);
  console.log({ calls: rows.length, matchingRows: count.rows[0].n });
} finally {
  await pool.end();
}

Jalankan dari folder tersebut:

node demo.mjs

Dengan penyiapan yang disebutkan dan tanpa proses lain yang mengubah baris demonstrasi, keluaran akhir yang diharapkan adalah:

{ calls: 8, matchingRows: 1 }

Sintaks JavaScript program ini telah diperiksa dengan Node.js. Demonstrasi basis data tidak dijalankan di sini karena alat PostgreSQL tidak tersedia; keluaran di atas adalah hasil yang diharapkan.

Setiap transaksi menggunakan satu klien yang dipinjam untuk semua pernyataan. node-postgres secara eksplisit mensyaratkan hal ini: menjalankan pernyataan transaksi melalui pool.query dapat mengirimkannya ke klien yang berbeda. Kueri verifikasi hanya menggunakan pool.query setelah transaksi selesai. node-postgres: Transaksi

Teks kueri dan nilainya dikirim secara terpisah menggunakan $1 dan $2, sesuai antarmuka kueri berparameter milik driver. node-postgres: Kueri

Perulangan ini menangani kemungkinan interleaving ilustratif lainnya: transaksi lain dapat menghapus artikel yang berkonflik sebelum pencarian menemukannya. Jika pencarian tidak menemukan baris, percobaan penyisipan diulang, dengan batas eksplisit lima percobaan sebagai contoh. Jika semua percobaan habis, program menghasilkan galat alih-alih baris bernilai undefined.

Setelah diambil, FOR UPDATE mencegah transaksi lain mengubah atau menghapus baris tersebut hingga transaksi ini berakhir. Perintah ini melindungi hasil yang sudah ada; batasan unik menangani penyisipan yang saling bersaing. Setelah commit, transaksi lain dapat mengubah baris lagi. Jika pekerjaan basis data yang bergantung pada artikel terkunci harus dilakukan, letakkan pekerjaan tersebut sebelum COMMIT. PostgreSQL: Penguncian Eksplisit

Pahami batasan tingkat isolasi

Contoh ini secara eksplisit memilih Read Committed. Repeatable Read menggunakan snapshot transaksi yang ditetapkan oleh pernyataan substantif pertamanya, sehingga kueri lain tidak dapat begitu saja memperbaruinya. Serializable memberikan hasil commit yang setara dengan eksekusi serial, tetapi dapat membatalkan transaksi dan masih dapat menghasilkan pelanggaran unik dalam kasus tumpang tindih yang terdokumentasi. MERGE juga tidak memiliki jaminan penyisipan atau pembaruan serentak yang sama. PostgreSQL: Isolasi Transaksi

Untuk Repeatable Read atau Serializable, siapkan diri untuk mengulangi seluruh transaksi setelah SQLSTATE 40001, termasuk keputusan aplikasi yang menghasilkan pernyataan-pernyataannya. Mengulang hanya kueri terakhir tidaklah cukup. PostgreSQL juga mengidentifikasi 40P01 sebagai kegagalan kebuntuan yang mungkin dapat dicoba ulang. PostgreSQL: Penanganan Kegagalan Serialisasi

Jangan otomatis menganggap setiap pelanggaran unik 23505 bersifat sementara. Pelanggaran itu mungkin menandakan konflik yang menetap. Kebijakan percobaan ulang seharusnya mengenali kondisi yang diharapkan, membatasi jumlah percobaan, dan melaporkan jika batas tersebut tercapai; PostgreSQL tidak menjamin bahwa percobaan ulang pada akhirnya akan berhasil. Demonstrasi ini menangani benturan slug yang diharapkan melalui ON CONFLICT, sementara galat lainnya diteruskan. PostgreSQL: Penanganan Kegagalan Serialisasi

Untuk tugas buat-atau-kembalikan ini, pembagiannya sederhana: batasan basis data menyatakan identitas artikel, tindakan konflik menyatakan apa yang harus dilakukan terhadap artikel yang sudah ada, dan transaksi mengendalikan pengambilan serta pekerjaan apa pun yang bergantung padanya. Selaraskan pilihan-pilihan tersebut, maka pemanggil yang berjalan bersamaan dapat menggunakan satu artikel tanpa diam-diam mengganti kontennya.

Sumber