Thundering herd muncul ketika banyak request atau worker mencoba mengambil atau menghitung data yang sama pada waktu yang hampir bersamaan. Pola ini sering terjadi setelah cache miss massal, TTL habis serentak, proses warming gagal, atau saat backend melambat sehingga antrean menumpuk. Dampaknya jelas: database melonjak, latency meningkat, dan sistem yang semula stabil bisa jatuh hanya karena satu key populer.

Untuk mencegahnya, ada beberapa teknik yang saling melengkapi: singleflight atau request coalescing di level proses, cache lease atau lock singkat di level cache terdistribusi, soft TTL vs hard TTL untuk memisahkan masa segar dan masa boleh disajikan, serta stale-while-revalidate agar request tetap cepat sambil satu proses melakukan refresh di belakang layar. Pendekatan yang tepat tergantung pada kebutuhan latency, toleransi data stale, dan kompleksitas operasional yang sanggup Anda kelola.

Memahami pola thundering herd

Masalah ini biasanya bukan karena satu request mahal, melainkan karena duplikasi kerja. Misalnya ada satu key cache bernama product:123 yang dibaca ribuan kali per menit. Ketika TTL key itu habis, banyak request serentak melihat cache miss dan semuanya memanggil database. Jika query tersebut berat atau backend punya koneksi terbatas, lonjakan ini bisa memicu efek berantai:

  • pool koneksi database penuh,
  • latency meningkat,
  • timeout bertambah,
  • retry dari client memperparah beban,
  • cache makin lama terisi ulang karena backend sudah kewalahan.

Masalah serupa juga muncul pada worker queue: banyak worker memproses job dengan key identik, semuanya mencoba membangun agregasi yang sama, mengambil konfigurasi yang sama, atau memanggil API yang sama.

Pemicu yang umum

  • TTL seragam pada banyak key yang dibuat pada saat yang sama.
  • Hot key yang dibaca sangat sering.
  • Cache warming gagal atau tidak ada mekanisme warming.
  • Backend melambat sehingga jendela race condition menjadi lebih panjang.
  • Retry tanpa backoff dari aplikasi atau load balancer.
  • Invalidasi massal setelah deploy atau perubahan data besar.

Singleflight dan request coalescing

Singleflight adalah teknik untuk memastikan hanya satu eksekusi aktif untuk satu key di dalam satu proses atau satu instance aplikasi. Request lain yang datang untuk key yang sama tidak ikut menghantam backend, melainkan menunggu hasil dari eksekusi pertama.

Ini adalah bentuk request coalescing. Tujuannya bukan caching permanen, melainkan menghilangkan duplikasi kerja selama periode inflight. Teknik ini sangat efektif untuk:

  • mengurangi query ganda pada key yang sama,
  • menahan lonjakan kecil hingga menengah di satu instance,
  • melindungi backend saat cache miss terjadi bersamaan.

Kapan singleflight cocok

  • Aplikasi berjalan di satu instance atau beberapa instance, tetapi duplikasi terbesar terjadi di masing-masing proses.
  • Biaya operasi backend tinggi dan banyak request untuk key yang sama datang dalam rentang waktu pendek.
  • Anda ingin solusi sederhana tanpa lock terdistribusi penuh sebagai lapisan pertama.

Keterbatasan singleflight

Singleflight di dalam memori proses hanya menggabungkan request di instance yang sama. Jika ada 20 pod dan semuanya menerima request untuk key yang sama, Anda masih bisa mendapatkan hingga 20 eksekusi backend. Karena itu, singleflight sering dipakai bersama cache terdistribusi, stale-while-revalidate, atau lease di Redis.

Pseudo-code singleflight

global inflight = Map<string, Promise>()

def getOrLoad(key):
    value = cache.get(key)
    if value exists:
        return value

    if inflight.contains(key):
        return await inflight[key]

    p = async function():
        try:
            fresh = db.load(key)
            cache.set(key, fresh, ttl=60)
            return fresh
        finally:
            inflight.remove(key)
    end

    inflight[key] = p()
    return await inflight[key]

