Lonjakan trafik tinggi pada endpoint yang mengandalkan caching sering memicu masalah kritis saat masa berlaku data berakhir: ribuan eksekusi konkuren mendapati cache miss secara bersamaan dan serentak mengeksekusi kueri berat ke basis data. Fenomena ini dikenal sebagai cache stampede atau thundering herd problem.
Artikel ini membahas akar penyebab kegagalan mitigasi bawaan pada Next.js dalam skala terdistribusi, serta implementasi konkret distributed mutex lock menggunakan Redis untuk melindungi upstream database.
Anatomi Masalah: Thundering Herd pada Next.js App Router
Saat Server Component di Next.js memproses permintaan masuk, data sering diambil menggunakan layer abstraksi cache. Jika data bernilai hot diakses oleh 2.000 concurrent request per detik dan TTL (Time-To-Live) cache habis pada detik ke-60, seluruh 2.000 worker thread/proses akan membaca kondisi cache kosong di milidetik yang sama.
Dampaknya langsung terasa pada infrastruktur downstream:
- Database Connection Pool Exhaustion: Batas koneksi Postgres/MySQL terlampaui dalam hitungan milidetik, menghasilkan galat
max_connections reachedatauConnection pool timeout. - CPU Spikes: Peningkatan utilisasi CPU hingga 100% pada database instance akibat parsing dan eksekusi ribuan kueri identik secara simultan.
- Cascading Failure: Service Next.js mengalami timeout antrean permintaan, memicu health check orkestrator (seperti Kubernetes) gagal, dan berujung pada restart pod yang memperparah kegagalan kluster.
Kelemahan unstable_cache pada Arsitektur Multi-Instance
Next.js menyediakan unstable_cache untuk menyimpan hasil eksekusi fungsi async di Data Cache. Fitur ini bekerja baik pada satu instance monolitik sederhana dengan memori lokal. Namun, pada arsitektur terdistribusi (multi-pod, multi-container Docker, atau edge/serverless compute), muncul celah kritis:
- Koordinasi Antar-Pod Terisolasi: Revalidasi lokal
unstable_cachetidak memiliki koordinasi global out-of-the-box lintas instance container tanpa custom shared cache handler. Jika 10 pod menjalankan instance aplikasi Next.js secara horizontal, kesepuluh pod akan menjalankan revalidasi fungsi masing-masing secara bersamaan saat cache lokal/shared belum terisi. - Cold Start & Hard Cache Expiry: Saat cache benar-benar terhapus atau kedaluwarsa secara absolut (bukan soft-stale revalidation), tidak ada mekanisme locking bawaan yang menahan antrean request. Mekanisme deduplikasi
fetch()bawaan React hanya berlaku dalam single render pass lifecycle untuk satu request masuk, bukan antar-request konkuren yang berbeda.
Arsitektur Distributed Lock Menggunakan Redis
Pola distributed lock memastikan hanya satu worker thread dari seluruh armada instance Next.js yang diizinkan mengambil data baru dari database saat cache habis. Worker lain yang gagal mendapatkan lock memiliki dua opsi rute:
- Membaca data lama yang masih tersisa (Stale Value Fallback).
- Menunggu sejenak dan mencoba kembali membaca cache (Retry Backoff) jika data sama sekali belum pernah ada.
Diagram alir logika koordinasi Redis:
Client Request
│
▼
Check Cache (Redis) ──[ HIT & FRESH ]──> Return Cached Data
│
[ MISS / STALE ]
│
▼
Acquire Distributed Lock (SET key token NX PX ttl)
├─── [ ACQUIRED ] ────> Fetch DB ──> Set Cache ──> Release Lock (Lua) ──> Return Fresh
│
└─── [ FAILED ] ──────> Has Stale Data?
├─── [ YES ] ──> Return Stale Data
└─── [ NO ] ──> Retry Loop + Backoff ──> Re-read CacheImplementasi Distributed Lock Wrapper di Next.js
Langkah implementasi berikut menggunakan driver ioredis. Pola ini memisahkan logical expiry dengan physical persistence agar stale data tetap tersimpan saat data kedaluwarsa.
1. Inisialisasi Redis Client Singleton
// lib/redis.ts
import Redis from 'ioredis';
declare global {
var redisInstance: Redis | undefined;
}
// ponytail: basic singleton; ganti dengan Redis Cluster jika menggunakan multi-region
export const redis =
globalThis.redisInstance ||
new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
maxRetriesPerRequest: 2,
enableReadyCheck: false,
});
if (process.env.NODE_ENV !== 'production') {
globalThis.redisInstance = redis;
}2. Locking Wrapper dengan Safe Lua Release dan Stale Fallback
// lib/distributed-cache.ts
import { redis } from './redis';
import crypto from 'crypto';
interface CacheEnvelope<T> {
data: T;
expiresAt: number;
}
interface FetchOptions<T> {
key: string;
logicalTtlSec: number;
physicalTtlSec?: number;
lockTtlMs?: number;
maxRetryAttempts?: number;
fetchFn: () => Promise<T>;
}
// Script Lua untuk memastikan hanya pemilik lock yang dapat menghapusnya
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 fetchWithDistributedLock<T>({
key,
logicalTtlSec,
physicalTtlSec = logicalTtlSec * 2,
lockTtlMs = 5000,
maxRetryAttempts = 5,
fetchFn,
}: FetchOptions<T>): Promise<T> {
const cacheKey = `cache:${key}`;
const lockKey = `lock:${key}`;
const lockToken = crypto.randomUUID();
// 1. Baca data dari Redis
const cachedRaw = await redis.get(cacheKey);
let envelope: CacheEnvelope<T> | null = cachedRaw ? JSON.parse(cachedRaw) : null;
const now = Date.now();
// 2. Data ditemukan dan masih fresh
if (envelope && envelope.expiresAt > now) {
return envelope.data;
}
// 3. Data expired/miss: Coba ambil distributed lock
const acquired = await redis.set(lockKey, lockToken, 'PX', lockTtlMs, 'NX');
if (acquired === 'OK') {
try {
// Worker ini memenangkan lock: eksekusi fungsi database/upstream
const freshData = await fetchFn();
const newEnvelope: CacheEnvelope<T> = {
data: freshData,
expiresAt: Date.now() + logicalTtlSec * 1000,
};
// Simpan data baru dengan physical TTL lebih lama untuk mendukung fallback stale
await redis.set(cacheKey, JSON.stringify(newEnvelope), 'EX', physicalTtlSec);
return freshData;
} finally {
// 4. Safe release lock menggunakan script Lua
await redis.eval(RELEASE_LOCK_LUA, 1, lockKey, lockToken);
}
}
// 5. Gagal dapat lock: Jika stale data tersedia, kembalikan langsung (SWR pattern)
if (envelope) {
return envelope.data;
}
// 6. Cold miss (stale data kosong): Jalankan retry backoff hingga lock dilepas worker utama
for (let attempt = 1; attempt <= maxRetryAttempts; attempt++) {
const jitter = Math.random() * 50;
const waitMs = Math.min(1000, Math.pow(2, attempt) * 40 + jitter);
await new Promise((res) => setTimeout(res, waitMs));
const retryRaw = await redis.get(cacheKey);
if (retryRaw) {
const retryEnvelope: CacheEnvelope<T> = JSON.parse(retryRaw);
return retryEnvelope.data;
}
}
// Skenario darurat: Retry habis dan DB lock masih tertahan
throw new Error(`Lock contention timeout on resource key: ${key}`);
}3. Penggunaan pada Server Component / Data Access Layer
// app/products/[id]/page.tsx
import { fetchWithDistributedLock } from '@/lib/distributed-cache';
import { db } from '@/lib/db';
import { notFound } from 'next/navigation';
interface PageProps {
params: Promise<{ id: string }>;
}
async function getProductDetail(productId: string) {
return fetchWithDistributedLock({
key: `product:${productId}`,
logicalTtlSec: 60, // Cache fresh selama 1 menit
physicalTtlSec: 3600, // Stale data disimpan 1 jam untuk tameng stampede
lockTtlMs: 3000, // Batas eksekusi query sebelum lock auto-expire
fetchFn: async () => {
const product = await db.product.findUnique({
where: { id: productId },
});
return product;
},
});
}
export default async function ProductPage({ params }: PageProps) {
const { id } = await params;
const product = await getProductDetail(id);
if (!product) {
notFound();
}
return (
<main className="p-6">
<h1 className="text-2xl font-bold">{product.name}</h1>
<p className="mt-2 text-gray-700">{product.description}</p>
<span className="font-mono text-lg">Rp {product.price.toLocaleString()}</span>
</main>
);
}Evaluasi Trade-off dan Pertimbangan Teknis
Penerapan distributed lock membawa kompromi desain sistem yang perlu diperhitungkan sebelum diaplikasikan ke seluruh layer query:
| Aspek | Tanpa Lock (Direct SWR/unstable_cache) | Dengan Distributed Lock (Redis Mutex) |
|---|---|---|
| Beban Database | Sangat tinggi saat stampede (ratusan query/detik). | Stabil konstan (maksimal 1 query per key). |
| Latensi P99 (Cold Cache) | Tinggi akibat DB queue saturation. | Stabil untuk pemenang lock; bertambah 50-200ms bagi worker yang menunggu retry. |
| Infrastruktur Tambahan | Nol (cukup in-memory bawaan). | Membutuhkan Redis cluster yang highly-available. |
| Risiko Deadlock | Tidak ada. | Mitigasi wajib via PX (lock auto-expiration) dan Lua script. |
Observabilitas: Metrik Deteksi Lock Contention
Implementasi distributed lock yang buruk dapat memindahkan bottleneck dari database ke antrean lock Redis. Untuk memantau stabilitas sistem di lingkungan produksi, pantau metrik berikut:
- Lock Contention Rate: Rasio antara percobaan akuisisi lock yang gagal (mendapatkan null dari
SET NX) terhadap total permintaan cache miss. Jika rasio > 80% secara konstan pada non-peak hour, perpanjang logical TTL. - Stale Fallback Count: Jumlah request yang disajikan menggunakan data stale. Angka ini normal mengalami lonjakan singkat saat revalidasi berjalan, membuktikan thundering herd berhasil diredam.
- Lock Execution Duration: Waktu yang dibutuhkan callback
fetchFnuntuk menyelesaikan eksekusi hingga lock dilepas. Jika durasi mendekati nilailockTtlMs, naikkan TTL lock untuk mencegah lock release prematur sebelum fetch selesai. - Redis Command Latency: Pastikan metrik P99 response time Redis berada di bawah 2 milidetik. Perintah
evalLua script danSET NXharus beroperasi dengan kompleksitas waktu O(1).
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!