Nomor Urut Kembar di Laravel: Kenapa Rule::unique Tidak Cukup dan Cara Membuatnya Aman dengan lockForUpdate
- Authors

- Name
- Samsul Hadi
- Threads
- @Threads


Dua pemeriksa lapangan bekerja di gedung yang sama. Masing-masing membuka aplikasi untuk mencatat temuan baru, dan aplikasi memberi nomor urut otomatis: M05. Keduanya mendapat M05. Keduanya menekan simpan. Yang satu berhasil, yang lain melihat layar error tanpa penjelasan dan harus mengulang dari awal.
Ini terjadi di aplikasi inspeksi berbasis Laravel di salah satu proyek klien yang saya pegang. Nomor temuan harus unik per gedung dan per lingkup pemeriksaan, karena nomor itu tercetak di laporan resmi. Kodenya sudah punya Rule::unique. Database-nya punya unique index. Kodenya bahkan sudah memakai lockForUpdate. Nomornya tetap bisa kembar.
Setelah saya telusuri, ada dua bug yang saling menutupi. Yang pertama soal urutan waktu: nomor dibuat jauh sebelum disimpan. Yang kedua soal cara menghitung nomor berikutnya. Artikel ini membahas keduanya sampai ke mekanismenya, lalu cara memperbaikinya langkah demi langkah. Semua screenshot berasal dari demo Laravel 12 dan MySQL 8 yang saya jalankan untuk mereproduksi kasus ini.
Kenapa nomor bisa kembar walaupun sudah ada Rule::unique
Alurnya di aplikasi itu kira-kira begini: aplikasi meminta nomor berikutnya lewat request GET, pemeriksa mengisi formulir, lalu nomor itu dikirim balik bersama data temuan lewat POST. Di antara GET dan POST ada jeda, bisa beberapa detik, bisa beberapa menit.
Masalahnya ada di jeda itu. Dua orang yang meminta nomor di saat hampir bersamaan membaca data yang sama, jadi mendapat nomor yang sama. Rule::unique tidak menolong, karena aturan itu membaca dulu, baru menulis. Kedua request bisa lolos validasi sebelum salah satu baris benar-benar tersimpan. Yang kalah baru tertahan oleh unique index saat INSERT, dan itu muncul sebagai error 500, bukan pesan yang bisa dipahami pemeriksa.
Lalu kenapa lockForUpdate tidak menyelamatkan? Karena lock hanya berlaku di dalam transaksi. Di jalur web aplikasi itu, nomor memang dibuat ulang di dalam transaksi penyimpanan, jadi lock-nya bekerja. Tapi jalur API untuk aplikasi HP memakai nomor apa pun yang dikirim di request. Lock di fungsi pembuat nomor tidak pernah tersentuh dari jalur itu.
Bug kedua lebih diam. Fungsi pembuat nomor mengambil baris terbaru berdasarkan id, lalu menganggap baris itu memegang nomor tertinggi. Tidak ada yang menjamin itu. Begitu ada baris dengan nomor lebih kecil yang tersimpan belakangan, misalnya dari data pindahan atau koreksi, nomor berikutnya jatuh ke nomor yang sudah dipakai.
Ada satu jebakan tambahan: nomor seperti M9 dan M10 disimpan sebagai teks. Kalau nilai tertinggi dicari dengan perbandingan teks, M9 dianggap lebih besar dari M10.
Perbaikan yang masuk ke aplikasi
Prinsipnya satu: nomor urut hanya boleh dibuat di tempat yang sama dengan tempat ia disimpan, di dalam satu transaksi, dengan lock.
Untuk jalur API, nomor yang dikirim aplikasi HP sekarang diabaikan. Server membuat nomor di dalam transaksi penyimpanan, memakai service yang sama dengan jalur web, lalu mengembalikan nomor itu di response. Aplikasi HP yang sudah terpasang memang sudah membaca nomor dari response, jadi versi yang sudah beredar tetap jalan tanpa perlu update.
Aturan Rule::unique pada nomor yang dikirim juga dihapus. Kalau dibiarkan, aturan itu akan menolak request yang sebenarnya bisa dilayani dengan memberi nomor berikutnya. Unique index di database tetap ada sebagai penjaga terakhir.
Untuk bug kedua, nomor berikutnya dihitung dari nilai tertinggi sebagai angka, bukan dari baris terbaru dan bukan dari perbandingan teks. Sebelum mengubahnya, saya memeriksa semua kelompok gedung dan lingkup di data aplikasi: 252 kelompok. Tiga belas di antaranya punya baris terbaru dengan nomor lebih kecil dari nomor yang sudah pernah dipakai, dan tiga akan langsung gagal begitu ada temuan baru. Setelah perubahan, pemeriksaan yang sama menunjukkan nol tabrakan.
Hasil verifikasi di server: mengirim nomor M05 yang sudah terpakai dua kali menghasilkan M06 dan M07, bukan penolakan. Seluruh 405 tes lulus.
Mereproduksi dan memperbaikinya di Laravel 12
Demo memakai Laravel 12 dan MySQL 8 lokal. SQLite tidak cocok untuk demo ini karena tidak punya row lock seperti MySQL.
Langkah 1: reproduksi tabrakannya
Buat tabel dengan unique index pada kombinasi gedung dan nomor:
Schema::create('sheets', function (Blueprint $table) {
$table->id();
$table->unsignedInteger('building');
$table->string('number', 10);
$table->string('inspector');
$table->unique(['building', 'number']);
});
Lalu tiru alur "ambil nomor dulu, simpan belakangan". Jeda 400 milidetik mewakili waktu pemeriksa mengisi formulir:
$number = sheetNextFromLatestRow($building); // seperti GET /next-number
usleep(400000); // pemeriksa mengisi formulir
DB::table('sheets')->insert([ // seperti POST
'building' => $building, 'number' => $number, 'inspector' => $inspector,
]);
Jalankan empat proses sekaligus, seolah empat pemeriksa menekan simpan di detik yang sama:
for p in Andi Budi Citra Dewi; do php artisan demo:sheet get-then-post $p & done; wait

