Cache stampede terjadi ketika banyak request melewati cache yang sama lalu bersama-sama menghitung ulang data mahal pada saat cache kadaluarsa atau hilang. Di aplikasi SvelteKit, masalah ini sering muncul saat server route, endpoint API, dan worker background sama-sama membaca dan menulis key yang sama di Redis atau cache in-memory. Jika tidak dikendalikan, gejalanya biasanya berupa lonjakan latency, beban database mendadak, job refresh ganda, dan data cache yang saling menimpa.

Solusinya bukan sekadar “pasang Redis”, tetapi memilih pola kontrol konkurensi yang sesuai: stale-while-revalidate untuk menjaga latency tetap rendah, single-flight request untuk mencegah duplicate recomputation di satu proses, distributed lock dengan TTL untuk koordinasi lintas instance, fencing token untuk mencegah write usang, dan worker idempoten agar retry tidak merusak state. Artikel ini fokus pada implementasi praktis untuk SvelteKit, bukan pembahasan cache secara umum.

Kapan cache stampede dan race condition muncul di SvelteKit?

Pola masalahnya biasanya seperti ini:

  • Route server membaca data dari cache lalu fallback ke database jika miss.
  • Endpoint API memicu refresh cache ketika data dianggap stale.
  • Worker background menjalankan revalidasi berkala atau event-driven untuk key yang sama.

Kalau ketiganya bekerja tanpa koordinasi, beberapa kegagalan umum akan muncul:

  • Duplicate recomputation: 20 request bersamaan sama-sama memanggil query mahal ke database.
  • Last write wins yang salah: worker lambat menulis hasil lama setelah route server sudah menulis hasil baru.
  • Lock orphan atau lock terlalu cepat habis: proses refresh masih berjalan, tetapi TTL lock habis sehingga proses lain ikut refresh.
  • Cache hole: key dihapus duluan sebelum nilai baru siap, sehingga semua request jatuh ke origin.
  • Inconsistent freshness: satu instance menganggap data segar, instance lain menganggap stale karena cache lokal berbeda.

Gejala operasional yang paling sering terlihat

  • Latency p95/p99 naik tajam tepat saat TTL cache habis.
  • Traffic ke database atau API upstream melonjak secara periodik.
  • Jumlah job refresh per key jauh lebih tinggi dari perkiraan.
  • Error timeout pada worker meningkat saat beban puncak.
  • Log menunjukkan beberapa proses memproses key yang sama dalam rentang waktu sangat dekat.

Perbedaan masalah pada cache in-memory vs Redis

Cache in-memory

Cache in-memory cocok untuk optimasi lokal per proses, tetapi tidak cukup untuk koordinasi antar-instance. Jika aplikasi SvelteKit berjalan di beberapa proses atau container, masing-masing punya cache sendiri. Hasilnya:

  • Single-flight hanya efektif dalam satu proses.
  • Miss dapat terjadi serentak di banyak instance.
  • Invalidasi menjadi sulit karena tidak ada sumber kebenaran bersama.

Gunakan in-memory untuk meredam beban lokal dan menutup gap kecil, tetapi jangan mengandalkannya sebagai satu-satunya mekanisme anti-stampede pada deployment horizontal.

Redis atau cache terdistribusi

Redis memberi koordinasi lintas proses dan biasanya lebih cocok sebagai shared cache. Namun Redis tidak otomatis menyelesaikan race condition. Anda tetap perlu mengatur:

  • siapa yang boleh melakukan recompute,
  • berapa lama lock hidup,
  • apa yang terjadi saat lock gagal diambil,
  • bagaimana mencegah hasil stale menimpa hasil baru.

Kesalahan umum adalah menganggap SETNX saja sudah cukup. Padahal tanpa token pemilik lock, TTL yang tepat, dan proteksi saat write-back, Anda masih bisa mendapat kondisi balapan.

Arsitektur alur yang disarankan

