Cache stampede terjadi ketika banyak request atau job memicu recompute cache yang sama secara bersamaan. Di sisi lain, worker overlap terjadi saat beberapa job queue memproses resource yang sama secara paralel, sehingga beban naik, retry menumpuk, dan data berisiko tidak konsisten.

Pada Laravel Queue, dua masalah ini biasanya tidak selesai hanya dengan menambah worker. Solusi yang efektif adalah menggabungkan cache lock, unique job, middleware WithoutOverlapping, rate limiting, backoff, TTL lock yang tepat, dan strategi stale-while-revalidate. Artikel ini fokus pada pola implementasi yang bisa langsung dipakai di lingkungan produksi dengan Redis.

Masalah Nyata di Produksi

Gejala yang umum muncul:

  • Banyak request membaca key cache yang sama, mendapati cache kosong, lalu semuanya menghitung ulang data berat secara bersamaan.
  • Beberapa job queue untuk resource yang sama masuk hampir bersamaan, lalu semua worker mengeksekusi proses yang sama.
  • Retry akibat timeout atau error sementara justru memperparah lonjakan beban karena job lama dan job retry sama-sama berjalan.
  • Update data menjadi balapan: job yang lebih lambat menimpa hasil yang lebih baru.

Jika dibiarkan, dampaknya bukan hanya CPU dan Redis naik. Database juga ikut terpukul karena query berat dieksekusi berulang, antrian memanjang, dan latency menyebar ke endpoint lain yang sebenarnya tidak bermasalah.

Peta Solusi: Pilih Mekanisme Sesuai Masalah

1. Cache lock

Gunakan saat Anda ingin memastikan hanya satu proses yang boleh melakukan recompute cache untuk key tertentu. Cocok untuk mencegah cache stampede dari request HTTP maupun job queue.

2. Unique job

Gunakan saat Anda ingin mencegah duplikasi job sejak fase dispatch. Ini berguna ketika banyak event atau request mencoba mengantrekan job yang sama berulang kali.

3. WithoutOverlapping

Gunakan saat job boleh tetap didispatch berkali-kali, tetapi tidak boleh dieksekusi bersamaan untuk resource yang sama. Ini fokus ke fase eksekusi worker, bukan fase enqueue.

4. Rate limiting worker

Gunakan untuk membatasi laju eksekusi job tertentu agar Redis, database, atau API eksternal tidak dibanjiri, terutama saat backlog besar atau retry meningkat.

5. Backoff

Gunakan agar retry tidak langsung menghantam sistem lagi. Backoff membantu meredam lonjakan saat lock sedang dipegang proses lain, service dependency lambat, atau resource sedang padat.

6. Stale-while-revalidate

Gunakan saat data tidak harus selalu real-time. Daripada semua request menunggu recompute, layani data lama yang masih bisa ditoleransi, lalu segarkan di background.

Arsitektur Praktis dengan Redis

Untuk kasus Laravel Queue, kombinasi berikut biasanya efektif:

  1. Request membaca cache.
  2. Jika cache masih segar, langsung kembalikan.
  3. Jika cache sudah stale tetapi masih bisa dipakai, kembalikan data stale dan dispatch job refresh.
  4. Job refresh dibuat unik atau diberi kunci resource agar tidak terantre/berjalan paralel.
  5. Di dalam job, gunakan lock lagi sebelum recompute untuk perlindungan tambahan.
  6. Jika lock tidak didapat, job dilepas kembali dengan delay atau diabaikan sesuai kebutuhan.

Poin penting: satu mekanisme saja sering tidak cukup. Unique job mencegah duplikasi saat dispatch, tetapi tidak selalu menggantikan lock saat eksekusi. Tanpa lock di level resource, proses dari jalur lain masih bisa recompute key yang sama.

Mencegah Cache Stampede dengan Cache Lock

Pola dasar

