Laravel Queue dengan Redis Lock berguna saat satu pekerjaan tidak boleh diproses dua kali pada waktu yang sama atau dalam jendela waktu tertentu. Masalah ini sering muncul ketika ada banyak worker paralel, job di-retry, worker timeout lalu proses lama masih berjalan, atau worker crash sebelum status pekerjaan tersimpan dengan benar.

Pada kasus seperti kirim email, sinkron stok, atau generate invoice, eksekusi ganda bisa menyebabkan efek samping nyata: email terkirim dua kali, stok menjadi salah, atau invoice terbuat lebih dari satu. Solusi yang aman biasanya bukan hanya queue biasa, tetapi kombinasi lock, desain idempoten, dan observabilitas yang memadai.

Mengapa job ganda bisa terjadi di Laravel Queue

Queue tidak otomatis menjamin bahwa sebuah job hanya akan dieksekusi tepat satu kali. Dalam praktiknya, sistem queue umumnya bekerja dengan model at least once delivery: job bisa diproses lebih dari sekali pada kondisi tertentu. Itu bukan bug, tetapi karakteristik sistem terdistribusi.

Skenario umum penyebab duplikasi

  • Worker paralel: dua worker mengambil pekerjaan yang secara logis mewakili entitas yang sama, misalnya sinkron stok untuk produk yang sama.
  • Retry: job gagal, di-retry, tetapi eksekusi sebelumnya sebenarnya sempat menyentuh sistem eksternal.
  • Timeout: worker dianggap timeout oleh supervisor, lalu job dijalankan lagi sementara proses lama belum benar-benar berhenti.
  • Crash worker: worker mati setelah memproses sebagian logika tetapi sebelum menandai job selesai.
  • Race condition: dua request hampir bersamaan mendispatch job yang sama sebelum status lock atau flag tersimpan.

Karena itu, pencegahan job ganda tidak cukup hanya mengandalkan “jangan dispatch dua kali”. Anda perlu pengaman di sisi eksekusi.

Kapan memakai Redis lock, kapan cukup idempotensi

Idempotensi berarti eksekusi berulang menghasilkan efek akhir yang sama. Ini tetap penting, bahkan jika Anda sudah memakai lock. Namun lock dan idempotensi menyelesaikan masalah yang berbeda.

Gunakan idempotensi ketika

  • Anda bisa memberi operation key unik pada aksi, misalnya order_id atau external_request_id.
  • Sistem tujuan mendukung deduplikasi, misalnya tabel dengan unique constraint atau API eksternal dengan idempotency key.
  • Efek samping harus aman walaupun request datang lebih dari sekali.

Gunakan Redis lock ketika

  • Anda ingin mencegah eksekusi paralel untuk resource yang sama.
  • Proses mahal dan tidak aman jika berjalan bersamaan, misalnya generate invoice per order atau sinkron stok per SKU.
  • Anda butuh proteksi cepat di level aplikasi tanpa menunggu constraint di database “berteriak” belakangan.

Gunakan keduanya untuk hasil terbaik

Lock mencegah dua worker masuk ke blok kritis secara bersamaan. Idempotensi melindungi Anda jika lock kedaluwarsa, worker crash, atau job masuk ulang setelah proses sebagian berhasil. Dalam sistem nyata, lock bukan pengganti idempotensi.

Pola lock yang aman untuk Laravel Queue

Pilih kunci lock berdasarkan resource, bukan job class saja

Kesalahan umum adalah memakai satu key global seperti lock:send-email. Ini terlalu kasar dan membuat job yang berbeda saling menghambat. Sebaliknya, buat key lock berdasarkan identitas resource yang memang harus eksklusif.

Contoh yang lebih aman:

  • lock:invoice:order:{orderId}
  • lock:stock-sync:sku:{sku}
  • lock:email:user:{userId}:template:{template}

Pola ini menjaga granularitas. Job untuk order A tidak memblokir order B, tetapi tetap mencegah dua worker memproses order A bersamaan.

TTL harus lebih lama dari durasi normal, tetapi tidak terlalu lama

Time to live lock menentukan kapan lock kedaluwarsa otomatis jika worker gagal me-release lock. TTL terlalu pendek berbahaya karena lock bisa habis saat proses masih berjalan; worker lain lalu masuk dan terjadilah duplikasi. TTL terlalu panjang juga buruk karena lock yatim akan memblokir pekerjaan terlalu lama setelah crash.

