Saat job Laravel yang seharusnya unique tetap berjalan ganda, banyak tim langsung menyalahkan Horizon, Supervisor, atau jumlah worker yang terlalu banyak. Padahal, akar masalah paling sering ada pada mekanisme lock di cache. Fitur seperti ShouldBeUnique dan middleware WithoutOverlapping sama-sama bergantung pada lock yang dibuat oleh cache driver. Jika driver tidak tepat, key lock tidak konsisten, TTL terlalu pendek, atau uniqueId() tidak stabil, dua worker tetap bisa mengeksekusi job identik secara paralel.
Artikel ini membahas kasus nyata ketika ShouldBeUnique atau WithoutOverlapping tetap tembus, fokus pada empat area yang paling sering bermasalah: driver cache, TTL lock, Horizon/Supervisor dengan banyak worker, dan perbedaan uniqueId yang tidak stabil. Di akhir, ada contoh implementasi yang benar dan langkah verifikasi langsung di Redis.
Memahami Perbedaan: ShouldBeUnique vs WithoutOverlapping
Sebelum masuk ke debugging, penting memahami bahwa kedua mekanisme ini bekerja di tahap yang berbeda.
ShouldBeUnique
ShouldBeUnique mencegah job identik didispatch lebih dari sekali selama lock masih aktif. Artinya, kontrol terjadi saat proses dispatching, bukan saat job mulai diproses worker. Jika lock berhasil dibuat, dispatch berikutnya untuk job dengan identitas yang sama akan ditolak atau diabaikan.
Ini cocok untuk kasus seperti sinkronisasi data akun tertentu, impor file yang sama, atau pengiriman notifikasi yang tidak boleh diantre berkali-kali.
WithoutOverlapping
WithoutOverlapping adalah middleware job yang mencegah dua job dengan key sama berjalan bersamaan. Job tetap bisa masuk queue beberapa kali, tetapi saat worker mengeksekusi, hanya satu yang boleh memegang lock pada waktu tertentu.
Ini cocok jika job boleh menumpuk di antrean, tetapi tidak boleh memproses resource yang sama secara paralel. Misalnya, rekalkulasi saldo user, pembaruan inventory SKU, atau sinkronisasi API per tenant.
Konsekuensi Praktis
- ShouldBeUnique: fokus pada deduplikasi saat enqueue.
- WithoutOverlapping: fokus pada serialisasi saat runtime.
- Keduanya sama-sama bisa gagal jika lock cache tidak benar-benar atomic atau key lock tidak konsisten.
Penyebab Paling Umum Job Tetap Berjalan Paralel
1. Driver Cache Tidak Mendukung Lock Secara Andal
Fitur unique job Laravel mengandalkan cache lock. Secara praktis, driver seperti Redis atau Memcached lebih aman dipakai untuk skenario multi-worker karena mendukung operasi atomic lock. Sebaliknya, memakai file, array, atau konfigurasi cache yang berbeda-beda antar proses bisa membuat lock tidak efektif, terutama jika Anda menjalankan banyak worker pada banyak container atau server.
Kasus yang sering terjadi:
- QUEUE_CONNECTION memakai Redis, tetapi CACHE_DRIVER masih
file. - Aplikasi berjalan pada beberapa pod/container, tetapi cache
filetersimpan lokal di masing-masing instance. - Environment worker berbeda dengan environment aplikasi web, sehingga keduanya memakai store cache berbeda.
Jika lock harus berlaku lintas worker, lintas proses, atau lintas host, gunakan cache terpusat seperti Redis. Lock lokal per mesin tidak cukup untuk mencegah duplikasi secara global.
2. TTL Lock Terlalu Pendek
Lock bukan selamanya. Baik ShouldBeUnique maupun WithoutOverlapping bergantung pada masa hidup lock. Jika TTL lock habis sebelum job selesai, worker lain bisa mengambil lock yang sama dan mengeksekusi job identik secara paralel.
Contoh klasik:
- Job rata-rata selesai dalam 90 detik.
- TTL lock diset hanya 30 detik.
- Setelah detik ke-30, worker lain bisa menganggap lock sudah hilang.
Hasilnya: dua proses aktif pada resource yang sama, walaupun kode tampak sudah memakai proteksi unique.
Masalah ini makin sering muncul jika durasi job tidak stabil, misalnya tergantung respons API eksternal, ukuran file, atau beban database.
3. Horizon atau Supervisor Menjalankan Banyak Worker Secara Bersamaan
Multi-worker bukan masalah; justru itulah tujuan queue. Masalah muncul ketika banyak worker aktif, tetapi mekanisme lock tidak benar-benar global atau tidak cukup lama. Dalam konfigurasi Horizon atau Supervisor, puluhan worker dapat mengambil job hampir bersamaan dalam selang milidetik. Jika lock gagal dibuat secara atomic, race condition akan terlihat jauh lebih sering.
Gejala khasnya:
- Pada local development job tampak aman.
- Di staging atau production, ketika worker diperbanyak, job identik mulai dobel.
- Duplikasi lebih sering saat traffic tinggi atau saat ada batch dispatch besar.
Artinya, masalahnya bukan pada banyaknya worker, melainkan pada validitas lock ketika ada konkurensi tinggi.
4. uniqueId() Tidak Stabil
Ini sumber bug yang sangat sering luput. Laravel hanya bisa menganggap dua job identik jika key uniknya benar-benar sama. Jika uniqueId() dibuat dari data yang berubah-ubah, dua job yang secara bisnis identik akan dianggap berbeda.
Contoh buruk:
- Memasukkan
now(), timestamp, random string, atau UUID baru ke dalamuniqueId(). - Menggunakan seluruh payload JSON yang urutan field-nya bisa berubah.
- Menggunakan object serialization yang hasilnya tidak konsisten.
Akibatnya, secara teknis lock memang bekerja, tetapi setiap dispatch membuat key baru sehingga tidak pernah benar-benar saling menahan.
Implementasi ShouldBeUnique yang Benar
Gunakan identifier yang stabil, deterministik, dan mewakili unit kerja yang memang ingin dibuat unik. Misalnya, sinkronisasi invoice berdasarkan ID invoice.
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SyncInvoiceJob implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $uniqueFor = 300; // 5 menit
public function __construct(public int $invoiceId)
{
}
public function uniqueId(): string
{
return 'invoice:' . $this->invoiceId;
}
public function handle(): void
{
// proses sinkronisasi invoice
}
}Poin penting pada contoh di atas:
uniqueId()hanya menggunakaninvoiceId, yang stabil.uniqueFormemberi TTL lock yang eksplisit.- Nilai TTL harus cukup panjang untuk menutup durasi antrean dan eksekusi yang realistis.
Jika job kadang memakan waktu 2-3 menit, memberi uniqueFor = 60 jelas terlalu pendek.
Kesalahan yang Sering Terjadi
public function uniqueId(): string
{
return 'invoice:' . $this->invoiceId . ':' . now()->timestamp;
}Contoh di atas salah karena setiap dispatch menghasilkan key berbeda. Secara tampilan tampak unik, tetapi bukan unik dalam arti deduplikasi job identik.
Implementasi WithoutOverlapping yang Benar
Jika tujuan Anda bukan mencegah dispatch ganda, melainkan mencegah proses paralel terhadap resource yang sama, gunakan WithoutOverlapping.
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
class RecalculateUserBalanceJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $userId)
{
}
public function middleware(): array
{
return [
(new WithoutOverlapping('user-balance:' . $this->userId))
->expireAfter(600)
->releaseAfter(10),
];
}
public function handle(): void
{
// proses rekalkulasi saldo user
}
}Penjelasan parameter penting:
- Key lock harus stabil, misalnya
user-balance:123. - expireAfter(600) memberi batas TTL lock 10 menit. Ini penting jika worker mati mendadak agar lock tidak menggantung selamanya.
- releaseAfter(10) membuat job yang gagal mendapat lock dilepas kembali ke queue setelah 10 detik, bukan dibuang.
Tanpa expireAfter(), Anda berisiko punya lock yang terlalu lama atau perilaku yang tidak sesuai ekspektasi saat proses crash. Tetapi jika nilainya terlalu pendek, masalah paralel bisa muncul lagi.
Checklist Konfigurasi yang Wajib Dicek
Pastikan Cache Driver Terpusat
CACHE_STORE=redis
QUEUE_CONNECTION=redisNama environment variable bisa berbeda tergantung struktur aplikasi, tetapi intinya sama: cache store untuk lock sebaiknya Redis, terutama jika worker berjalan di banyak proses atau mesin.
Pastikan Semua Worker Menggunakan Environment yang Sama
Sering terjadi worker Horizon atau Supervisor belum direstart setelah perubahan .env. Akibatnya:
- Web app sudah pakai Redis.
- Worker lama masih pakai file cache atau konfigurasi lama.
Setelah mengubah cache atau queue config, lakukan restart yang sesuai, misalnya:
php artisan config:clear
php artisan cache:clear
php artisan horizon:terminateJika memakai Supervisor, restart proses worker agar environment dimuat ulang.
Sesuaikan TTL dengan Durasi Nyata Job
Jangan menebak TTL dari perkiraan optimistis. Gunakan durasi terburuk yang masih masuk akal. Jika job normalnya 20 detik tetapi bisa mencapai 4 menit ketika API lambat, TTL 30 detik adalah resep duplikasi.
Sebagai aturan praktis:
- Pilih TTL lebih besar dari durasi eksekusi terburuk.
- Tambahkan buffer untuk retry, network delay, atau startup worker.
- Evaluasi ulang jika pola beban berubah.
Langkah Verifikasi Lock Langsung di Redis
Jika Anda ingin memastikan masalahnya benar-benar pada cache lock, verifikasi langsung di Redis. Ini jauh lebih akurat daripada menebak dari log aplikasi saja.
1. Hubungkan ke Redis
redis-cli2. Cari Key yang Berkaitan dengan Job atau Lock
SCAN 0 MATCH *invoice* COUNT 100
SCAN 0 MATCH *laravel* COUNT 100
SCAN 0 MATCH *overlap* COUNT 100Nama key internal bisa berbeda tergantung versi dan implementasi, jadi gunakan pola pencarian yang cukup luas lalu persempit.
3. Cek TTL Key Lock
TTL nama_key_lockInterpretasinya:
- Nilai positif: key akan kedaluwarsa dalam sejumlah detik.
-1: key ada tetapi tidak punya expire.-2: key tidak ditemukan.
Jika lock hilang terlalu cepat saat job masih berjalan, kemungkinan TTL terlalu pendek. Jika key tidak pernah muncul, mungkin worker tidak memakai Redis store yang Anda kira, atau key yang dibentuk berbeda dari ekspektasi.
4. Pantau Secara Real-Time
MONITORPerintah ini sangat berguna di lingkungan uji untuk melihat operasi Redis secara langsung saat Anda mendispatch dua job identik. Anda bisa mengamati apakah lock dibuat, diperbarui, atau hilang terlalu cepat. Jangan gunakan MONITOR sembarangan di production dengan trafik tinggi karena biayanya mahal.
5. Uji dengan Dispatch Paralel
Lakukan percobaan sederhana:
- Dispatch dua job dengan resource ID yang sama dalam waktu hampir bersamaan.
- Pastikan worker lebih dari satu agar race condition benar-benar teruji.
- Lihat apakah hanya satu lock yang terbentuk dan apakah job kedua tertahan atau dilepas ulang.
Jika dua job tetap jalan bersamaan, cek kembali tiga hal: key lock, cache driver, dan TTL.
Kesalahan Debugging yang Sering Menyesatkan
Menganggap Queue Driver dan Cache Driver Itu Sama
Memakai Redis sebagai queue tidak otomatis berarti unique lock juga aman. Lock mengikuti cache store, bukan semata queue connection.
Menguji Hanya dengan Satu Worker
Pada satu worker, hampir semua mekanisme tampak benar karena tidak ada kompetisi nyata. Bug biasanya baru muncul saat ada dua atau lebih worker aktif.
Key Unik Dibuat dari Payload Mentah
Payload JSON, array, atau object sering tampak mewakili job yang sama, tetapi bisa memiliki urutan atau representasi berbeda. Lebih aman gunakan identifier bisnis yang eksplisit: userId, orderId, tenantId, invoiceId, atau kombinasi yang memang deterministik.
Kapan Memilih ShouldBeUnique dan Kapan WithoutOverlapping?
- Pilih ShouldBeUnique jika Anda ingin mencegah job identik masuk queue berkali-kali.
- Pilih WithoutOverlapping jika job boleh menumpuk, tetapi eksekusinya harus satu per satu untuk resource tertentu.
- Gabungkan keduanya hanya jika Anda benar-benar butuh deduplikasi saat dispatch sekaligus proteksi saat runtime.
Namun, menggabungkan keduanya tanpa memahami perilakunya bisa menyulitkan debugging. Mulailah dari kebutuhan bisnis yang jelas: apakah masalah Anda ada di antrean yang berulang, atau di eksekusi paralel terhadap resource yang sama?
Penutup
Jika ShouldBeUnique atau WithoutOverlapping di Laravel terasa “tembus”, penyebabnya biasanya bukan karena fiturnya tidak bekerja, melainkan karena fondasi lock-nya bermasalah. Empat tersangka utama adalah cache driver yang tidak tepat, TTL lock terlalu pendek, konkurensi tinggi dari Horizon/Supervisor multi-worker, dan uniqueId yang tidak stabil.
Langkah paling aman untuk production adalah:
- Gunakan Redis sebagai cache store untuk lock.
- Buat key unik yang stabil dan deterministik.
- Tentukan TTL berdasarkan durasi job yang realistis, bukan asumsi terbaik.
- Uji pada multi-worker, bukan hanya satu worker.
- Verifikasi langsung key dan TTL di Redis.
Dengan pendekatan ini, Anda tidak hanya “berharap” unique job bekerja, tetapi bisa membuktikan bahwa lock benar-benar ada, berlaku global, dan bertahan cukup lama untuk mencegah proses ganda.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!