Saat cache miss terjadi, ambil lock per key cache. Hanya pemegang lock yang melakukan recompute dan menulis ulang cache. Proses lain bisa menunggu sebentar, mengambil data stale, atau keluar lebih cepat tergantung SLA aplikasi.

<?php

use Illuminate\Support\Facades\Cache;

function getProductSummary(int $productId): array
{
    $cacheKey = "product:summary:{$productId}";
    $lockKey = "lock:{$cacheKey}";

    $cached = Cache::get($cacheKey);
    if ($cached) {
        return $cached;
    }

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

    try {
        if ($lock->get()) {
            $cached = Cache::get($cacheKey);
            if ($cached) {
                return $cached;
            }

            $data = app(ProductSummaryService::class)->build($productId);
            Cache::put($cacheKey, $data, now()->addMinutes(10));

            return $data;
        }

        $cached = Cache::get($cacheKey);
        if ($cached) {
            return $cached;
        }

        throw new \RuntimeException('Cache sedang direcompute, coba lagi.');
    } finally {
        optional($lock)->release();
    }
}

Mengapa pola ini bekerja

Lock mencegah banyak proses masuk ke blok recompute secara bersamaan. Pemeriksaan cache kedua setelah lock berhasil didapat penting untuk menghindari recompute ganda jika proses lain lebih dulu mengisi cache sesaat sebelum lock diambil.

TTL lock yang tepat

TTL lock harus lebih lama dari durasi normal recompute, tetapi tidak terlalu panjang. Jika terlalu pendek, lock bisa kedaluwarsa saat job masih berjalan dan worker lain masuk. Jika terlalu panjang, lock macet akan menahan sistem lebih lama.

Pendekatannya praktis:

  • Ambil patokan dari durasi p95 atau p99 recompute, bukan rata-rata.
  • Tambahkan margin untuk lonjakan sesaat.
  • Evaluasi kembali jika payload, query, atau dependency berubah.

Jangan mengandalkan lock tanpa TTL. Saat worker crash, proses dibunuh, atau koneksi putus, lock tanpa kedaluwarsa berisiko membuat resource terkunci terlalu lama.

Mencegah Duplikasi Job dengan Unique Job

Jika banyak request mengantrekan job refresh cache yang sama, lebih baik cegah duplikasi sejak awal. Di Laravel, pola ini biasanya diterapkan dengan job unik berdasarkan resource, misalnya per product ID atau tenant ID.

<?php

namespace App\Jobs;

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 RefreshProductSummary implements ShouldQueue, ShouldBeUnique
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public int $productId) {}

    public function uniqueId(): string
    {
        return 'product-summary:' . $this->productId;
    }

    public function handle(): void
    {
        app(ProductSummaryCacheRefresher::class)->refresh($this->productId);
    }
}

Kapan dipakai: saat Anda tidak ingin antrean dipenuhi job identik. Ini efektif untuk event yang sangat sering, misalnya banyak update kecil pada entity yang sama.

Trade-off unique job

  • Bagus untuk meredam ledakan enqueue.
  • Tidak otomatis menyelesaikan overlap dari jalur eksekusi lain yang memakai kunci berbeda atau proses manual di luar job.
  • Jika desain terlalu agresif, perubahan penting bisa terwakili hanya oleh satu job, sehingga Anda perlu memastikan satu job refresh memang cukup untuk menghasilkan state terbaru.

Mencegah Worker Overlap dengan WithoutOverlapping

Jika job tetap boleh muncul berkali-kali tetapi tidak boleh jalan bersamaan untuk resource yang sama, gunakan middleware WithoutOverlapping. Ini sangat berguna untuk sinkronisasi ke sistem eksternal, rebuild cache per resource, atau update agregat yang rawan race condition.

<?php

namespace App\Jobs;

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 RebuildTenantDashboard implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public int $tenantId) {}

    public function middleware(): array
    {
        return [
            (new WithoutOverlapping('tenant-dashboard:' . $this->tenantId))
                ->releaseAfter(10)
                ->expireAfter(120),
        ];
    }

    public function handle(): void
    {
        app(TenantDashboardBuilder::class)->rebuild($this->tenantId);
    }
}

