Pengembangan Web

Unique Constraint di MariaDB - Biarkan Request Bersamaan Bersaing di Database

Unique Constraint di MariaDB - Biarkan Request Bersamaan Bersaing di Database

Dua request pendaftaran meminta username yang sama pada waktu yang hampir bersamaan. Masing-masing request memeriksa database, dan keduanya melihat bahwa nama tersebut tampaknya masih tersedia. Jika keduanya kemudian diizinkan memasukkan row, layer mana yang memastikan database pada akhirnya hanya memiliki satu pemilik?

Pemeriksaan ketersediaan di level aplikasi dapat memberikan pesan awal yang membantu, tetapi pemeriksaan itu hanya mengamati satu momen. Request lain dapat menulis setelah pemeriksaan tersebut dan sebelum proses insert. Key UNIQUE memindahkan keputusan terakhir ke tempat kedua write harus bertemu: database. Janji yang berguna ini memiliki batas yang sempit. MariaDB dapat menolak nilai atau kombinasi tersimpan yang sudah ada dalam unique index; MariaDB tidak dapat memutuskan apakah business rule-nya sudah dimodelkan dengan benar.

Artikel ini berfokus pada batas tersebut dalam aplikasi PHP kecil yang memakai MariaDB. Pembahasan ini tidak menggantikan desain transaction secara umum, dan tidak mengklaim bahwa request yang lebih dahulu tiba di web server pasti menang.

Mulai dengan menyebutkan aturan satu pemilik

Unique key seharusnya menyatakan sebuah invariant, bukan sekadar mempercepat query. Contohnya adalah “satu akun per username”, “satu penerimaan webhook per provider dan delivery ID”, atau “satu category slug di dalam setiap situs”. Setiap kalimat menyebutkan nilai sekaligus cakupannya.

Untuk username yang unik secara global, tabel dapat mendeklarasikan key dengan nama yang jelas:

CREATE TABLE users (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  username VARCHAR(80) NOT NULL,
  display_name VARCHAR(120) NOT NULL,
  PRIMARY KEY (id),
  CONSTRAINT uq_users_username UNIQUE (username)
) ENGINE=InnoDB;

Primary key mengidentifikasi setiap row. Unique key tambahan melindungi aturan yang berbeda: dua row tidak boleh menyimpan username yang dianggap sama oleh collation kolom tersebut. Memberi nama pada key membuat pemeriksaan schema dan diagnostic lebih mudah dipahami, meskipun perilaku aplikasi tidak seharusnya bergantung pada parsing pesan error yang ditujukan untuk manusia.

Jika username yang sama boleh ada di situs atau tenant yang berbeda, uniqueness global akan terlalu ketat. Composite key dapat menyatakan cakupan yang lebih kecil:

CONSTRAINT uq_users_site_username UNIQUE (site_id, username)

Aturan ini tidak membuat site_id unik atau username unik secara terpisah. Aturan ini membuat pasangan lengkapnya unik. Perbedaan tersebut sering menentukan apakah constraint menggambarkan domain dengan benar atau justru menolak data yang valid.

Pemeriksaan ketersediaan tetap berguna, tetapi bukan penentu terakhir

Form dapat memeriksa apakah sebuah username tampaknya tersedia sebelum dikirim. Ini memperbaiki feedback dan dapat mencegah percobaan yang sudah jelas akan gagal. Namun, sebuah read saja tidak dapat mencadangkan nilai tersebut.

Bayangkan dua request yang independen:

  1. Request A mencari river dan tidak menemukan row.
  2. Request B mencari river dan juga tidak menemukan row.
  3. Keduanya mencoba melakukan insert.
  4. MariaDB menerima satu nilai tersimpan dan menolak write yang berkonflik melalui unique key.

Urutan eksekusi yang tepat adalah detail database dan workload, bukan antrean adil yang dijanjikan kepada aplikasi. Constraint memberikan hasil akhir: kedua nilai yang berkonflik tidak menjadi row yang sama-sama committed. Karena itu, aplikasi seharusnya menganggap “tersedia” sebagai status sementara sampai write berhasil.

Mencoba menutup celah hanya dengan pemeriksaan aplikasi yang lebih luas dapat menambah kerumitan locking dan transaction, tetapi masih bisa melewatkan writer lain yang tidak mengikuti code path yang sama. Schema constraint mencakup handler PHP, import, maintenance script, dan aplikasi masa depan yang memakai tabel yang sama.

Tentukan arti “nilai yang sama”

Kata duplikat terdengar jelas sampai string dan nilai yang tidak ada ikut terlibat. Database menerapkan aturan perbandingan yang konkret, dan aturan tersebut perlu sesuai dengan invariant.

NULL bukan slot kosong yang dicadangkan

Dokumentasi CREATE TABLE MariaDB menyatakan bahwa unique key mengizinkan beberapa row bernilai NULL pada kolom nullable. Jika setiap akun wajib memiliki username unik, UNIQUE karena itu perlu dipasangkan dengan NOT NULL. Tanpanya, beberapa row dapat secara valid menyatakan bahwa tidak ada nilai username.

