Pada arsitektur Nuxt 3 berbasis SSR yang berjalan di lingkungan multi-instance (seperti Kubernetes Pods atau PM2 cluster), invalidasi cache secara serentak membuka celah performa kritis: cache stampede atau thundering herd. Kondisi ini terjadi ketika entri cache bernilai tinggi kedaluwarsa, lalu ratusan hingga ribuan request masuk secara bersamaan ke layer komputasi Nitro.

Tanpa mekanisme sinkronisasi terdistribusi, setiap instance SSR akan menganggap data tersebut hilang lalu mengeksekusi query berat secara paralel ke database. Artikel ini mengupas mitigasi sistematis menggunakan konfigurasi storage Redis Nitro, strategi Stale-While-Revalidate (SWR), serta implementasi distributed mutex lock berbasis Redis primitif.

Anatomi Masalah: Thundering Herd pada Arsitektur Multi-Instance

Secara default, engine Nitro menyediakan caching memory in-process dan integrasi unstorage. Saat aplikasi diskalakan secara horizontal menjadi beberapa node/worker, masing-masing worker menangani thread request independen.

Ketika TTL (Time-To-Live) cache produk atau halaman utama habis pada detik 00:00:00:

  • Instance A menerima 50 req/detik dan mendeteksi cache miss.
  • Instance B menerima 70 req/detik dan mendeteksi hal serupa secara simultan.
  • Seluruh instance mengeksekusi I/O intensif (misal: query PostgreSQL kompleks atau agregasi data mikroservis).
  • Koneksi database mengalami saturasi (connection pool exhaustion), lonjakan latensi, dan berpotensi memicu HTTP 504 Gateway Timeout secara kaskade.

Konfigurasi Storage Driver Redis di Nuxt 3

Langkah awal adalah memindahkan backend cache Nitro dari memory internal ke Redis menggunakan driver unstorage. Buka berkas konfigurasi Nitro pada nuxt.config.ts.

// nuxt.config.ts
import { defineNuxtConfig } from 'nuxt/config'

export default defineNuxtConfig({
  nitro: {
    storage: {
      redis: {
        driver: 'redis',
        host: process.env.REDIS_HOST || '127.0.0.1',
        port: Number(process.env.REDIS_PORT) || 6379,
        password: process.env.REDIS_PASSWORD || undefined,
        base: 'nitro:cache:'
      }
    }
  }
})

Pengaturan ini memastikan semua instance SSR merujuk ke data layer yang sama melalui Redis instance/cluster terpusat.

Implementasi defineCachedEventHandler dengan SWR

Nitro menyediakan utilitas defineCachedEventHandler yang mendukung paradigma SWR (Stale-While-Revalidate). Strategi ini mengembalikan data kedaluwarsa secara instan ke pengguna, sementara pembaruan data dilakukan di latar belakang.

// server/api/catalog.get.ts
import { defineCachedEventHandler } from '#imports'

export default defineCachedEventHandler(async (event) => {
  // Query database berbobot tinggi
  return await fetchCatalogFromDatabase()
}, {
  maxAge: 60,         // Cache dianggap valid selama 60 detik
  swr: true,          // Mengembalikan data stale saat kedaluwarsa dan memicu fetch baru
  base: 'redis',
  name: 'catalog',
  getKey: () => 'public-catalog'
})

Kelemahan SWR Bawaan pada Multi-Instance: Nitro mengelola deduplikasi promise secara in-memory per worker process. Jika Node A dan Node B menerima request tepat saat SWR jatuh tempo, kedua instance tersebut tetap akan memicu dua query revalidasi paralel ke database. Untuk mengisolasi revalidasi menjadi tepat 1 worker saja, distributed mutex diperlukan.

Implementasi Distributed Mutex Lock via Redis (SET NX PX)

Distributed lock memastikan hanya ada satu worker yang memegang hak eksekusi komputasi ulang. Mekanisme ini menggunakan perintah atomik Redis: SET key value NX PX ttl.

  • NX: Set value hanya jika key belum ada (Not Exists).
  • PX: Tentukan masa kedaluwarsa otomatis dalam milidetik untuk mencegah deadlock jika worker crash sebelum melepas lock.

Buat utility lock menggunakan Redis client pada server/utils/redisLock.ts:

// server/utils/redisLock.ts
import Redis from 'ioredis'
import { randomUUID } from 'node:crypto'

const redis = new Redis({
  host: process.env.REDIS_HOST || '127.0.0.1',
  port: Number(process.env.REDIS_PORT) || 6379,
  password: process.env.REDIS_PASSWORD || undefined,
  lazyConnect: false
})

// Lua script: Lepas lock hanya jika token/value cocok (mencegah release lock instance lain)
const RELEASE_LOCK_LUA = `
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0
end
`