Pola praktis yang aman untuk banyak kasus adalah menggabungkan stale-while-revalidate + single-flight + distributed lock + fencing token.

  1. Request ke endpoint SvelteKit membaca nilai cache beserta metadata freshness.
  2. Jika nilai masih fresh, langsung kembalikan.
  3. Jika nilai stale tetapi masih dapat ditoleransi, kembalikan nilai stale untuk menjaga latency.
  4. Secara paralel, coba jadwalkan refresh melalui worker atau jalankan refresh terkendali.
  5. Gunakan single-flight untuk mencegah duplicate recomputation dalam satu proses.
  6. Gunakan distributed lock agar hanya satu instance yang benar-benar melakukan refresh lintas cluster.
  7. Saat menulis hasil baru, sertakan fencing token atau versi monotonik agar hasil lama tidak menimpa hasil baru.

Prinsip penting: jangan hapus cache lama sebelum data baru siap. Lebih aman menyimpan nilai stale untuk sementara daripada membuat semua request jatuh ke origin.

Struktur data cache yang berguna

Alih-alih menyimpan value mentah, simpan envelope seperti berikut:

type CacheEnvelope<T> = {
  value: T;
  freshUntil: number;   // timestamp ms
  staleUntil: number;   // masih boleh disajikan setelah fresh habis
  version: number;      // fencing token / monotonic version
  updatedAt: number;
};

Dengan model ini, endpoint dapat membedakan tiga keadaan:

  • Fresh: aman disajikan.
  • Stale but acceptable: aman disajikan sementara sambil refresh.
  • Expired hard: jangan disajikan, butuh fallback lain.

Pola 1: stale-while-revalidate untuk menahan lonjakan latency

Stale-while-revalidate berarti Anda tetap menyajikan data stale dalam jendela waktu tertentu sambil memicu penyegaran di belakang layar. Pola ini efektif untuk mencegah herd effect saat banyak request datang tepat setelah TTL fresh habis.

Mengapa pola ini bekerja

Tanpa stale window, semua request setelah expiry akan berlomba memukul database atau menunggu refresh. Dengan stale window:

  • user tetap mendapat respons cepat,
  • hanya sebagian kecil request yang perlu memicu refresh,
  • sistem punya ruang untuk menyelesaikan recompute tanpa menyebabkan antrian panjang.

Trade-off

  • Kelebihan: latency lebih stabil, beban origin lebih rendah.
  • Kekurangan: data yang disajikan bisa sedikit usang.

Cocok untuk data yang tidak harus real-time mutlak, misalnya ringkasan dashboard, daftar produk, atau agregat statistik ringan. Kurang cocok untuk saldo final, kuota yang berubah sangat cepat, atau data yang terkait keputusan bisnis sensitif.

Pola 2: single-flight request di level proses

Single-flight mencegah banyak request dalam satu proses Node menjalankan operasi refresh yang sama. Implementasinya biasanya berupa Map<string, Promise<T>>. Jika sebuah key sedang dihitung, request berikutnya cukup menunggu Promise yang sama.

const inflight = new Map<string, Promise<unknown>>();

export function singleFlight<T>(key: string, fn: () => Promise<T>): Promise<T> {
  const existing = inflight.get(key);
  if (existing) return existing as Promise<T>;

  const p = fn().finally(() => {
    inflight.delete(key);
  });

  inflight.set(key, p);
  return p;
}

Di SvelteKit, pola ini berguna jika beberapa request ke route server yang sama masuk bersamaan pada satu instance.

Keterbatasan single-flight

  • Tidak mencegah duplikasi antar-instance.
  • Tidak membantu worker di proses terpisah.
  • Jika Promise macet terlalu lama, request lain ikut menunggu.

Karena itu single-flight sebaiknya dianggap lapisan optimasi lokal, bukan satu-satunya kontrol konkurensi.

Pola 3: distributed lock dengan TTL

Untuk koordinasi antar-instance atau antara SvelteKit dan worker, gunakan distributed lock di Redis. Pola minimalnya adalah menyimpan key lock hanya jika belum ada, lalu memberi TTL agar lock tidak permanen jika proses mati.

Tujuan lock

  • Hanya satu pihak melakukan refresh untuk key tertentu.
  • Pihak lain tahu bahwa refresh sedang berlangsung dan bisa memilih fallback yang aman.

Catatan penting tentang TTL

TTL lock harus lebih panjang dari durasi refresh normal, tetapi tidak terlalu panjang hingga memperlambat recovery ketika pemilik lock crash. Jika durasinya sulit diprediksi, Anda bisa menambahkan heartbeat atau extension lock secara hati-hati. Namun extension pun harus aman: jangan memperpanjang lock milik proses lain secara tidak sengaja.