Keempatnya mendapat M05. Satu tersimpan, tiga gagal dengan Duplicate entry, yang di aplikasi sungguhan muncul sebagai error 500.
Langkah 2: pindahkan pembuatan nomor ke dalam transaksi
$number = DB::transaction(function () use ($building, $inspector) {
$max = DB::table('sheets')
->where('building', $building)
->lockForUpdate()
->selectRaw('MAX(CAST(SUBSTRING(number, 2) AS UNSIGNED)) AS n')
->value('n');
$next = 'M'.str_pad((string) ($max + 1), 2, '0', STR_PAD_LEFT);
DB::table('sheets')->insert(['building' => $building, 'number' => $next, 'inspector' => $inspector]);
return $next;
});
lockForUpdate membuat transaksi lain yang ingin membaca baris yang sama menunggu sampai transaksi ini selesai. Karena membaca dan menyimpan terjadi di transaksi yang sama, tidak ada lagi jeda yang bisa direbut orang lain.

Empat pemeriksa yang sama sekarang mendapat M05, M06, M07, dan M08. Semuanya tersimpan.
Langkah 3: hitung dari nomor tertinggi, bukan baris terbaru
Isi tabel dengan urutan yang bisa terjadi di dunia nyata: M08, lalu M10, lalu M09 yang tersimpan belakangan.

Cara lama mengambil baris terbaru (M09), menambah satu, lalu mencoba M10 yang sudah dipakai. Cara baru menghitung nilai tertinggi sebagai angka dan memberi M11.
Langkah 4: jangan membandingkan nomor sebagai teks
Ini yang membuat MAX(number) berbahaya:

