Laravel Queue Job Bekerja Dua Kali? Penyebab dan Cara Mencegahnya
Kenapa Laravel Queue Job Bisa Bekerja Dua Kali?
Laravel Queue dirancang untuk memastikan Job tetap diproses ketika terjadi kegagalan tertentu.
Namun, konsekuensinya adalah sebuah Job dapat diproses lebih dari satu kali dalam kondisi tertentu.
Misalnya:
Queue
↓
Job
↓
Worker memproses
↓
Proses sebenarnya berhasil
↓
Worker mengalami masalah sebelum Job dianggap selesai
↓
Job diproses kembali
Akibatnya, developer dapat melihat gejala seperti:
Kirim email
Kirim email lagi
atau
Update order
Update order lagi
atau
API dipanggil
API dipanggil lagi
Hal ini bukan selalu berarti Laravel mengalami bug.
Sering kali penyebabnya adalah kombinasi antara retry, timeout, worker termination, deployment, concurrency, atau desain Job yang tidak idempotent.
Contoh Laravel Queue Job Diproses Dua Kali
Misalnya kita memiliki Job:
class SendInvoiceEmail implements ShouldQueue
{
public function handle(): void
{
Mail::to($this->email)
->send(new InvoiceMail($this->invoice));
}
}
Kemudian:
SendInvoiceEmail::dispatch($invoice);
Secara normal:
Dispatch
↓
Queue
↓
Worker
↓
handle()
↓
Email terkirim
Tetapi dalam kondisi tertentu:
Dispatch
↓
Queue
↓
Worker A
↓
handle()
↓
Email terkirim
↓
Worker mengalami timeout/crash
↓
Job dianggap belum selesai
↓
Worker mengambil Job lagi
↓
Email terkirim lagi
Hasil akhirnya:
User menerima 2 email
Inilah salah satu alasan mengapa Job tidak boleh diasumsikan hanya akan berjalan sekali.
Apakah Laravel Queue Menjamin Job Hanya Berjalan Sekali?
Tidak.
Laravel Queue sebaiknya dipahami menggunakan model at-least-once processing.
Artinya, sistem Queue berusaha memastikan Job diproses, tetapi dalam kondisi tertentu Job dapat diproses kembali.
Ini berbeda dengan jaminan:
Exactly once
yang berarti sebuah pekerjaan benar-benar hanya dieksekusi satu kali.
Dalam sistem distributed processing, exactly-once execution sangat sulit dijamin.
Karena itu, pendekatan yang lebih aman adalah:
Anggap Job dapat dijalankan ulang dan buat Job tersebut aman ketika terjadi retry atau duplicate processing.
Konsep ini disebut idempotency.
Apa Itu Idempotency?
Idempotency berarti menjalankan operasi yang sama berkali-kali menghasilkan efek akhir yang sama seperti menjalankannya sekali.
Contoh sederhana:
$order->update([
'status' => 'paid',
]);
Jika operasi tersebut dijalankan dua kali:
paid
↓
paid
hasil akhirnya tetap:
paid
Tetapi operasi seperti:
$user->increment('balance', 100);
berbeda.
Jika dijalankan dua kali:
100
↓
200
↓
300
Operasi tersebut menghasilkan efek ganda.
Karena itu, Job dengan operasi increment, payment, pengiriman email, atau pemanggilan API harus dirancang lebih hati-hati.
Penyebab Laravel Queue Job Berjalan Dua Kali
Ada beberapa penyebab yang paling umum:
timeoutJob terlalu pendekretry_afterterlalu kecil- Worker crash
- Server restart
- Worker dimatikan secara paksa
- Deployment dilakukan tanpa graceful restart
- Multiple Worker memproses Job yang sama
- Job gagal setelah efek samping sudah terjadi
- Job memang di-dispatch lebih dari sekali
- Tidak menggunakan mekanisme idempotency
- Concurrency tidak dikontrol
- API eksternal timeout setelah request sebenarnya berhasil
Mari kita bahas satu per satu.
Penyebab 1: Queue Job Mengalami Timeout
Ini salah satu penyebab yang sering terjadi.
Misalnya:
public $timeout = 30;
Job membutuhkan 60 detik.
Maka:
Job mulai
↓
30 detik
↓
Timeout
↓
Worker menghentikan Job
Masalahnya, bagaimana jika pekerjaan sebenarnya sudah melakukan sebagian efek sebelum timeout?
Misalnya:
Job
↓
Update database
↓
Kirim API
↓
Proses terlalu lama
↓
Timeout
Job kemudian dapat dianggap gagal dan dicoba kembali.
Hasilnya:
Update database
↓
Kirim API
↓
Retry
↓
Update database lagi
↓
Kirim API lagi
Karena itu, timeout harus dikonfigurasi dengan benar.
Penyebab 2: retry_after Terlalu Kecil
Jika menggunakan Queue backend seperti Redis atau database, ada mekanisme untuk menentukan berapa lama Job dianggap sedang diproses sebelum dapat tersedia kembali.
Laravel memiliki konfigurasi:
'retry_after' => 90,
Nilainya dapat berbeda tergantung versi dan konfigurasi Queue.
Misalnya:
retry_after = 60
Tetapi Job membutuhkan:
90 detik
Maka Worker A masih mengerjakan Job ketika Job tersebut sudah dianggap dapat diambil kembali.
Akibatnya:
Worker A
↓
Job A
↓
Masih bekerja...
Setelah 60 detik
Worker B
↓
Mengambil Job A
Sekarang terdapat:
Worker A → Job A
Worker B → Job A
Job yang sama dapat berjalan secara bersamaan.
Hubungan timeout dan retry_after
Ini bagian yang sangat penting.
Secara umum, retry_after harus lebih besar daripada timeout Worker agar Job tidak dianggap tersedia kembali ketika Worker sebenarnya masih menjalankannya.
Contohnya:
Job timeout = 60 detik
retry_after = 90 detik
Lebih aman daripada:
Job timeout = 90 detik
retry_after = 60 detik
Karena konfigurasi kedua berpotensi membuat Job terlihat tersedia kembali sebelum Worker selesai.
Namun, nilai sebenarnya harus disesuaikan dengan workload, queue backend, Worker, dan kebutuhan aplikasi.
Penyebab 3: Worker Crash
Worker dapat mengalami crash karena:
- Out of memory
- Fatal error
- PHP process crash
- Server restart
- Redis connection problem
- Dependency error
- Process manager menghentikan Worker
Misalnya:
Worker
↓
Job dimulai
↓
Proses
↓
Worker crash
Queue backend dapat menganggap Job belum selesai.
Setelah visibility/retry period terpenuhi:
Queue
↓
Job tersedia lagi
↓
Worker baru
↓
Job diproses kembali
Jika Job sudah melakukan sebagian pekerjaan sebelumnya, duplicate effect dapat terjadi.
Penyebab 4: Server Restart
Misalnya server VPS direstart:
Worker
↓
Job sedang diproses
↓
Server restart
↓
Worker mati
Setelah server kembali:
Supervisor
↓
Worker baru
↓
Job dapat diproses kembali
Hal ini merupakan salah satu alasan mengapa Job harus aman ketika dijalankan ulang.
Penyebab 5: Worker Dimatikan Secara Paksa
Misalnya:
kill -9 <PID>
atau process manager menghentikan PHP process secara paksa.
Jika Job belum benar-benar selesai, Job dapat kembali tersedia untuk diproses.
Untuk deployment dan maintenance, sebaiknya gunakan mekanisme graceful shutdown.
Laravel menyediakan:
php artisan queue:restart
Command tersebut memberi sinyal kepada Worker agar berhenti setelah menyelesaikan Job yang sedang dikerjakan.
Penyebab 6: Deployment Tanpa Restart Worker
Ini kasus yang sering terjadi pada Laravel production.
Misalnya:
Deploy
↓
Git pull
↓
Code berubah
↓
Worker lama masih berjalan
Worker adalah long-running process.
Kode yang sudah dimuat oleh Worker tidak otomatis berubah hanya karena file aplikasi berubah.
Setelah deployment, gunakan:
php artisan queue:restart
Kemudian process manager seperti Supervisor dapat menjalankan Worker kembali.
Alurnya:
Deployment
↓
queue:restart
↓
Worker menyelesaikan Job
↓
Worker berhenti
↓
Supervisor menjalankan Worker baru
Ini lebih aman daripada membunuh process Worker secara paksa.
Penyebab 7: Job Memang Di-dispatch Dua Kali
Tidak semua duplicate Job disebabkan Queue Worker.
Bisa jadi aplikasinya sendiri melakukan:
ProcessOrder::dispatch($order);
ProcessOrder::dispatch($order);
Akibatnya memang ada dua Job.
Queue bekerja sesuai perintah yang diberikan:
Dispatch #1 → Job A
Dispatch #2 → Job B
Kemudian Worker memproses:
Job A
↓
Job B
Jika keduanya melakukan pekerjaan yang sama, hasilnya terlihat seperti duplicate processing.
Cek Controller yang Melakukan Dispatch
Misalnya:
public function store(Request $request)
{
$order = Order::create([
// ...
]);
ProcessOrder::dispatch($order);
return redirect()->back();
}
Pastikan method tersebut tidak terpanggil dua kali.
Hal ini dapat terjadi karena:
- Double submit
- User refresh halaman
- JavaScript mengirim request dua kali
- AJAX request duplicate
- Webhook dikirim ulang
- API client melakukan retry
- Event listener terdaftar lebih dari sekali
Penyebab 8: Event Listener Menjalankan Job Dua Kali
Misalnya:
OrderCreated::dispatch($order);
Kemudian listener:
class ProcessOrderListener
{
public function handle(OrderCreated $event)
{
ProcessOrder::dispatch($event->order);
}
}
Jika listener terdaftar dua kali, Job juga dapat di-dispatch dua kali.
Karena itu, ketika menemukan duplicate Job, jangan hanya memeriksa Worker.
Periksa juga:
Controller
↓
Event
↓
Listener
↓
Job
↓
Queue
Cari di mana duplicate terjadi.
Penyebab 9: Webhook Dipanggil Dua Kali
Webhook dari service eksternal juga sering menjadi sumber duplicate Job.
Contoh:
Payment Gateway
↓
Webhook
↓
Laravel
↓
ProcessPayment
Jika server Laravel lambat merespons:
Webhook
↓
Timeout
Payment gateway mungkin mengirim webhook kembali:
Webhook #1
↓
Laravel
Webhook #2
↓
Laravel
Akhirnya:
ProcessPayment
ProcessPayment
Ini bukan masalah Queue saja.
Endpoint webhook juga harus dibuat idempotent.
Gunakan Unique ID untuk Setiap Operasi
Salah satu cara paling efektif untuk mencegah duplicate processing adalah memberikan identifier unik.
Contoh:
order_id
transaction_id
invoice_id
external_event_id
Misalnya:
transaction_id = PAY-2026-00123
Kemudian sebelum melakukan pekerjaan:
Apakah transaction_id ini sudah diproses?
│
Ya
↓
Stop
Tidak
↓
Proses
↓
Tandai selesai
Dengan demikian retry tidak menyebabkan proses bisnis dijalankan dua kali.
Gunakan Database Unique Constraint
Jangan hanya mengandalkan pengecekan di kode:
if (!Payment::where('transaction_id', $id)->exists()) {
// insert
}
Dalam kondisi concurrent:
Worker A → Cek → belum ada
Worker B → Cek → belum ada
Keduanya dapat melakukan insert.
Lebih aman jika database juga memiliki unique constraint.
Contoh migration:
Schema::table('payments', function (Blueprint $table) {
$table->unique('transaction_id');
});
Sekarang database sendiri mencegah duplicate:
PAY-001
PAY-001
Baris kedua akan ditolak.
Ini jauh lebih kuat untuk menjaga integritas data.
Idempotency dengan Status Database
Contoh Job:
public function handle(): void
{
$order = Order::findOrFail($this->orderId);
if ($order->status === 'processed') {
return;
}
$order->update([
'status' => 'processed',
]);
}
Jika Job dijalankan dua kali:
Attempt 1
↓
status = pending
↓
Update → processed
Attempt 2
↓
status = processed
↓
return
Job kedua tidak melakukan pekerjaan lagi.
Ini merupakan pola idempotency sederhana.
Namun untuk proses yang kompleks, pengecekan status saja belum tentu cukup. Gunakan transaction, unique constraint, lock, atau mekanisme idempotency key sesuai kebutuhan.
Idempotency untuk increment()
Operasi seperti:
$user->increment('points', 100);
berbahaya jika Job dapat dijalankan ulang.
Attempt pertama:
points = 0
↓
+100
↓
100
Attempt kedua:
100
↓
+100
↓
200
Padahal seharusnya:
100
Salah satu pendekatan adalah menyimpan record transaksi atau event yang memiliki ID unik.
Contohnya:
reward_events
event_id = ORDER-1001-REWARD
Kemudian database memastikan event_id hanya dapat diproses sekali.
Gunakan ShouldBeUnique
Laravel juga menyediakan mekanisme Unique Job.
Contohnya:
use Illuminate\Contracts\Queue\ShouldBeUnique;
class SyncProduct implements ShouldQueue, ShouldBeUnique
{
public function __construct(
public int $productId
) {}
public function handle(): void
{
// Sync product
}
}
Dengan ShouldBeUnique, Laravel dapat mencegah Job yang sama dimasukkan ke Queue ketika Job dengan unique identity yang sama masih dianggap aktif.
Untuk menentukan identifier unik, kita dapat menggunakan uniqueId():
public function uniqueId(): string
{
return (string) $this->productId;
}
Sekarang:
SyncProduct(10)
SyncProduct(10)
dapat dianggap sebagai Job yang sama berdasarkan identifier tersebut.
Sedangkan:
SyncProduct(10)
SyncProduct(20)
adalah Job berbeda.
Kapan Menggunakan ShouldBeUnique?
Unique Job cocok ketika kita tidak ingin ada beberapa Job identik yang aktif secara bersamaan.
Contoh:
SyncProduct(100)
Tidak perlu ada:
Worker A → SyncProduct(100)
Worker B → SyncProduct(100)
Jika sinkronisasi produk membutuhkan waktu lama, unique Job dapat membantu mencegah dispatch duplicate.
Namun perlu diingat:
Unique Job bukan pengganti idempotency.
Job tetap harus aman jika diproses kembali akibat failure.
ShouldBeUnique Tidak Sama dengan Idempotency
Ini perbedaan penting.
ShouldBeUnique berfokus pada:
Mencegah duplicate Job tertentu masuk atau berjalan bersamaan.
Idempotency berfokus pada:
Jika Job tetap dijalankan dua kali,
efek akhirnya tetap aman.
Sistem production yang kritis sering membutuhkan keduanya.
Contohnya:
Unique Job
+
Idempotent Processing
+
Database Constraint
lebih kuat daripada hanya mengandalkan satu mekanisme.
Menggunakan WithoutOverlapping
Laravel juga menyediakan middleware WithoutOverlapping.
Contohnya:
use Illuminate\Queue\Middleware\WithoutOverlapping;
public function middleware(): array
{
return [
(new WithoutOverlapping($this->productId)),
];
}
Konsepnya:
Product #10
Worker A
↓
Lock Product #10
↓
Process
Worker B
↓
Mencoba Product #10
↓
Tidak dapat lock
Dengan demikian dua Job yang memproses resource yang sama tidak berjalan bersamaan.
ShouldBeUnique vs WithoutOverlapping
Keduanya sering membingungkan.
ShouldBeUnique
Lebih fokus pada:
Apakah Job duplicate perlu masuk/aktif?
WithoutOverlapping
Lebih fokus pada:
Apakah Job dengan resource yang sama boleh berjalan bersamaan?
Contoh:
SyncProduct(10)
SyncProduct(10)
Jika keduanya masuk Queue, WithoutOverlapping dapat mencegah keduanya berjalan bersamaan berdasarkan lock yang digunakan.
Lock Redis untuk Mencegah Concurrent Processing
Jika aplikasi menggunakan Redis, lock juga dapat digunakan.
Contoh konsep:
$lock = Cache::lock('order:' . $orderId, 60);
if ($lock->get()) {
try {
// Proses order
} finally {
$lock->release();
}
}
Konsepnya:
Worker A
↓
Lock order:100
↓
Process
Worker B
↓
Lock order:100
↓
Tidak mendapatkan lock
TTL lock harus dipilih dengan hati-hati.
Lock yang terlalu pendek dapat habis sebelum pekerjaan selesai.
Lock yang terlalu panjang dapat menghambat proses ketika Worker benar-benar crash.
Gunakan Database Transaction
Untuk operasi database yang harus konsisten, transaction dapat membantu.
Contoh:
DB::transaction(function () use ($order) {
$order->update([
'status' => 'processed',
]);
OrderLog::create([
'order_id' => $order->id,
'action' => 'processed',
]);
});
Jika salah satu operasi gagal, transaction dapat di-rollback.
Namun transaction tidak membuat Job otomatis idempotent.
Misalnya:
Database transaction
↓
Update database
↓
Call API eksternal
Transaction database tidak dapat membatalkan request yang sudah dikirim ke API eksternal.
Karena itu, efek samping eksternal harus ditangani dengan strategi tersendiri.
Hati-Hati dengan API Eksternal
Salah satu kasus paling sulit adalah:
Laravel
↓
POST Payment API
↓
Payment berhasil
↓
Network timeout
Laravel melihat:
Timeout
Kemudian Queue melakukan retry.
Retry
↓
POST Payment API
↓
Payment berhasil lagi?
Tanpa idempotency key, payment gateway dapat memproses transaksi kedua.
Solusinya adalah menggunakan idempotency key jika API yang digunakan mendukungnya.
Contoh konsep:
Idempotency-Key:
PAYMENT-ORDER-10001
Request pertama:
PAYMENT-ORDER-10001
→ Process
Request kedua:
PAYMENT-ORDER-10001
→ Return existing result
Dengan begitu retry menjadi lebih aman.
Job Berjalan Dua Kali Karena retry_after
Mari lihat contoh yang sering terjadi.
Konfigurasi:
timeout = 120
retry_after = 90
Job membutuhkan:
100 detik
Alurnya:
00s Worker A mengambil Job
30s Job masih berjalan
60s Job masih berjalan
90s Job kembali terlihat tersedia
100s Worker A selesai
Jika Worker B mengambil Job pada detik ke-90:
Worker A → Job
Worker B → Job
Sekarang Job yang sama berjalan dua kali.
Karena itu, perhatikan hubungan timeout dengan retry_after.
Jangan Membuat retry_after Terlalu Pendek
Jika Job normalnya membutuhkan 60 detik, jangan membuat:
retry_after = 30
karena Worker lain dapat mengambil Job tersebut sebelum Worker pertama selesai.
Lebih aman memiliki margin:
Job timeout
<
retry_after
Contoh:
timeout = 60
retry_after = 90
atau sesuaikan dengan durasi maksimal Job dan karakteristik workload.
Jangan Membuat Timeout Terlalu Tinggi
Sebaliknya, jangan langsung menggunakan:
timeout = 3600
hanya agar duplicate processing tidak terjadi.
Jika Job sebenarnya hang selama satu jam:
Worker
↓
Job stuck
↓
1 jam
↓
baru dianggap timeout
Worker menjadi tidak efektif.
Lebih baik cari penyebab Job lambat dan tentukan timeout berdasarkan kebutuhan nyata.
Cek Konfigurasi Worker
Misalnya:
php artisan queue:work redis \
--sleep=3 \
--tries=3 \
--timeout=120
Perhatikan:
--tries
--timeout
--sleep
Worker juga dapat memiliki konfigurasi memory dan lifecycle.
Untuk production, parameter tersebut harus disesuaikan dengan workload.
Worker Jangan Terlalu Banyak
Menambah Worker memang meningkatkan throughput.
Misalnya:
1 Worker
↓
10 Job/detik
Kemudian:
10 Worker
↓
100 Job/detik
Namun bukan berarti semakin banyak Worker selalu lebih baik.
Terlalu banyak Worker dapat menyebabkan:
- Database overload
- Redis overload
- API rate limit
- CPU tinggi
- Memory habis
- Lock contention
Dan pada proses tertentu, concurrency berlebihan justru meningkatkan risiko konflik.
Cek Supervisor
Jika menggunakan Supervisor:
sudo supervisorctl status
Pastikan Worker yang aktif memang sesuai konfigurasi.
Contohnya:
laravel-worker:laravel-worker_00 RUNNING
laravel-worker:laravel-worker_01 RUNNING
laravel-worker:laravel-worker_02 RUNNING
Jika ternyata ada dua konfigurasi Supervisor berbeda yang menjalankan queue yang sama tanpa sengaja, jumlah Worker bisa jauh lebih banyak daripada yang diperkirakan.
Periksa Apakah Job Memang Duplicate
Jika menemukan:
Order #100 diproses dua kali
jangan langsung menyimpulkan Queue bermasalah.
Cari tahu:
Apakah Job di-dispatch dua kali?
↓
Ya → Perbaiki dispatch
Tidak
↓
Apakah Job retry?
↓
Ya → Periksa timeout/error
Tidak
↓
Apakah Worker concurrent?
↓
Ya → Periksa retry_after/lock
Tidak
↓
Periksa webhook/event
Debugging menjadi jauh lebih mudah jika kita mengetahui di mana duplicate terjadi.
Tambahkan Logging pada Job
Saat debugging, tambahkan log:
Log::info('ProcessOrder started', [
'order_id' => $this->orderId,
]);
Dan:
Log::info('ProcessOrder finished', [
'order_id' => $this->orderId,
]);
Kita juga dapat mencatat identifier Job atau informasi attempt yang tersedia pada konteks Job.
Tujuannya adalah mengetahui:
Job A
started
finished
atau
Job A
started
started
finished
finished
Dari sini kita dapat membedakan duplicate dispatch dengan retry/concurrent processing.
Cek attempts()
Dalam Job, informasi attempt dapat digunakan untuk debugging.
Contohnya:
public function handle(): void
{
Log::info('Processing order', [
'order_id' => $this->orderId,
'attempt' => $this->attempts(),
]);
// Proses
}
Jika log menunjukkan:
attempt = 1
attempt = 2
kemungkinan Job yang sama sedang mengalami retry.
Jika terdapat dua Job berbeda dengan attempt masing-masing:
Job A → attempt 1
Job B → attempt 1
kemungkinan aplikasi melakukan duplicate dispatch.
Cara Mencegah Job Diproses Dua Kali
Tidak ada satu solusi untuk semua kasus.
Gunakan kombinasi sesuai kebutuhan.
1. Buat Job Idempotent
Pastikan menjalankan Job dua kali tidak menyebabkan efek samping ganda.
2. Gunakan Unique Constraint
Contohnya:
$table->unique('transaction_id');
Database menjadi lapisan perlindungan terakhir.
3. Gunakan ShouldBeUnique
Untuk mencegah duplicate Job berdasarkan identifier tertentu.
4. Gunakan WithoutOverlapping
Untuk mencegah dua Job dengan resource sama berjalan bersamaan.
5. Gunakan Lock
Redis atau cache lock dapat digunakan untuk operasi yang membutuhkan mutual exclusion.
6. Atur timeout dan retry_after
Pastikan Job tidak tersedia kembali ketika Worker sebenarnya masih memprosesnya.
7. Gunakan Graceful Worker Restart
Saat deployment:
php artisan queue:restart
8. Gunakan Idempotency Key untuk API
Terutama untuk:
- Payment
- Order
- External API
Contoh Job yang Lebih Aman
Misalnya kita memiliki Job untuk memproses order.
Daripada:
public function handle(): void
{
$this->order->increment('processed_count');
}
kita dapat menggunakan status atau record proses.
Contoh sederhana:
public function handle(): void
{
$order = Order::findOrFail($this->orderId);
if ($order->processed_at) {
return;
}
$order->update([
'processed_at' => now(),
]);
}
Jika Job berjalan dua kali:
Attempt 1
↓
processed_at kosong
↓
Set processed_at
Attempt 2
↓
processed_at sudah ada
↓
return
Untuk proses yang lebih kompleks, gunakan transaction dan locking yang sesuai.
Contoh dengan Database Lock
Untuk kondisi concurrent, kita dapat menggunakan row lock:
DB::transaction(function () {
$order = Order::query()
->lockForUpdate()
->findOrFail($this->orderId);
if ($order->processed_at) {
return;
}
$order->update([
'processed_at' => now(),
]);
});
Jika dua Worker mencoba memproses order yang sama secara bersamaan:
Worker A
↓
lockForUpdate()
↓
Process
Worker B
↓
Menunggu lock
↓
Membaca kondisi terbaru
↓
processed_at sudah terisi
↓
Tidak memproses lagi
Ini dapat membantu pada kasus concurrent database processing.
Jangan Hanya Mengandalkan ShouldBeUnique
Misalnya:
class ProcessPayment implements ShouldQueue, ShouldBeUnique
Ini bagus.
Tetapi tetap jangan menganggap:
ShouldBeUnique
=
Exactly Once
Unique Job membantu mencegah duplicate Job dalam kondisi tertentu.
Tetapi Job tetap harus aman terhadap:
- Worker crash
- Timeout
- Retry
- External API timeout
- Server restart
Karena itu:
Unique Job
+
Idempotency
lebih kuat.
Kapan Duplicate Job Justru Normal?
Dalam sistem Queue, duplicate execution dapat terjadi karena sifat at-least-once processing.
Jadi:
Duplicate execution
≠ selalu bug Laravel
Yang menjadi masalah adalah ketika aplikasi tidak siap menghadapinya.
Contohnya:
Job dijalankan dua kali
↓
Email terkirim dua kali
Jika email ganda tidak diinginkan, aplikasi perlu memiliki strategi deduplikasi atau idempotency yang sesuai.
Bagaimana Menentukan Solusi yang Tepat?
Gunakan panduan sederhana berikut.
| Masalah | Solusi yang Dipertimbangkan |
|---|---|
| Job di-dispatch dua kali | Perbaiki dispatch |
| Job sama berjalan bersamaan | WithoutOverlapping / lock |
| Duplicate dispatch | ShouldBeUnique |
| Retry menyebabkan efek ganda | Idempotency |
| Payment diproses dua kali | Idempotency key |
| Database duplicate | Unique constraint |
| Worker timeout | Perbaiki timeout |
| Job muncul kembali terlalu cepat | Periksa retry_after |
| Deployment mengganggu Worker | queue:restart |
| Webhook duplicate | Idempotent webhook |
| Job gagal setelah efek samping | Idempotent processing |
Checklist Debugging Laravel Queue Job yang Berjalan Dua Kali
Jika mengalami masalah ini di production, cek secara berurutan:
Cek Apakah Job Di-dispatch Dua Kali
Cari:
SomeJob::dispatch(...)
Periksa controller, event, listener, command, scheduler, dan webhook.
Cek Attempt
Tambahkan logging:
$this->attempts()
Jika attempt bertambah:
1 → 2 → 3
berarti ada retry.
Cek Timeout
Periksa:
public $timeout = 120;
dan konfigurasi Worker.
Cek retry_after
Pastikan tidak lebih kecil daripada durasi Job yang dibutuhkan.
Cek Worker
php artisan queue:work
dan:
sudo supervisorctl status
Cek Log
Periksa:
storage/logs/laravel.log
dan log Supervisor.
Cek Redis
Jika menggunakan Redis:
redis-cli ping
Pastikan Redis normal.
Cek Idempotency
Tanyakan:
Jika Job ini dijalankan dua kali, apakah data tetap benar?
Jika jawabannya tidak, Job perlu diperbaiki.
Kesimpulan
Laravel Queue Job yang bekerja dua kali tidak selalu berarti Laravel mengalami bug. Dalam sistem Queue, sebuah Job dapat diproses kembali ketika terjadi timeout, Worker crash, server restart, retry, deployment, atau kondisi concurrency tertentu.
Penyebab yang paling penting untuk diperiksa adalah:
timeout
retry_after
Worker crash
duplicate dispatch
retry
concurrency
webhook duplicate
Untuk mencegah efek buruk dari duplicate processing, gunakan kombinasi:
Idempotency
+
Unique Constraint
+
ShouldBeUnique
+
WithoutOverlapping
+
Lock
+
timeout & retry_after yang benar
Untuk Job sederhana, pengecekan status mungkin sudah cukup:
if ($order->processed_at) {
return;
}
Untuk proses yang lebih kritis seperti payment, jangan hanya mengandalkan pengecekan sederhana. Gunakan idempotency key, unique constraint, transaction, dan mekanisme locking sesuai kebutuhan.
Prinsip yang paling penting adalah:
Anggap setiap Laravel Queue Job dapat dijalankan lebih dari sekali, lalu desain Job agar aman ketika hal tersebut terjadi.
Dengan prinsip tersebut, aplikasi akan jauh lebih tahan terhadap retry, Worker crash, deployment, concurrency, dan gangguan service eksternal.
