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 Pengujian | Tanpa Mutex Lock (Vanilla SSR) | Dengan Redis Mutex + SWR |
|---|---|---|
| Total DB Queries saat Expire | 1.000 queries serentak | 1 query tunggal |
| Peak Database Connection Pool | Saturasi (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. PastikanLOCK_TTLmengakomodasi 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.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!