Alur eksekusi middleware ini

  1. Worker mengambil job dari queue.
  2. Middleware mencoba mengambil lock berdasarkan key overlap.
  3. Jika lock tersedia, job dieksekusi.
  4. Jika lock tidak tersedia, job tidak diproses bersamaan; biasanya akan dilepas kembali sesuai konfigurasi.

Kapan memilih WithoutOverlapping dibanding unique job

  • Unique job: cegah duplikasi saat dispatch.
  • WithoutOverlapping: izinkan job masuk queue, tapi serialkan eksekusi untuk resource yang sama.

Sering kali keduanya dipakai bersama: unique job untuk menahan spam enqueue, lalu WithoutOverlapping untuk perlindungan di sisi worker.

Stale-While-Revalidate untuk Menahan Lonjakan

Jika pengguna masih bisa menerima data yang sedikit lama, stale-while-revalidate adalah pola yang sangat efektif. Intinya, Anda simpan dua horizon waktu:

  • fresh TTL: sampai kapan data dianggap segar.
  • stale TTL: sampai kapan data lama masih boleh dilayani sambil menunggu refresh background.

Dengan pola ini, request tidak perlu ikut menghitung ulang cache saat data baru saja kedaluwarsa. Request cukup mengembalikan data stale dan memicu job refresh satu kali.

<?php

use Illuminate\Support\Facades\Cache;
use App\Jobs\RefreshProductSummary;

function getProductSummarySwr(int $productId): array
{
    $cacheKey = "product:summary:{$productId}";
    $metaKey = "product:summary-meta:{$productId}";

    $payload = Cache::get($cacheKey);
    $meta = Cache::get($metaKey, []);

    $freshUntil = $meta['fresh_until'] ?? null;
    $staleUntil = $meta['stale_until'] ?? null;
    $now = now()->timestamp;

    if ($payload && $freshUntil && $now <= $freshUntil) {
        return $payload;
    }

    if ($payload && $staleUntil && $now <= $staleUntil) {
        RefreshProductSummary::dispatch($productId);
        return $payload;
    }

    return app(ProductSummaryCacheRefresher::class)->refresh($productId);
}

Di sisi refresher, setelah data selesai dihitung ulang, simpan payload dan metadata waktu fresh/stale. Untuk menghindari spam dispatch, kombinasikan dengan unique job atau lock.

Kelebihan dan keterbatasan SWR

  • Mengurangi latency puncak saat cache habis massal.
  • Mengurangi pressure ke database dan queue.
  • Tidak cocok untuk data yang harus selalu akurat per detik, misalnya saldo atau otorisasi kritis.

Mengendalikan Retry, Backoff, dan Rate Limiting

Backoff: jangan retry terlalu agresif

Jika job gagal mendapat lock atau dependency sedang lambat, retry instan hanya akan memperburuk keadaan. Atur backoff bertahap agar worker memberi waktu sistem untuk pulih.

<?php

class RefreshProductSummary implements ShouldQueue
{
    public function backoff(): array
    {
        return [5, 15, 30, 60];
    }

    public function handle(): void
    {
        app(ProductSummaryCacheRefresher::class)->refresh($this->productId);
    }
}

Backoff berguna terutama saat:

  • lock sedang dipegang job lain,
  • Redis atau database mengalami latency,
  • API eksternal membatasi request,
  • thundering herd muncul setelah outage singkat.

Rate limiting worker

Jika throughput terlalu tinggi merusak dependency hilir, tambahkan pembatasan laju pada jenis job tertentu. Tujuannya bukan sekadar memperlambat worker, tetapi menjaga sistem tetap stabil dan prediktif.

Rate limiting cocok saat:

  • rebuild cache memicu query berat ke tabel besar,
  • job melakukan panggilan ke API pihak ketiga,
  • traffic backlog besar muncul setelah deploy atau recovery.