Trade-off locking

  • Lock terlalu pendek: proses kedua dapat masuk saat proses pertama belum selesai.
  • Lock terlalu panjang: recovery lambat saat pemilik lock gagal.
  • Lock global per resource: aman, tetapi dapat menurunkan throughput.
  • Lock granular per key: throughput lebih baik, tetapi observabilitas perlu rapi.

Pola 4: fencing token untuk mencegah stale overwrite

Fencing token adalah nomor versi yang selalu meningkat setiap kali pemilik lock sah memulai pekerjaan. Saat menulis hasil, hanya hasil dengan versi terbaru yang boleh diterima. Ini penting karena lock dengan TTL tidak menjamin proses lama benar-benar berhenti. Bisa saja proses lama telat selesai dan mencoba menulis hasil usang.

Contoh failure tanpa fencing token

  1. Worker A mengambil lock dan mulai refresh.
  2. Worker A lambat; TTL lock habis.
  3. Worker B mengambil lock baru, menghitung data terbaru, lalu menulis cache.
  4. Worker A akhirnya selesai dan menimpa hasil Worker B dengan data lama.

Dengan fencing token, write dari Worker A ditolak karena versinya lebih kecil.

Contoh implementasi endpoint SvelteKit untuk refresh cache

Berikut pseudocode/TypeScript yang menggambarkan alur praktis. Kode ini sengaja generik agar tidak bergantung pada satu library Redis tertentu.

// src/routes/api/report/+server.ts
import { json } from '@sveltejs/kit';

type Report = {
  totalUsers: number;
  activeUsers: number;
};

type CacheEnvelope<T> = {
  value: T;
  freshUntil: number;
  staleUntil: number;
  version: number;
  updatedAt: number;
};

const inflight = new Map<string, Promise<CacheEnvelope<Report>>>();

function singleFlight(key: string, fn: () => Promise<CacheEnvelope<Report>>) {
  const existing = inflight.get(key);
  if (existing) return existing;
  const p = fn().finally(() => inflight.delete(key));
  inflight.set(key, p);
  return p;
}

async function readCache(key: string): Promise<CacheEnvelope<Report> | null> {
  // ambil dari Redis / shared cache
  return null;
}

async function writeCacheIfVersionNewer(
  key: string,
  next: CacheEnvelope<Report>
): Promise<boolean> {
  // tulis hanya jika version di cache saat ini <= next.version
  // implementasi ideal dilakukan atomik di Redis via script/transaction
  return true;
}

async function tryAcquireLock(lockKey: string, ownerId: string, ttlMs: number): Promise<boolean> {
  // SET lockKey ownerId NX PX ttlMs
  return true;
}

async function releaseLock(lockKey: string, ownerId: string): Promise<void> {
  // hapus lock hanya jika ownerId masih cocok
}

async function nextFenceToken(counterKey: string): Promise<number> {
  // INCR counterKey
  return Date.now();
}

async function loadFromOrigin(): Promise<Report> {
  // query DB atau upstream API
  return {
    totalUsers: 1200,
    activeUsers: 340
  };
}

async function enqueueRefreshJob(cacheKey: string): Promise<void> {
  // kirim job ke queue; job harus idempoten
}

async function refreshReport(cacheKey: string): Promise<CacheEnvelope<Report>> {
  const ownerId = crypto.randomUUID();
  const lockKey = `lock:${cacheKey}`;
  const tokenKey = `fence:${cacheKey}`;

  const locked = await tryAcquireLock(lockKey, ownerId, 30_000);
  if (!locked) {
    const cached = await readCache(cacheKey);
    if (cached) return cached;
    throw new Error('refresh_in_progress_no_fallback');
  }

  try {
    const version = await nextFenceToken(tokenKey);
    const value = await loadFromOrigin();
    const now = Date.now();

    const envelope: CacheEnvelope<Report> = {
      value,
      freshUntil: now + 60_000,
      staleUntil: now + 5 * 60_000,
      version,
      updatedAt: now
    };

    await writeCacheIfVersionNewer(cacheKey, envelope);
    return envelope;
  } finally {
    await releaseLock(lockKey, ownerId);
  }
}