Intinya, map inflight menyimpan promise atau future per key. Request kedua dan seterusnya cukup menunggu promise yang sama.

Failure mode singleflight

  • Head-of-line blocking: semua request menunggu satu operasi yang ternyata lambat.
  • Error amplification: jika loader gagal, semua waiter menerima kegagalan yang sama.
  • Memory leak: entri inflight tidak dibersihkan saat timeout, panic, atau cancel tidak ditangani benar.
  • Cancellation coupling: jangan biarkan pembatalan satu requester membatalkan kerja bersama untuk requester lain kecuali itu memang desainnya.

Karena itu, singleflight perlu dipadukan dengan timeout yang jelas, fallback ke stale data bila memungkinkan, dan pembersihan state inflight yang selalu dieksekusi.

Cache lease dan lock berdurasi pendek

Jika singleflight mengatasi duplikasi di level proses, cache lease atau lock singkat mengatasi duplikasi antar-proses atau antar-node. Gagasannya: ketika cache miss atau data butuh refresh, hanya satu proses yang mendapatkan hak untuk mengisi ulang. Proses lain tidak ikut memukul database.

Lease biasanya disimpan di cache terdistribusi seperti Redis menggunakan operasi atomik. Bentuk sederhananya:

  1. Cek cache data utama.
  2. Jika miss atau stale, coba ambil lock/lease untuk key itu dengan TTL pendek.
  3. Jika lease didapat, proses tersebut memuat data dari backend lalu memperbarui cache.
  4. Jika lease tidak didapat, proses lain sedang mengisi. Request saat ini bisa menunggu singkat, membaca stale data, atau mengembalikan fallback.

Kapan cache lease cocok

  • Anda menjalankan banyak instance aplikasi.
  • Hot key dapat diakses dari banyak node sekaligus.
  • Biaya cache miss sangat tinggi dan harus dibatasi secara global.

Contoh alur dengan Redis

function readThrough(key):
    dataKey = "data:" + key
    lockKey = "lock:" + key

    entry = redis.get(dataKey)
    if entry exists and not entry.hardExpired:
        if entry.softExpired:
            triggerBackgroundRefreshIfPossible(key)
        return entry.value

    token = randomToken()
    gotLease = redis.set(lockKey, token, NX=true, PX=3000)

    if gotLease:
        try:
            fresh = backendLoad(key)
            wrapped = {
                value: fresh,
                soft_expire_at: now + 30s,
                hard_expire_at: now + 5m
            }
            redis.set(dataKey, serialize(wrapped), EX=300)
            return fresh
        finally:
            current = redis.get(lockKey)
            if current == token:
                redis.del(lockKey)
    else:
        stale = redis.get(dataKey)
        if stale exists:
            return stale.value

        sleep(50ms)
        retry = redis.get(dataKey)
        if retry exists:
            return retry.value

        return fallbackOrError()

Beberapa detail penting:

  • Lock harus punya TTL pendek agar tidak menggantung jika proses pemegang lock crash.
  • Gunakan token unik supaya hanya pemegang lock yang sah boleh melepas lock.
  • Jangan menunggu terlalu lama jika lease tidak didapat. Menunggu pendek atau melayani stale biasanya lebih aman.

Membedakan lease dan distributed lock umum

Untuk kasus cache fill, Anda biasanya tidak butuh koordinasi kompleks seperti lock kuat untuk transaksi bisnis. Yang dibutuhkan adalah best-effort exclusion dalam durasi pendek agar hanya sedikit proses yang melakukan refresh. Ini lebih sederhana dan lebih murah secara operasional dibanding memakai lock terdistribusi untuk semua jalur baca.

Soft TTL, hard TTL, dan stale-while-revalidate