Prinsipnya, lebih baik throughput sedikit lebih rendah tetapi stabil, daripada puncak throughput tinggi yang memicu timeout, retry, lalu total pekerjaan justru lebih lama selesai.

Implementasi Refresher yang Aman

Berikut contoh service refresh yang menggabungkan lock, pemeriksaan ulang cache, dan penulisan metadata untuk SWR.

<?php

namespace App\Services;

use Illuminate\Support\Facades\Cache;

class ProductSummaryCacheRefresher
{
    public function refresh(int $productId): array
    {
        $cacheKey = "product:summary:{$productId}";
        $metaKey = "product:summary-meta:{$productId}";
        $lockKey = "lock:{$cacheKey}";

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

        try {
            if (! $lock->get()) {
                $cached = Cache::get($cacheKey);
                if ($cached) {
                    return $cached;
                }

                throw new \RuntimeException('Refresh sedang berjalan oleh worker lain.');
            }

            $cached = Cache::get($cacheKey);
            $meta = Cache::get($metaKey, []);
            $freshUntil = $meta['fresh_until'] ?? 0;

            if ($cached && now()->timestamp <= $freshUntil) {
                return $cached;
            }

            $data = app(ProductSummaryService::class)->build($productId);

            Cache::put($cacheKey, $data, now()->addMinutes(30));
            Cache::put($metaKey, [
                'fresh_until' => now()->addMinutes(5)->timestamp,
                'stale_until' => now()->addMinutes(30)->timestamp,
            ], now()->addMinutes(30));

            return $data;
        } finally {
            optional($lock)->release();
        }
    }
}

Kenapa masih cek cache lagi setelah lock didapat?

Karena state bisa berubah di antara waktu job didispatch dan waktu worker benar-benar mulai berjalan. Pemeriksaan ulang ini menghindari kerja yang sebenarnya sudah tidak perlu.

Failure Mode yang Sering Terjadi

1. Lock kedaluwarsa sebelum job selesai

Akibatnya, worker lain bisa masuk dan memproses resource yang sama. Ini biasanya terjadi karena TTL lock terlalu pendek atau durasi job meningkat setelah perubahan query/dependency.

Solusi: ukur durasi aktual, naikkan TTL lock, pecah pekerjaan besar menjadi langkah lebih kecil, atau optimalkan query.

2. Lock macet karena proses crash

Jika proses mati sebelum pelepasan lock, resource bisa tertahan sampai TTL habis.

Solusi: selalu gunakan TTL, hindari lock tanpa kedaluwarsa, dan pantau durasi lock yang mendekati batas maksimal terlalu sering.

3. Retry storm

Job gagal karena dependency lambat, lalu semua retry masuk hampir bersamaan dan memperparah beban.

Solusi: gunakan backoff bertahap, rate limiting, dan jika perlu tambahkan jitter agar retry tidak sinkron.

4. Throughput turun drastis setelah menambah proteksi overlap

Ini sering bukan bug. Bisa jadi memang banyak job bertumpuk pada key resource yang sama sehingga eksekusi menjadi serial. Masalahnya ada pada distribusi workload, bukan pada jumlah worker.

Solusi: periksa cardinality key lock, gabungkan event kecil menjadi refresh batch, atau ubah granularity lock agar tidak terlalu kasar.

5. Data stale terlalu lama

SWR bisa menyamarkan masalah jika job refresh diam-diam gagal terus.

Solusi: pasang metrik umur cache, alert untuk stale yang melewati ambang, dan dashboard untuk job gagal per resource.

Checklist Observability yang Wajib Ada

Tanpa observability, lock dan overlap mudah terasa seperti masalah acak. Minimal ukur hal berikut:

  • Cache hit, miss, stale serve per key pattern utama.
  • Jumlah lock acquire sukses/gagal.
  • Waktu tunggu lock jika Anda memakai mekanisme blocking atau retry.
  • Durasi job per jenis job dan per resource kelas besar.
  • Attempt dan retry count.
  • Queue depth dan waktu tunggu job di antrean.
  • Jumlah release karena overlap pada middleware.
  • Usia cache saat dilayani ke pengguna.
  • Error dependency seperti Redis timeout, database slow query, atau API rate limit.