Sebagai aturan praktis:

  • Mulai dari estimasi durasi terburuk yang realistis, bukan rata-rata.
  • Sesuaikan dengan timeout job, latensi API eksternal, dan kemungkinan backlog.
  • Jika proses sangat lama dan tidak bisa diprediksi, pertimbangkan desain lain seperti status berbasis database atau pemecahan job menjadi unit lebih kecil.

Jika job normalnya selesai dalam 10-20 detik, TTL 60-120 detik biasanya lebih masuk akal daripada 15 detik. Tujuannya memberi ruang untuk jitter, retry jaringan, dan beban worker.

Fail-open vs fail-close

Saat lock tidak bisa diperoleh, Anda perlu menentukan perilaku sistem.

  • Fail-close: job tidak lanjut diproses. Cocok untuk operasi yang tidak boleh dobel, seperti generate invoice atau posting transaksi.
  • Fail-open: job tetap jalan walau lock gagal. Ini jarang cocok untuk pencegahan duplikasi, tetapi kadang dipakai pada proses yang lebih mementingkan ketersediaan daripada eksklusivitas, misalnya refresh cache non-kritis.

Untuk kebanyakan kasus artikel ini, pilih fail-close.

Implementasi Laravel Queue dengan Redis lock

Laravel menyediakan abstraksi lock melalui cache driver yang mendukung atomic lock, termasuk Redis. Jadi pendekatan yang umum adalah memakai Cache::lock() dengan driver cache Redis.

Konfigurasi dasar

Pastikan aplikasi menggunakan Redis untuk cache/lock. Detail file konfigurasi bisa berbeda antar proyek, tetapi prinsipnya:

  • Redis aktif dan dapat diakses aplikasi.
  • Cache store yang dipakai untuk lock menunjuk ke Redis.
  • Worker queue berjalan stabil dan timeout disetel sesuai karakter job.

Contoh pemeriksaan lingkungan:

php artisan queue:work
php artisan tinker

Di tinker, Anda bisa menguji lock sederhana:

use Illuminate\Support\Facades\Cache;

$lock = Cache::lock('test-lock', 30);
$lock->get(); // true jika berhasil
$lock->release();

Contoh job: generate invoice per order

Kasus ini cocok untuk lock karena satu order seharusnya hanya punya satu proses pembuatan invoice aktif pada satu waktu.

<?php

namespace App\Jobs;

use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Throwable;

class GenerateInvoiceJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $timeout = 90;
    public int $tries = 3;

    public function __construct(public int $orderId)
    {
    }

    public function handle(): void
    {
        $lockKey = "lock:invoice:order:{$this->orderId}";
        $lockTtlSeconds = 120;

        $lock = Cache::lock($lockKey, $lockTtlSeconds);

        if (! $lock->get()) {
            Log::warning('Invoice job skipped because lock is held', [
                'order_id' => $this->orderId,
                'lock_key' => $lockKey,
                'job_id' => optional($this->job)->getJobId(),
            ]);

            // Fail-close: jangan proses dobel.
            // Pilihan: release dengan delay jika Anda ingin mencoba lagi.
            $this->release(10);
            return;
        }

        try {
            $order = Order::query()->findOrFail($this->orderId);

            // Idempotency guard tambahan.
            if ($order->invoice_number) {
                Log::info('Invoice already exists, skipping duplicate execution', [
                    'order_id' => $order->id,
                ]);
                return;
            }

            // Lakukan pekerjaan inti di sini.
            // Misalnya generate nomor invoice dan simpan secara atomik.
            $order->invoice_number = $this->generateInvoiceNumber($order);
            $order->save();

            Log::info('Invoice generated', [
                'order_id' => $order->id,
                'invoice_number' => $order->invoice_number,
            ]);
        } catch (Throwable $e) {
            Log::error('Generate invoice failed', [
                'order_id' => $this->orderId,
                'error' => $e->getMessage(),
            ]);

            throw $e;
        } finally {
            // Release hanya lock yang memang dimiliki eksekusi ini.
            optional($lock)->release();
        }
    }

    private function generateInvoiceNumber(Order $order): string
    {
        return 'INV-' . $order->id . '-' . now()->format('YmdHis');
    }
}

Mengapa pola di atas bekerja

  • Lock key granular: hanya memblokir order yang sama.
  • Fail-close: jika lock sedang dipegang worker lain, job tidak lanjut mengeksekusi blok kritis.
  • Idempotency guard: jika lock kedaluwarsa atau job diproses ulang, invoice yang sudah ada mencegah efek samping ganda.
  • Release di finally: lock dilepas baik proses sukses maupun gagal.

