Lonjakan latensi mendadak pada aplikasi monolit Inertia.js sering kali berakar dari kedaluwarsanya data cache yang diakses secara masif. Ketika TTL (Time to Live) berakhir di bawah beban konkurensi tinggi, puluhan hingga ratusan request secara paralel mengalami cache miss dan memicu eksekusi ulang kueri database yang mahal secara simultan. Fenomena ini disebut cache stampede atau thundering herd.

Akar Masalah: DB Thrashing dan Lonjakan Latensi P99

Inertia.js memuat props halaman melalui controller monolitik (seperti Laravel). Skenario umum melibatkan dashboard dengan agregasi data kompleks: kueri multi-tabel, group by, dan metrik analitik. Pola standar yang sering digunakan adalah implementasi read-through cache naif:

$data = Cache::remember('dashboard_metrics', 300, function () {
    return Metric::calculateHeavyAggregations();
});

Pada traffic 2.000 RPS (requests per second), jika kunci dashboard_metrics kedaluwarsa pada detik ke-300, seluruh request yang masuk pada milidetik berikutnya akan mengeksekusi closure kueri secara bersamaan.

Dampaknya langsung terasa pada infrastruktur:

  • Database Connection Exhaustion: Connection pool database penuh dalam hitungan detik.
  • Thread Thrashing: CPU database melonjak hingga 100% hanya untuk memproses kueri identik berulang kali.
  • P99 Latency Collapse: Latensi response melonjak dari puluhan milidetik ke beberapa detik, berujung pada status HTTP 504 Gateway Timeout.

Pola Arsitektur: Mutex Lock dan Stale-While-Revalidate (SWR)

Solusi deterministik untuk thundering herd adalah memisahkan antara hard TTL (kunci kedaluwarsa sepenuhnya dari Redis) dan soft TTL (batas waktu data dianggap usang/stale), dikombinasikan dengan non-blocking distributed mutex lock.

Alur kerjanya:

  1. Simpan data beserta metadata timestamp stale_at ke Redis dengan hard TTL panjang (misal: 24 jam).
  2. Ketika request masuk, periksa nilai stale_at. Jika waktu sekarang belum melewati stale_at, langsung kembalikan data (Cache Hit).
  3. Jika stale_at terlewati (Stale), worker mencoba mendapatkan Redis lock secara non-blocking (menggunakan perintah SET key token NX PX ttl).
  4. Worker yang berhasil mendapatkan lock segera menghitung ulang data baru, memperbarui cache dan stale_at, lalu melepaskan lock.
  5. Worker lain yang gagal mendapatkan lock tidak menunggu. Worker tersebut langsung mengembalikan stale data yang ada di cache atau mengembalikan respons deferred fallback.

Implementasi Konkret pada Controller

Berikut implementasi reusable service pada backend monolit Laravel yang melayani endpoint Inertia.js:

namespace App\Services;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Throwable;

class SwrCacheService
{
    public static function remember(string $key, int $softTtlSeconds, int $hardTtlSeconds, callable $callback)
    {
        try {
            $cached = Cache::store('redis')->get($key);
        } catch (Throwable $e) {
            Log::error("Redis unreachable on read: {$e->getMessage()}");
            return $callback(); // Fail-open: ambil langsung dari DB jika Redis mati
        }

        $now = now()->timestamp;

        // Cold cache fallback
        if (!$cached) {
            return self::handleColdCache($key, $softTtlSeconds, $hardTtlSeconds, $callback);
        }

        // Cache masih segar
        if ($now < $cached['stale_at']) {
            return $cached['data'];
        }

        // Data usang (stale): Coba perbarui secara non-blocking
        $lockKey = "lock:{$key}";
        $lock = Cache::store('redis')->lock($lockKey, 10); // TTL Lock 10 detik

        if ($lock->get()) {
            try {
                $freshData = $callback();
                Cache::store('redis')->put($key, [
                    'data' => $freshData,
                    'stale_at' => $now + $softTtlSeconds,
                ], $hardTtlSeconds);

                return $freshData;
            } finally {
                $lock->release();
            }
        }

        // Gagal mendapatkan lock: Kembalikan stale data langsung ke client
        return $cached['data'];
    }

    private static function handleColdCache(string $key, int $softTtl, int $hardTtl, callable $callback)
    {
        // Block sebentar untuk satu worker, worker lain menunggu cold generation selesai
        $lock = Cache::store('redis')->lock("lock:{$key}", 15);
        
        return $lock->block(5, function () use ($key, $softTtl, $hardTtl, $callback) {
            // Double-check setelah berhasil mendapatkan antrean
            $existing = Cache::store('redis')->get($key);
            if ($existing) {
                return $existing['data'];
            }

            $data = $callback();
            Cache::store('redis')->put($key, [
                'data' => $data,
                'stale_at' => now()->timestamp + $softTtl,
            ], $hardTtl);

            return $data;
        });
    }
}

Penggunaan pada Inertia controller:

namespace App\Http\Controllers;

use App\Services\SwrCacheService;
use App\Models\Analytics;
use Inertia\Inertia;
use Inertia\Response;

class DashboardController extends Controller
{
    public function index(): Response
    {
        $metrics = SwrCacheService::remember(
            key: 'dashboard_aggregates',
            softTtlSeconds: 300,      // Revalidasi setiap 5 menit
            hardTtlSeconds: 86400,    // Simpan stale hingga 24 jam
            callback: fn() => Analytics::computeHeavyMetrics()
        );

        return Inertia::render('Dashboard/Index', [
            'metrics' => $metrics,
        ]);
    }
}

Mitigasi Deadlock dan Kegagalan Koneksi Redis

Sistem terdistribusi rentan terhadap deadlock dan pemadaman node cache. Dua skenario berikut wajib diantisipasi dalam arsitektur:

1. Deadlock Mitigasi via Lock Lease Time

Jika proses komputasi yang memegang lock mengalami Out-of-Memory (OOM) atau terminasi proses sebelum memanggil method release(), kunci lock akan tertahan selamanya. Penggunaan atomic TTL (lease time) pada Redis lock (seperti $lock = Cache::lock($lockKey, 10)) memastikan kunci akan otomatis dihapus oleh Redis setelah 10 detik, membebaskan antrean komputasi berikutnya.

2. Fail-Open saat Kegagalan Koneksi Redis

Cluster Redis yang down tidak boleh menghentikan seluruh aplikasi Inertia (500 Server Error). Blok try-catch di tingkat cache service mengimplementasikan mekanisme fail-open. Jika sambungan Redis terputus, controller langsung mengeksekusi callback database atau mengembalikan representasi parsial, mempertahankan ketersediaan sistem meski dengan latensi kueri yang lebih tinggi.

Observabilitas: Metrik P99 dan Cache Hit Ratio

Efektivitas mitigasi thundering herd harus diverifikasi melalui telemetri produksi:

  • Cache Hit Ratio: Pasang metrik kustom (menggunakan Prometheus/StatsD) untuk melacak rasio CACHE_HIT, CACHE_STALE_HIT, dan CACHE_MISS. Di bawah pola SWR, CACHE_MISS hanya boleh terjadi saat sistem cold boot.
  • P99 & P95 Latency: Amati distribusi latensi request HTTP di APM (New Relic, Datadog, atau Grafana Tempo). Eliminasi thundering herd ditandai dengan kurva P99 yang stabil dan ketiadaan lonjakan tajam berkala setiap kali siklus TTL berakhir.
  • Database Active Connections: Pool koneksi database harus tetap konstan tanpa lonjakan mendadak di jam sibuk.