Salah satu cara paling praktis untuk menahan thundering herd adalah memisahkan usia cache menjadi dua batas:

  • Soft TTL: setelah titik ini, data dianggap perlu disegarkan, tetapi masih boleh disajikan.
  • Hard TTL: setelah titik ini, data tidak boleh disajikan lagi dan harus dianggap kedaluwarsa penuh.

Dengan pola ini, request tidak langsung jatuh ke backend saat data mulai tua. Sebaliknya, request masih bisa menerima data stale dalam jangka pendek, sementara satu proses melakukan refresh. Inilah inti stale-while-revalidate.

Kenapa pendekatan ini efektif

Tanpa soft TTL, momen cache expired menjadi tebing tajam: tepat pada detik itu, semua request berubah dari cache hit menjadi backend hit. Dengan soft TTL, masa transisi dibuat landai. Mayoritas request tetap cepat, dan refresh dilakukan terkontrol.

Alur stale-while-revalidate

  1. Jika data belum melewati soft TTL, kembalikan sebagai cache hit biasa.
  2. Jika soft TTL terlewati tetapi hard TTL belum, kembalikan data stale.
  3. Sambil mengembalikan stale, satu proses mencoba mengambil lease untuk refresh.
  4. Jika hard TTL terlewati, jangan sajikan data. Gunakan lease untuk satu refresher, sisanya menunggu pendek atau fallback.

Trade-off soft TTL vs hard TTL

  • Latency: lebih baik karena banyak request tetap dilayani dari cache.
  • Konsistensi: lebih lemah karena data stale bisa disajikan dalam jendela tertentu.
  • Kompleksitas: lebih tinggi karena metadata cache bertambah dan alur baca menjadi bercabang.

Pola ini cocok untuk data yang tidak harus selalu real-time, seperti katalog produk, agregasi statistik, konfigurasi yang jarang berubah, profil publik, atau hasil komputasi berat yang toleran terhadap sedikit keterlambatan pembaruan.

Memilih pendekatan yang tepat

Pakai singleflight saja jika

  • masalah utama ada di duplikasi dalam satu proses,
  • jumlah instance sedikit,
  • Anda ingin solusi cepat dengan perubahan minimal.

Tambahkan cache lease jika

  • aplikasi horizontal scale ke banyak node,
  • ada hot key global,
  • cache miss pada satu key sangat mahal.

Tambahkan stale-while-revalidate jika

  • latency baca harus stabil,
  • data boleh sedikit stale,
  • Anda ingin menghindari cliff saat TTL habis.

Gunakan lock pendek, bukan lock lama

Untuk mencegah herd, lock panjang biasanya justru berbahaya. Jika durasi lock terlalu lama, request akan menumpuk, recovery lambat, dan lock orphan lebih merusak. Gunakan lock sesingkat mungkin, setara perkiraan durasi refresh plus margin yang konservatif, lalu andalkan retry ringan, stale response, atau fallback.

Alur implementasi yang praktis

1. Bungkus nilai cache dengan metadata

Jangan simpan hanya payload mentah. Simpan juga:

  • value
  • soft_expire_at
  • hard_expire_at
  • opsional: versi, checksum, waktu refresh terakhir

2. Terapkan request coalescing lokal

Di setiap instance aplikasi, gunakan peta inflight agar request untuk key yang sama tidak memicu kerja ganda dalam proses itu.

3. Tambahkan lease terdistribusi saat refresh

Saat key stale atau miss, coba dapatkan lease di Redis. Jika gagal, jangan semua requester ikut memukul backend.

4. Tentukan kebijakan fallback

  • Jika stale masih tersedia, kembalikan stale.
  • Jika tidak ada stale, tunggu singkat lalu cek ulang cache.
  • Jika backend tetap gagal, kembalikan error yang jelas atau fallback terbatas.

5. Tambahkan jitter pada TTL