export async function GET() {
  const cacheKey = 'report:summary';
  const now = Date.now();
  const cached = await readCache(cacheKey);

  if (cached && cached.freshUntil > now) {
    return json({ source: 'cache-fresh', data: cached.value });
  }

  if (cached && cached.staleUntil > now) {
    // kembalikan stale cepat, refresh di background
    void enqueueRefreshJob(cacheKey);
    return json({ source: 'cache-stale', data: cached.value });
  }

  const envelope = await singleFlight(cacheKey, () => refreshReport(cacheKey));
  return json({ source: 'recomputed', data: envelope.value });
}

Mengapa contoh di atas lebih aman

  • Fresh/stale dipisah, jadi expiry tidak langsung membuat semua request menghantam origin.
  • Single-flight mengurangi duplikasi dalam satu proses.
  • Distributed lock mencegah banyak instance refresh key yang sama bersamaan.
  • Fencing token mencegah hasil lama menimpa hasil baru.
  • Fallback saat lock gagal jelas: kembalikan stale jika ada, jangan memaksa semua pihak refresh ulang.

Worker background harus idempoten

Jika endpoint hanya enqueue job refresh, maka worker menjadi bagian penting dari konsistensi. Worker harus idempoten, artinya retry job yang sama tidak menghasilkan efek samping yang salah.

Praktik idempoten yang dianjurkan

  • Gunakan job key deterministik, misalnya berdasarkan resource key dan bucket waktu.
  • Simpan status pemrosesan jika perlu membedakan queued, running, done.
  • Saat menulis cache, gunakan fencing token atau versi monotonik.
  • Jangan mengeksekusi update irreversibel dua kali tanpa pengecekan.

Contoh alur worker

  1. Ambil job refresh untuk report:summary.
  2. Coba ambil lock distributed per cache key.
  3. Jika gagal, anggap ada worker lain yang lebih dulu, lalu exit secara aman.
  4. Jika berhasil, ambil fencing token baru, hit origin, lalu tulis cache hanya jika token masih terbaru.

Pola ini membuat retry akibat timeout atau restart worker tetap aman.

Fallback saat lock gagal

Salah satu keputusan terpenting adalah apa yang dilakukan request ketika lock tidak bisa diambil.

Opsi fallback yang umum

  • Kembalikan stale data: pilihan paling praktis untuk menjaga availability.
  • Tunggu sebentar lalu baca cache ulang: cocok jika refresh biasanya sangat cepat.
  • Fail fast dengan status sementara tidak tersedia: cocok untuk data yang tidak boleh stale.
  • Bypass ke origin secara terbatas: berisiko memicu stampede jika tidak diberi rate limit.

Secara umum, untuk endpoint baca yang toleran terhadap data usang, prioritaskan serve stale. Untuk endpoint yang sensitif, gunakan timeout pendek dan respons yang eksplisit daripada membiarkan antrian request menumpuk.

Skenario failure yang harus diantisipasi

1. Worker crash setelah mengambil lock

TTL lock akan melepaskan lock pada akhirnya, tetapi selama TTL itu pihak lain akan tertahan. Karena itu TTL tidak boleh terlalu panjang. Jika refresh mahal, pertimbangkan heartbeat yang hanya boleh dilakukan oleh pemilik lock.

2. TTL lock habis sebelum refresh selesai

Ini penyebab utama stale overwrite. Fencing token diperlukan agar proses lama tidak dapat menulis hasil setelah proses baru mengambil alih.

3. Redis tersedia, tetapi write cache gagal setelah origin sukses

Jangan anggap refresh berhasil hanya karena query origin berhasil. Catat metrik terpisah untuk origin fetch success dan cache write success. Jika write gagal, stale data bisa terus disajikan lebih lama dari yang direncanakan.

4. Queue mengirim job duplikat

Anggap duplikasi selalu mungkin. Lock dan idempotensi worker harus tetap benar walaupun job yang sama diproses lebih dari sekali.

5. Invalidation manual menghapus key saat refresh berjalan

Jika invalidasi dilakukan dengan menghapus key mentah, request berikutnya bisa mengalami cache hole. Lebih aman menandai stale atau menaikkan versi daripada menghapus data sebelum pengganti siap.

Metrik dan alert yang perlu dipantau