export async function acquireLock(lockKey: string, ttlMs: number): Promise {
  const lockToken = randomUUID()
  const acquired = await redis.set(lockKey, lockToken, 'PX', ttlMs, 'NX')
  return acquired === 'OK' ? lockToken : null
}

export async function releaseLock(lockKey: string, lockToken: string): Promise {
  const result = await redis.eval(RELEASE_LOCK_LUA, 1, lockKey, lockToken)
  return result === 1
}

export { redis }

Integrasi Handler: Mutex Lock dengan Fallback Stale Cache

Terapkan lock di custom server route. Jika instance gagal mendapatkan lock (artinya instance lain sedang melakukan revalidasi), instance tersebut segera membaca data lama (stale) atau melakukan backoff polling pendek, bukan membombardir database.

// server/api/products-protected.get.ts
import { defineEventHandler } from 'h3'
import { acquireLock, releaseLock, redis } from '~/server/utils/redisLock'

const CACHE_KEY = 'data:products'
const LOCK_KEY = 'lock:products'
const LOCK_TTL = 3000 // 3 detik batas revalidasi

interface CachedPayload {
  data: any
  updatedAt: number
}

export default defineEventHandler(async (event) => {
  const rawCache = await redis.get(CACHE_KEY)
  const cached: CachedPayload | null = rawCache ? JSON.parse(rawCache) : null
  const isStale = !cached || (Date.now() - cached.updatedAt > 30000) // TTL 30s

  // Jika data masih fresh, return langsung
  if (cached && !isStale) {
    return cached.data
  }

  // Revalidasi dibutuhkan: Coba ambil lock
  const lockToken = await acquireLock(LOCK_KEY, LOCK_TTL)

  if (!lockToken) {
    // Gagal mendapatkan lock: Worker lain sedang revalidasi
    if (cached) {
      // SWR fallback: Sajikan data stale sementara
      return cached.data
    }

    // Cold cache: Tidak ada data sama sekali, lakukan polling singkat dengan backoff
    return await pollForFreshData(CACHE_KEY, 10, 100)
  }

  try {
    // Hanya 1 instance yang mencapai blok ini
    const freshData = await queryHeavyDatabase()
    
    await redis.set(CACHE_KEY, JSON.stringify({
      data: freshData,
      updatedAt: Date.now()
    }))

    return freshData
  } catch (error) {
    // Fail-safe: Jika regenerasi gagal dan ada data stale, gunakan data stale
    if (cached) return cached.data
    throw createError({ statusCode: 500, statusMessage: 'Revalidation Failed' })
  } finally {
    await releaseLock(LOCK_KEY, lockToken)
  }
})

async function pollForFreshData(key: string, retries: number, delayMs: number): Promise {
  for (let i = 0; i < retries; i++) {
    await new Promise((resolve) => setTimeout(resolve, delayMs))
    const data = await redis.get(key)
    if (data) return JSON.parse(data).data
  }
  throw createError({ statusCode: 504, statusMessage: 'Cache Generation Timeout' })
}

async function queryHeavyDatabase() {
  // Simulasi query lambat
  return [{ id: 1, name: 'Sample Item' }]
}

Simulasi Konkurensi dan Perbandingan Beban Database

Pengujian disimulasikan menggunakan tool benchmarking HTTP (seperti k6 atau autocannon) dengan 1.000 request bersamaan tepat pada saat cache kedaluwarsa.

Metrik PengujianTanpa Mutex Lock (Vanilla SSR)Dengan Redis Mutex + SWR
Total DB Queries saat Expire1.000 queries serentak1 query tunggal
Peak Database Connection PoolSaturasi (Max connections hit)Normal (1 active connection)
Latensi p99> 4.200 ms~12 ms (served stale)
Tingkat Error (5xx)14.2% (Connection timeout)0%

Pola Penanganan Timeout dan Fail-Safe

Distributed lock membawa risiko baru jika tidak ditangani secara defensif:

  • Lock Renewal (Heartbeat): Jika query ke database membutuhkan waktu tak terduga melebihi LOCK_TTL, lock otomatis kedaluwarsa di Redis. Instance lain dapat mengambil lock baru, menyebabkan race condition. Pastikan LOCK_TTL mengakomodasi batas p99.9 durasi query, atau buat timer interval untuk memperpanjang TTL jika eksekusi belum selesai.
  • Safety Release via Lua Script: Jangan pernah menghapus lock hanya dengan perintah DEL key. Jika worker A mengalami delay GC dan lock kedaluwarsa, lalu worker B mengambil lock, worker A yang baru selesai bisa menghapus lock milik worker B tanpa sengaja. Penggunaan token acak yang diverifikasi lewat Lua script (seperti pada contoh di atas) menjamin atomisitas validasi kepemilikan lock.
  • Graceful Degradation: Jika Redis gagal diakses (connection refused), siapkan fallback untuk mengabaikan locking dan langsung mengarahkan request ke upstream dengan pembatasan koneksi di level application pool.