Sebagai teks, M9 lebih besar dari M10, karena perbandingan berhenti di karakter kedua: "9" lebih besar dari "1". Ambil bagian angkanya lalu ubah menjadi angka, seperti CAST(SUBSTRING(number, 2) AS UNSIGNED), atau simpan nomor urut di kolom angka terpisah dan bentuk teks "M10" hanya saat ditampilkan.
Langkah 5: abaikan nomor dari klien, kembalikan nomor dari server
Untuk API yang dipakai aplikasi HP, ubah kontraknya tanpa memutus aplikasi yang sudah beredar:
// Sebelum: nomor dari request dipakai apa adanya
'number' => ['required', Rule::unique('sheets')->where('building', $building)],
// Sesudah: nomor dari request diabaikan, server yang membuat
$sheet = $this->sheets->storeWithNextNumber($building, $data);
return response()->json(['number' => $sheet->number, ...]);
Selama aplikasi HP sudah membaca nomor dari response, perubahan ini aman. Kalau belum, ubah aplikasinya dulu, baru server.
Langkah 6: periksa data yang sudah ada sebelum deploy
Perbaikan kode tidak memperbaiki data lama. Sebelum deploy, cari kelompok yang sudah bermasalah:
SELECT building,
MAX(CAST(SUBSTRING(number, 2) AS UNSIGNED)) AS tertinggi,
(SELECT CAST(SUBSTRING(s2.number, 2) AS UNSIGNED)
FROM sheets s2 WHERE s2.building = s.building
ORDER BY s2.id DESC LIMIT 1) AS di_baris_terbaru
FROM sheets s
GROUP BY building
HAVING di_baris_terbaru < tertinggi;
Setiap baris hasil query ini adalah kelompok yang akan gagal dengan cara lama. Di proyek klien, pemeriksaan seperti ini menemukan 13 dari 252 kelompok.
Checklist untuk setiap nomor urut di aplikasi kamu
- Nomor dibuat di server, di dalam transaksi yang sama dengan penyimpanan.
- Ada
lockForUpdatepada query yang membaca nilai tertinggi, dan query itu benar-benar berjalan di dalam transaksi. - Nilai tertinggi dihitung sebagai angka, bukan teks, dan bukan dari baris terbaru.
- Nomor dari klien diabaikan; nomor dari server dikembalikan di response.
- Unique index tetap ada di database sebagai penjaga terakhir.
- Ada tes yang menjalankan beberapa request bersamaan, bukan hanya satu.
- Data lama sudah diperiksa sebelum deploy.
Yang belum diselesaikan perbaikan ini
- Lock membuat request antre. Untuk satu gedung dan satu lingkup, pemeriksa memang harus antre sesaat. Itu wajar untuk nomor laporan. Untuk nomor yang dibuat ribuan kali per detik, pertimbangkan sequence database atau kolom auto increment terpisah.
- Demo memakai MySQL 8.
lockForUpdatebekerja di MySQL dan PostgreSQL. SQLite tidak punya row lock yang sama, jadi demo ini tidak bisa direproduksi di sana. - Contoh query dan kode di atas disederhanakan. Di proyek klien, nomor unik per gedung dan lingkup, dengan awalan huruf yang berbeda per lingkup. Logikanya sama, kolom kelompoknya lebih banyak.
- Memperbaiki data lama butuh keputusan. Nomor yang sudah tercetak di laporan tidak bisa sembarang diubah. Tentukan dulu mana yang dipertahankan dan bagaimana pemeriksa diberi tahu.
Query di langkah 6 menjawab pertanyaan paling penting dalam satu kali jalan: hasil kosong berarti aman untuk sekarang, hasil tidak kosong berarti ada nomor yang sedang menunggu untuk kembar.
Artikel terkait
Tulisan lain yang nyambung dengan topik ini.


Status "Dalam Proses" yang Tidak Pernah Selesai: Tiga Cara Job Laravel Gagal Tanpa Memberi Tahu Siapa Pun
Error di dalam catch, kehabisan memori, dan worker yang masih memakai kode lama. Tiga cara job Laravel gagal diam-diam, dengan demo dan perbaikannya.


Job Laravel Gagal Sebelum handle() Jalan, dan Retry Tidak Akan Menolong
Payload job Laravel yang membawa objek jadi bisa gagal sebelum handle() jalan, dan retry tidak menolong. Penyebab, demo, dan cara memperbaikinya langkah demi langkah.


Tiga Hal yang Langsung Saya Terapkan Setelah Paham Kenapa Laravel Queues Itu Bukan Fitur Opsional
Selama ini saya anggap queue itu fitur advanced. Ternyata itu basic. Ini yang berubah setelah saya benar-benar memahaminya.
Comments
Komentar akan tampil setelah disetujui.