Anti-stampede yang baik tidak cukup benar di kode; ia juga harus terlihat jelas di observability.

Metrik inti

  • cache_hit_total, cache_stale_served_total, cache_miss_total
  • cache_refresh_total per key atau per kelompok key
  • cache_refresh_duration histogram
  • lock_acquire_success_total dan lock_acquire_failure_total
  • lock_wait_duration jika ada mekanisme wait
  • fencing_reject_total untuk mendeteksi stale overwrite yang berhasil dicegah
  • origin_qps atau jumlah query DB/upstream selama periode refresh
  • worker_duplicate_job_total
  • serve_stale_age untuk melihat seberapa tua data yang sedang disajikan

Alert yang masuk akal

  • Rasio miss naik tajam bersamaan dengan lonjakan query origin.
  • Lock acquisition failure sangat tinggi untuk key tertentu dalam periode singkat.
  • Refresh duration mendekati atau melampaui TTL lock.
  • Fencing reject meningkat, tanda adanya proses refresh yang terlambat atau terlalu banyak overlap.
  • Usia stale yang disajikan melebihi batas operasional yang diterima.

Checklist debugging produksi

  1. Periksa apakah miss terjadi serentak saat TTL habis. Ini tanda klasik stampede.
  2. Bandingkan jumlah request, jumlah refresh, dan jumlah query origin. Jika refresh jauh lebih tinggi dari seharusnya, single-flight atau locking mungkin tidak efektif.
  3. Audit granularity key lock. Jangan sampai beberapa resource berbeda berbagi lock yang sama tanpa sengaja.
  4. Periksa TTL lock vs durasi refresh p99. TTL yang lebih pendek dari durasi nyata hampir pasti menimbulkan overlap.
  5. Pastikan release lock memverifikasi owner. Jangan hapus lock milik proses lain.
  6. Pastikan write cache bersifat kondisional berdasarkan versi. Jika tidak, stale overwrite masih mungkin terjadi.
  7. Telusuri job duplikat dari queue. Banyak sistem queue memberi at-least-once delivery, jadi duplikasi adalah perilaku normal.
  8. Cek apakah endpoint menghapus cache sebelum menulis nilai baru. Ini sering menyebabkan cache hole.
  9. Periksa fallback saat lock gagal. Jika fallback-nya bypass ke origin tanpa batas, stampede hanya berpindah tempat.
  10. Tambahkan logging terstruktur per key. Sertakan cache key, owner lock, version, source response, dan durasi refresh.

Kapan memilih tiap pendekatan?

Gunakan stale-while-revalidate jika:

  • data boleh sedikit usang,
  • latency stabil lebih penting daripada freshness absolut,
  • beban refresh mahal dan traffic baca tinggi.

Gunakan single-flight jika:

  • masalah utama ada pada duplikasi dalam satu proses,
  • Anda ingin optimasi sederhana dengan overhead kecil,
  • tetap siap menambah distributed lock untuk deployment multi-instance.

Gunakan distributed lock jika:

  • aplikasi berjalan di banyak instance/container,
  • worker dan web server sama-sama bisa refresh key yang sama,
  • biaya refresh cukup mahal sehingga duplikasi tidak bisa diterima.

Tambahkan fencing token jika:

  • ada kemungkinan lock expire sebelum kerja selesai,
  • write usang dapat menimbulkan bug data yang serius,
  • Anda butuh proteksi terhadap proses lambat, retry, atau pause GC panjang.

Kesimpulan

Untuk SvelteKit: mencegah cache stampede dan race condition worker, pendekatan paling praktis biasanya bukan satu teknik tunggal. Gabungkan stale-while-revalidate untuk menjaga respons cepat, single-flight untuk menekan duplikasi lokal, distributed lock dengan TTL untuk koordinasi lintas instance, fencing token untuk mencegah stale overwrite, dan worker idempoten agar retry tetap aman.

Jika harus memulai dari yang paling berdampak, prioritaskan tiga hal: jangan hapus cache lama sebelum nilai baru siap, pastikan hanya satu proses yang refresh per key, dan tolak write hasil lama dengan versi yang lebih kecil. Tiga langkah ini biasanya sudah menghilangkan sebagian besar stampede dan race condition yang paling mahal di produksi.