Perilaku ini dapat berguna ketika identifier opsional memang tidak ada, tetapi model tersebut harus disengaja. Empty string, string yang hanya berisi whitespace, dan NULL juga merupakan input yang berbeda. Validasi aplikasi dapat menolak atau menormalisasi sebagian di antaranya sebelum database mengevaluasi uniqueness.

Collation ikut menentukan equality

Untuk kolom karakter, uniqueness tidak selalu berarti byte yang identik. Referensi error 1062 MariaDB menjelaskan bahwa collation kolom menentukan apakah nilai dianggap duplikat. Di bawah collation case-insensitive yang sesuai, River dan river dianggap sama. Sensitivitas aksen dan perlakuan terhadap trailing space juga dapat memengaruhi hasilnya.

Perilaku itu dapat sangat tepat untuk login name dan keliru untuk identifier lain. Jangan mengubah collation hanya untuk menghilangkan duplicate error. Tentukan dahulu perbedaan mana yang memang ingin dipertahankan oleh domain, lalu pilih dan uji character set, collation, validasi, serta aturan normalisasi secara bersama-sama. Mengubah aturan perbandingan pada tabel yang sudah berjalan dapat mengungkap collision yang sebelumnya diizinkan.

Biarkan insert memutuskan, lalu terjemahkan konflik yang diharapkan

MariaDB melaporkan duplicate unique atau primary key sebagai error 1062 dengan SQLSTATE 23000. Exception mode PDO menjadi default sejak PHP 8.0, menurut PHP manual. Handler kecil dapat mencoba insert dan hanya menerjemahkan kondisi duplicate yang sudah dikenal:

<?php
$insert = $pdo->prepare(
    'INSERT INTO users (username, display_name)
     VALUES (:username, :display_name)'
);

try {
    $insert->execute([
        'username' => $validatedUsername,
        'display_name' => $validatedDisplayName,
    ]);
} catch (PDOException $error) {
    $sqlState = (string) $error->getCode();
    $driverCode = (int) ($error->errorInfo[1] ?? 0);

    if ($sqlState === '23000' && $driverCode === 1062) {
        throw new DomainException('That username is no longer available.');
    }

    throw $error;
}

Pemeriksaan ganda tersebut penting. PDO menstandarkan SQLSTATE, tetapi 23000 adalah kelas integrity constraint, bukan kode khusus duplicate. Dokumentasi PDOException menyediakan informasi khusus driver melalui errorInfo; kode duplicate MariaDB yang sempit adalah 1062. Mencocokkan teks pesan database lebih rapuh dan dapat membocorkan nilai atau nama index jika pesan mentah sampai ke response publik.

Contoh tersebut bersifat ilustratif. Endpoint nyata tetap memerlukan validasi input, authorization, perlindungan CSRF jika relevan, error logging yang aman, serta response HTTP atau form yang sesuai. Jika beberapa unique key dapat gagal, error 1062 saja mungkin tidak menunjukkan field domain mana yang berkonflik. Aplikasi mungkin memerlukan pemetaan terbatas dari metadata key yang dikenal, read lanjutan yang menghormati aturan disclosure, atau response konflik yang generik. Aplikasi tidak seharusnya memberi tahu pengunjung yang belum terautentikasi bahwa alamat email sensitif sudah memiliki akun kecuali disclosure tersebut memang disengaja.

Pilih semantik duplicate, jangan sekadar menyembunyikan error

Duplicate tidak selalu berarti business operation gagal. MariaDB mendokumentasikan beberapa alternatif, tetapi maknanya berbeda:

  • INSERT biasa: menolak write yang berkonflik. Ini adalah default yang jelas ketika pengguna harus memilih nilai lain.
  • INSERT IGNORE: melewati row duplicate dan melaporkan warning. Ini dapat sesuai untuk kebijakan bulk import yang dirancang dengan sadar, tetapi juga dapat membuat caller mengira pekerjaan telah dilakukan ketika sebuah row sebenarnya dilewati.
  • INSERT ... ON DUPLICATE KEY UPDATE: mengubah duplicate menjadi update. Ini hanya berguna ketika row yang sudah ada memang merupakan target update dan caller berwenang mengubahnya.
  • REPLACE: menghapus row yang berkonflik lalu memasukkan row baru. Semantik delete lalu insert dapat memengaruhi identifier, foreign key, dan trigger, sehingga kata “replace” yang familier tidak boleh disamakan dengan update biasa.

Dokumentasi resmi ON DUPLICATE KEY UPDATE menambahkan peringatan penting: jika lebih dari satu unique index cocok, hanya index pertama yang di-update, dan MariaDB tidak merekomendasikan statement ini untuk tabel dengan lebih dari satu unique index. Karena itu, upsert adalah business policy, bukan cara universal untuk menghindari exception handling.