Catatan penting tentang release lock

Release lock harus dilakukan oleh pemilik lock yang sama. Hindari menghapus key Redis secara manual dengan operasi generik seperti DEL dari luar alur lock, karena Anda berisiko melepaskan lock milik proses lain. Gunakan API lock Laravel agar ownership lock tetap terjaga.

Pendekatan middleware job

Jika Anda punya banyak job dengan pola serupa, middleware job membuat implementasi lebih konsisten. Intinya, middleware memeriksa lock sebelum memanggil $next($job).

Contoh sederhana:

<?php

namespace App\Jobs\Middleware;

use Closure;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

class WithoutOverlappingResource
{
    public function __construct(
        private string $key,
        private int $ttl = 120,
        private int $releaseAfter = 10,
    ) {
    }

    public function handle(object $job, Closure $next): void
    {
        $lock = Cache::lock($this->key, $this->ttl);

        if (! $lock->get()) {
            Log::warning('Job overlap prevented by lock', [
                'lock_key' => $this->key,
                'job_class' => get_class($job),
            ]);

            if (method_exists($job, 'release')) {
                $job->release($this->releaseAfter);
            }

            return;
        }

        try {
            $next($job);
        } finally {
            $lock->release();
        }
    }
}

Pemakaian pada job:

public function middleware(): array
{
    return [
        new \App\Jobs\Middleware\WithoutOverlappingResource(
            key: "lock:stock-sync:sku:{$this->sku}",
            ttl: 180,
            releaseAfter: 15,
        ),
    ];
}

Kelebihan middleware adalah aturan lock tersentralisasi. Kekurangannya, Anda tetap harus memikirkan idempotensi di logika bisnis karena middleware hanya mengatur eksklusivitas eksekusi.

Kasus nyata dan strategi yang tepat

Kirim email

Untuk email, lock bisa mencegah dua worker mengirim email yang sama secara paralel. Tetapi lock saja belum cukup jika job di-retry setelah email terlanjur terkirim. Solusi yang lebih aman adalah menyimpan catatan pengiriman dengan key unik, misalnya kombinasi user_id dan jenis notifikasi, lalu cek status sebelum kirim.

Kesimpulan: email biasanya butuh lock + idempotensi.

Sinkron stok

Pada sinkron stok per SKU, lock per SKU mencegah dua sinkronisasi saling menimpa. Ini penting jika sumber data eksternal lambat atau ada kalkulasi incremental. Namun jika update stok berbasis angka final terbaru, Anda juga perlu memastikan urutan data dan versi update, bukan hanya lock.

Kesimpulan: lock mencegah overlap, tapi tidak menyelesaikan masalah stale data.

Generate invoice

Ini kasus kuat untuk fail-close. Dua invoice untuk order yang sama biasanya tidak bisa ditoleransi. Selain lock, gunakan unique constraint atau validasi status invoice di database.

Kesimpulan: lock + guard database adalah kombinasi yang sehat.

Risiko lock kedaluwarsa dan cara menguranginya

Masalah paling berbahaya adalah lock habis sebelum job selesai. Jika ini terjadi, worker lain bisa memperoleh lock baru dan masuk ke blok kritis saat proses lama masih berjalan.

Tanda-tandanya

  • Log menunjukkan dua eksekusi untuk resource yang sama dalam rentang waktu berdekatan.
  • Ada efek samping ganda walau lock sudah diterapkan.
  • Kasus lebih sering muncul saat beban tinggi atau API eksternal melambat.

Mitigasi

  • Set TTL dengan margin aman berdasarkan durasi terburuk realistis.
  • Pertahankan idempotensi pada perubahan database atau request eksternal.
  • Pecah job panjang menjadi beberapa langkah lebih kecil jika memungkinkan.
  • Selaraskan timeout worker dan TTL lock agar tidak saling bertentangan.

Jika timeout job lebih pendek daripada durasi kerja nyata, worker bisa menghentikan job lalu queue menganggapnya gagal. Dalam kondisi tertentu, eksekusi lama masih sempat meninggalkan efek samping. Itulah sebabnya penyelarasan timeout, retry, dan TTL penting.

Observabilitas: jangan pakai lock tanpa jejak