Jangan set semua key dengan TTL yang identik. Tambahkan variasi kecil agar expiry tidak terjadi serentak. Ini salah satu perbaikan paling murah dan sering terlupakan.

6. Lindungi backend dari retry berlebihan

Jika load gagal, gunakan backoff dan batasi retry. Retry sinkron dalam jumlah besar sering mengubah gangguan kecil menjadi insiden penuh.

Pseudo-code gabungan: singleflight + lease + stale-while-revalidate

global inflight = Map<string, Promise>()

function getData(key):
    entry = cache.get(key)

    if entry exists:
        if now < entry.soft_expire_at:
            return entry.value

        if now < entry.hard_expire_at:
            triggerRefreshOnce(key)
            return entry.value

    return refreshSyncOnce(key)

function triggerRefreshOnce(key):
    if inflight.contains(key):
        return

    inflight[key] = asyncBackground(function():
        try:
            lease = tryLease(key, ttl=3s)
            if not lease:
                return

            fresh = backendLoad(key)
            cache.set(key, {
                value: fresh,
                soft_expire_at: now + 30s,
                hard_expire_at: now + 5m
            })
        finally:
            releaseLeaseIfOwner(key)
            inflight.remove(key)
    )

function refreshSyncOnce(key):
    if inflight.contains(key):
        return await inflight[key]

    inflight[key] = async(function():
        try:
            lease = waitShortOrTryLease(key)
            if lease:
                fresh = backendLoad(key)
                cache.set(key, wrap(fresh))
                return fresh

            cached = cache.get(key)
            if cached exists and now < cached.hard_expire_at:
                return cached.value

            throw UpstreamUnavailable()
        finally:
            releaseLeaseIfOwner(key)
            inflight.remove(key)
    )

    return await inflight[key]

Pola ini bukan satu-satunya desain, tetapi cukup representatif untuk implementasi produksi: cepat di jalur normal, menahan refresh duplikat, dan tetap punya fallback saat backend bermasalah.

Failure mode yang sering terjadi

1. Stampede setelah hard TTL

Jika hard TTL terlalu pendek atau refresh terlalu sering gagal, Anda tetap bisa melihat gelombang request ke backend. Solusinya: perpanjang jendela stale bila aman, perbaiki reliabilitas refresh, dan tambahkan jitter.

2. Lock orphan atau lock terlalu lama

Proses pemegang lease bisa crash setelah mendapatkan lock. Jika lock tidak punya TTL, key bisa macet lama. Jika TTL lock terlalu panjang, request lain akan menderita menunggu. Jika terlalu pendek, dua refresher bisa berjalan bersamaan. Ini harus dituning berdasarkan durasi load normal dan tail latency backend.

3. Semua request ikut menunggu

Kesalahan umum adalah menjadikan semua request menunggu refresh sinkron, termasuk saat data stale sebenarnya masih layak. Ini menaikkan latency secara tidak perlu.

4. Cache penetration pada key tidak valid

Jika key yang tidak ada sering diminta, sistem tetap bisa memukul backend berulang kali. Solusinya: simpan negative cache untuk hasil kosong dengan TTL pendek.

5. Invalidasi massal

Menghapus banyak key sekaligus dapat memicu herd besar. Lebih aman menggunakan versioned key, refresh bertahap, atau prewarming sebelum trafik dialihkan.

6. Refresh loop saat backend lambat

Jika timeout terlalu agresif, refresher gagal berulang, lease terus berpindah, dan tidak ada yang berhasil mengisi cache. Pantau error rate loader dan durasi refresh p95/p99, bukan hanya rata-rata.

Metrik yang perlu dipantau

Tanpa metrik, Anda sulit membedakan herd, lock contention, dan backend slowdown biasa. Beberapa metrik yang berguna:

  • Cache hit ratio dipisahkan antara fresh hit, stale hit, dan miss.
  • Jumlah refresh per key dan frekuensi hot key.
  • Lease acquisition rate dan lease contention rate.
  • Jumlah waiter singleflight per key atau distribusinya.
  • Durasi backend load p50, p95, p99.
  • Error rate pada loader, database, atau upstream.
  • Stale serve rate: seberapa sering stale disajikan.
  • Lock timeout/expiry count.
  • Request latency dipisahkan jalur fresh hit, stale hit, dan miss-refresh.