Tambahkan aturan ke data lama dengan hati-hati

Tabel baru dimulai dalam keadaan kosong; tabel yang sudah lama digunakan mungkin telah bertentangan dengan invariant yang diusulkan. Audit tabel menggunakan definisi kolom dan collation yang sama dengan key yang akan dipasang. Untuk kolom email nullable, query diagnostic awal dapat berbentuk:

SELECT email, COUNT(*) AS row_count
FROM users
WHERE email IS NOT NULL
GROUP BY email
HAVING COUNT(*) > 1;

Jalankan query semacam ini hanya dalam konteks administratif yang terlindungi karena hasilnya memuat data akun. Hasil yang tidak kosong bukan izin untuk menghapus row secara sembarang. Duplicate tersebut mungkin mewakili akun yang perlu digabung, historical import, variasi case, atau aturan yang sebelumnya tidak pernah dijanjikan oleh aplikasi. Tentukan bagaimana record akan direkonsiliasi, dialihkan, diubah namanya, atau ditinjau sebelum mengubah schema.

Setelah data model dan record sesuai, migration dapat menambahkan key:

ALTER TABLE users
  ADD CONSTRAINT uq_users_email UNIQUE (email);

Dokumentasi ALTER TABLE MariaDB menyatakan bahwa operasi ini berhenti dengan error ketika duplicate sudah ada. Dokumentasi tersebut juga menjelaskan ALTER ... IGNORE, yang dapat membuang duplicate tanpa kendali atas row mana yang bertahan. Itu bukan mekanisme cleanup yang baik ketika setiap row memiliki arti.

Perlakukan migration sebagai perubahan operasional. ALTER TABLE dapat menunggu metadata lock, dan algorithm serta lock level yang tersedia bergantung pada versi MariaDB, storage engine, dan operasi yang tepat. Periksa versi yang terpasang, uji dengan data yang representatif, siapkan recovery plan, dan verifikasi definisi hasil dengan SHOW CREATE TABLE. Jangan menyimpulkan klaim “online” yang universal hanya dari contoh SQL singkat tersebut.

Apa yang tidak dijamin oleh unique key

Unique key kuat justru karena klaimnya terbatas. Unique key tidak:

  • memberi authorization kepada caller untuk mengklaim atau meng-update sebuah nilai;
  • membuktikan bahwa username, alamat email, atau external identifier itu asli;
  • menormalisasi Unicode, whitespace, atau alias khusus aplikasi dengan sendirinya;
  • membuat beberapa statement terkait menjadi all-or-nothing;
  • mengoordinasikan database independen atau remote service;
  • menjamin bahwa request yang dipersepsikan manusia sebagai “pertama” adalah request yang diterima MariaDB.

Transaction tetap diperlukan ketika satu operasi mengubah beberapa row secara bersama-sama. Foreign key melindungi relationship. Validasi menjelaskan input yang malformed. Authorization memutuskan siapa yang boleh bertindak. Unique key menyumbangkan satu jaminan terpisah: berdasarkan kolom dan aturan perbandingannya, stored key yang berkonflik akan ditolak.

Checklist review

  • Tulis invariant dalam satu kalimat, termasuk cakupannya.
  • Pilih single-column atau composite key yang sesuai dengan cakupan tersebut.
  • Tentukan apakah nilai yang tidak ada diperbolehkan; tambahkan NOT NULL ketika nilai wajib ada.
  • Uji contoh case, aksen, whitespace, dan normalisasi di bawah collation yang benar-benar digunakan.
  • Anggap pemeriksaan ketersediaan sebagai informasi sementara dan biarkan write menghadapi constraint.
  • Tangani SQLSTATE dan driver code MariaDB tanpa mengembalikan pesan database mentah.
  • Pilih semantik reject, ignore, update, atau replacement berdasarkan business operation.
  • Audit duplicate lama dan selesaikan dengan sengaja sebelum migration.
  • Uji percobaan bersamaan dan verifikasi jumlah row akhir, bukan hanya response dari satu request.

Kesimpulan

Race antara check dan insert tidak selesai dengan menanyakan pertanyaan yang sama kepada database secara lebih yakin. Availability query dapat membantu manusia, sedangkan unique key melindungi stored invariant ketika writer bersamaan bertemu. Salah satu write kemudian dapat menerima konflik normal yang memang diharapkan, lalu PHP menerjemahkannya ke dalam bahasa domain.

Bagian yang sulit bukan mengetik UNIQUE. Tantangannya adalah menentukan cakupan, nullability, dan aturan perbandingan yang tepat; memilih arti duplicate untuk operasi tersebut; serta merekonsiliasi data lama tanpa diam-diam merusaknya. Setelah keputusan itu dibuat secara eksplisit, constraint dapat menjalankan satu tugas kecil tetapi penting dengan andal: menolak pemilik kedua untuk key yang menurut schema hanya boleh dimiliki satu row.

References