Tanpa observabilitas, Anda sulit membedakan apakah lock berhasil mencegah overlap atau justru menahan job terlalu lama. Minimal, catat event penting berikut:

  • Lock berhasil didapat.
  • Lock gagal didapat.
  • Job dilepas ulang karena lock sedang dipegang.
  • Durasi eksekusi job.
  • Jumlah retry.
  • Key resource yang diproses.

Contoh field log yang berguna:

[
  'job_class' => 'GenerateInvoiceJob',
  'job_id' => '...',
  'lock_key' => 'lock:invoice:order:123',
  'order_id' => 123,
  'attempt' => 2,
  'worker' => gethostname(),
]

Jika Anda memakai sistem monitoring, pantau metrik berikut:

  • Jumlah job yang tertunda karena lock.
  • Rasio retry terhadap total job.
  • Durasi job per jenis pekerjaan.
  • Jumlah kegagalan yang terkait timeout atau crash worker.

Pengujian lokal untuk mensimulasikan race condition

Masalah job ganda sering tidak terlihat di lingkungan lokal jika Anda hanya punya satu worker. Untuk mengujinya, jalankan beberapa worker dan dispatch job untuk resource yang sama secara cepat.

Langkah uji sederhana

  1. Pastikan Redis aktif.
  2. Jalankan dua atau lebih worker queue di terminal terpisah.
  3. Dispatch beberapa job dengan key resource sama, misalnya order ID yang sama.
  4. Tambahkan sleep() sementara di dalam job agar overlap lebih mudah terlihat saat testing.

Contoh dispatch dari route debug atau tinker:

GenerateInvoiceJob::dispatch(123);
GenerateInvoiceJob::dispatch(123);
GenerateInvoiceJob::dispatch(123);

Ekspektasi hasil yang benar:

  • Hanya satu worker masuk ke blok kritis pada satu waktu.
  • Job lain me-release kembali ke queue atau berhenti sesuai strategi fail-close.
  • Tidak ada invoice ganda.

Hal yang perlu diuji secara eksplisit

  • Retry: paksa exception setelah sebagian proses dan pastikan idempotency guard bekerja.
  • Timeout: simulasikan kerja melebihi timeout lalu lihat apakah ada duplikasi.
  • Crash: hentikan worker di tengah proses dan amati perilaku setelah TTL lock habis.
  • Kontensi tinggi: dispatch puluhan job ke resource yang sama dan pastikan throughput tetap masuk akal.

Kesalahan umum yang sering terjadi

  • Key lock terlalu umum, sehingga job berbeda saling memblokir tanpa perlu.
  • TTL terlalu pendek, menyebabkan overlap justru tetap terjadi.
  • Mengandalkan lock tanpa idempotensi, padahal lock bisa kedaluwarsa atau proses bisa crash.
  • Release lock manual dengan operasi Redis mentah, yang bisa melepaskan lock milik proses lain.
  • Tidak mencatat log lock contention, sehingga sulit tahu kenapa job terasa lambat atau sering retry.
  • Tidak menyelaraskan timeout, retry, dan TTL.

Checklist operasional untuk produksi

  • Gunakan Redis yang stabil dan latensi rendah untuk cache lock.
  • Tentukan key lock berdasarkan resource yang benar-benar perlu eksklusif.
  • Set TTL lock berdasarkan durasi terburuk yang realistis, bukan rata-rata.
  • Terapkan idempotency guard di level database atau integrasi eksternal.
  • Selaraskan timeout worker, retry, dan TTL lock.
  • Log semua event lock penting: acquired, skipped, released, failed.
  • Pantau job retry, timeout, dan lock contention.
  • Uji skenario crash worker dan timeout sebelum go-live.
  • Hindari lock global kecuali memang diperlukan.
  • Tinjau apakah job panjang bisa dipecah menjadi unit lebih kecil.

Penutup

Laravel Queue dengan Redis lock adalah pendekatan praktis untuk mencegah job ganda ketika beberapa worker memproses resource yang sama. Namun lock tidak cukup jika berdiri sendiri. Untuk sistem yang aman di dunia nyata, gunakan lock untuk mencegah overlap, idempotensi untuk menahan duplikasi yang lolos, dan observabilitas untuk membuktikan bahwa mekanisme tersebut benar-benar bekerja.

Jika Anda menangani proses seperti kirim email, sinkron stok, atau generate invoice, mulai dari tiga hal ini: key lock yang granular, TTL yang realistis, dan guard idempoten di jalur efek samping. Dari sana, tambahkan logging, pengujian race condition, dan checklist operasional agar perilaku queue tetap dapat diprediksi saat traffic naik atau worker bermasalah.