Untuk log, sertakan identifier yang konsisten: nama job, resource key, lock key, attempt, durasi, dan keputusan eksekusi seperti lock_acquired, lock_skipped, served_stale, atau released_for_overlap.

Langkah Debugging Saat Lock Macet atau Throughput Turun

Jika lock terasa macet

  1. Periksa apakah TTL lock cukup panjang dibanding durasi job aktual.
  2. Cari job yang sering melewati durasi normal karena query lambat atau dependency timeout.
  3. Pastikan semua jalur keluar melepaskan lock, idealnya lewat blok finally.
  4. Periksa apakah key lock terlalu kasar, misalnya satu lock untuk seluruh tenant besar padahal bisa dipersempit.
  5. Validasi bahwa cache driver dan queue worker sama-sama menggunakan backend yang benar untuk lock terdistribusi, misalnya Redis yang sama sesuai desain.

Jika throughput turun setelah menambah WithoutOverlapping

  1. Lihat distribusi key overlap: apakah sebagian besar job ternyata menarget resource yang sama?
  2. Bandingkan queue wait time dengan execution time. Jika wait naik tetapi execution stabil, bottleneck ada di serialisasi lock.
  3. Periksa apakah release delay terlalu lama sehingga job terlalu lambat mencoba lagi.
  4. Tinjau ulang apakah semua job benar-benar perlu overlap protection, atau hanya subset tertentu.
  5. Pertimbangkan batch/coalescing: satu job menggabungkan banyak trigger kecil menjadi satu refresh.

Jika cache stampede masih terjadi

  1. Pastikan lock dipasang pada jalur yang benar-benar melakukan recompute, bukan hanya pada controller.
  2. Tambahkan pemeriksaan cache kedua setelah lock didapat.
  3. Gunakan SWR agar request tidak ikut berebut saat masa fresh habis.
  4. Cegah dispatch job duplikat dengan unique job.
  5. Audit key cache: stampede kadang terjadi karena key terlalu granular atau malah salah format sehingga hit ratio rendah.

Kapan Memilih Pola Tertentu

  • Banyak request memicu recompute key yang sama → gunakan cache lock, dan pertimbangkan stale-while-revalidate.
  • Banyak event mendispatch job identik → gunakan unique job.
  • Job boleh banyak, tapi tidak boleh jalan paralel untuk resource sama → gunakan WithoutOverlapping.
  • Retry menumpuk dan membebani sistem → atur backoff dan rate limiting.
  • Data cukup toleran terhadap stale → pilih SWR untuk menahan lonjakan.

Rekomendasi Pola Produksi

Untuk kebanyakan kasus Laravel Queue dengan Redis, pola yang aman dan pragmatis adalah:

  1. Layanan baca cache memakai stale-while-revalidate.
  2. Job refresh dibuat unik per resource agar antrean tidak dipenuhi duplikasi.
  3. Eksekusi job diberi WithoutOverlapping atau lock eksplisit jika ada risiko race condition tinggi.
  4. Refresher internal tetap memakai cache lock dan double-check cache.
  5. Retry menggunakan backoff bertahap, bukan retry agresif.
  6. Tambahkan rate limiting untuk job yang memukul database atau API berat.
  7. Pantau metrik lock, queue, stale age, dan failure rate sejak awal.

Intinya, Laravel Queue: Atasi Cache Stampede dan Worker Overlap bukan soal satu fitur tunggal, melainkan kombinasi kontrol di fase dispatch, eksekusi, dan cache refresh. Jika Anda memilih granularitas key yang tepat, menetapkan TTL lock secara realistis, dan memasang observability yang cukup, sistem akan jauh lebih stabil saat traffic naik atau saat dependency sedang tidak sehat.