Tambahkan logging terstruktur untuk key panas, hasil lease, durasi refresh, dan alasan fallback. Saat insiden terjadi, data ini jauh lebih berguna daripada log umum per request.

Checklist debugging saat traffic melonjak dan backend kewalahan

  1. Lihat apakah miss melonjak serentak. Jika ya, cek TTL seragam, invalidasi massal, atau deploy baru.
  2. Periksa hot key. Sering kali hanya sedikit key yang menyebabkan sebagian besar beban.
  3. Ukur stale hit vs hard miss. Jika stale hampir nol, mungkin soft TTL tidak dimanfaatkan atau refresh berjalan sinkron.
  4. Cek lock contention. Jika banyak proses gagal memperoleh lease tetapi backend tetap tinggi, mungkin ada jalur yang bypass lease.
  5. Periksa durasi refresh. Jika refresh melambat, TTL lock mungkin tidak sesuai.
  6. Audit retry. Pastikan client, worker, dan gateway tidak melakukan retry agresif tanpa backoff.
  7. Pastikan inflight dibersihkan. Entri yang tertinggal dapat menyebabkan kebuntuan lokal.
  8. Verifikasi negative cache. Serangan ke key tidak valid bisa terlihat seperti cache herd.
  9. Cek jitter TTL. Expiry serentak adalah penyebab klasik stampede.
  10. Review fallback policy. Untuk data non-kritis, menyajikan stale sering lebih aman daripada membiarkan seluruh request masuk ke database.

Trade-off utama: latency, konsistensi, dan kompleksitas

Tidak ada satu pendekatan yang selalu terbaik.

  • Singleflight sederhana dan efektif, tetapi skopnya lokal per proses.
  • Cache lease mengurangi duplikasi antar-node, tetapi menambah ketergantungan pada operasi atomik dan tuning lock TTL.
  • Stale-while-revalidate menjaga latency tetap rendah, tetapi mengorbankan konsistensi segar absolut.
  • Soft TTL/hard TTL memberi kontrol yang bagus, tetapi implementasinya lebih kompleks daripada cache TTL biasa.

Untuk banyak sistem backend, kombinasi yang paling pragmatis adalah:

  1. cache read-through dengan metadata soft TTL/hard TTL,
  2. singleflight di setiap instance,
  3. lease singkat di Redis untuk refresh global,
  4. stale-while-revalidate untuk jalur baca,
  5. jitter TTL dan retry dengan backoff.

Jika data boleh sedikit stale, prioritaskan melayani dari cache dan batasi jumlah refresher. Jika data harus sangat segar, kurangi jendela stale tetapi siapkan proteksi backend yang lebih ketat dan observabilitas yang lebih baik.

Penutup

Mencegah thundering herd dengan singleflight dan cache lease pada dasarnya adalah soal menghindari kerja yang sama dilakukan berkali-kali pada saat yang paling buruk. Singleflight menahan duplikasi di level proses, cache lease menahan duplikasi antar-node, dan stale-while-revalidate dengan soft TTL/hard TTL menjaga jalur baca tetap cepat tanpa membuat backend runtuh saat cache menua.

Mulailah dari desain yang sederhana: tambahkan jitter TTL, ukur hot key, terapkan singleflight lokal, lalu lanjutkan ke lease terdistribusi dan stale serving jika traffic dan topologi sistem memang membutuhkannya. Yang paling penting, uji failure mode-nya: backend lambat, lock kedaluwarsa, refresh gagal, dan lonjakan traffic mendadak. Di situlah strategi anti-herd benar-benar terbukti.