Anatomi Masalah: Cache Stampede dan Thundering Herd Pasca-Invalidasi

Ketika objek cache dengan tingkat konkurensi tinggi kedaluwarsa (TTL habis) atau di-purge secara eksplisit, sistem menghadapi risiko cache stampede (dikenal juga sebagai thundering herd problem). Jika sebuah resource menerima 10.000 request per detik (RPS) dan cache tiba-tiba menjadi invalid, 10.000 request masuk tersebut akan secara bersamaan mendeteksi cache miss.

Tanpa mekanisme koordinasi pada HTTP proxy layer, seluruh request akan di-forward langsung ke backend origin. Dampaknya meliputi lonjakan beban CPU backend secara instan, saturasi connection pool database, peningkatan drastis pada latensi tail (p99/p999), hingga potensi kegagalan berantai (cascading failure) yang merobohkan downstream service.

Mekanisme Request Coalescing (Request Collapsing)

Request coalescing (atau request collapsing) adalah teknik sinkronisasi konkurensi di mana caching layer mencegat beberapa request identik yang datang secara bersamaan (in-flight requests) dan menggabungkannya menjadi satu fetch tunggal ke server origin.

Secara arsitektur, evolusi konsep ini dipopulerkan oleh sistem seperti Varnish Cache (melalui mekanisme waiting lists) dan arsitektur Vinyl Cache milik Facebook. Alur eksekusi request coalescing bekerja sebagai berikut:

  1. Identifikasi Hash Key: HTTP proxy menerima request dan menghitung cache key (misalnya: hash dari HTTP Method + Host + Request URI).
  2. State Detection: Proxy memeriksa tabel in-flight fetch. Jika key sedang di-fetch oleh worker lain, request saat ini ditandai sebagai sub-request (follower).
  3. Sub-request Queueing: Request pemimpin (leader) melanjutkan koneksi ke backend origin. Seluruh follower dialihkan ke antrean tunggu (waiting list/queue) yang diblokir oleh condition variable, mutex, atau channel.
  4. Broadcast Response: Ketika response origin kembali, HTTP proxy menyimpan payload ke dalam storage cache, kemudian membroadcast response body dan header identik ke seluruh sub-request yang sedang menunggu.

Sinergi dengan Stale-While-Revalidate (Grace Mode)

Kelemahan murni dari request coalescing tanpa optimasi tambahan adalah latensi: semua sub-request terpaksa menunggu durasi round-trip fetch dari origin oleh leader request. Jika origin membutuhkan 200ms, seluruh klien yang tertahan dalam antrean ikut mengalami penalti latensi 200ms.

Solusi optimal menggabungkan request coalescing dengan Stale-While-Revalidate (RFC 5861) atau Grace Mode pada Varnish:

  • Saat cache key kedaluwarsa, proxy tetap menyajikan data usang (stale object) secara instan kepada klien.
  • Secara asinkron di background, sistem memicu tepat satu request revalidasi ke origin menggunakan request coalescing.
  • Request berikutnya akan langsung mendapatkan objek baru setelah revalidasi background selesai, mempertahankan latensi edge di kisaran sub-milidetik tanpa membebani origin.

Implementasi Praktis

1. Pola Singleflight pada Go Reverse Proxy

Paket golang.org/x/sync/singleflight menyediakan mekanisme duplikasi eksekusi fungsi in-flight berdasarkan key tertentu.

package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"sync"
	"time"

	"golang.org/x/sync/singleflight"
)

type CacheEntry struct {
	Body      []byte
	Header    http.Header
	ExpiresAt time.Time
}

type CoalescingProxy struct {
	cache sync.Map
	group singleflight.Group
}

func (p *CoalescingProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	cacheKey := r.Method + ":" + r.URL.String()

	// 1. Cek Cache Hit
	if val, ok := p.cache.Load(cacheKey); ok {
		entry := val.(CacheEntry)
		if time.Now().Before(entry.ExpiresAt) {
			writeResponse(w, entry)
			return
		}
	}

	// 2. Coalesce In-Flight Request
	data, err, _ := p.group.Do(cacheKey, func() (interface{}, error) {
		// Timeout eksplisit backend fetch
		ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
		defer cancel()

		req, err := http.NewRequestWithContext(ctx, r.Method, "http://backend-origin"+r.URL.RequestURI(), nil)
		if err != nil {
			return nil, err
		}

		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			return nil, err
		}
		defer resp.Body.Close()

		body, err := io.ReadAll(resp.Body)
		if err != nil {
			return nil, err
		}

		entry := CacheEntry{
			Body:      body,
			Header:    resp.Header.Clone(),
			ExpiresAt: time.Now().Add(30 * time.Second),
		}

		p.cache.Store(cacheKey, entry)
		return entry, nil
	})

	if err != nil {
		http.Error(w, "Upstream Gateway Error: "+err.Error(), http.StatusBadGateway)
		return
	}

	writeResponse(w, data.(CacheEntry))
}

func writeResponse(w http.ResponseWriter, entry CacheEntry) {
	for k, v := range entry.Header {
		w.Header()[k] = v
	}
	w.WriteHeader(http.StatusOK)
	w.Write(entry.Body)
}

2. Konfigurasi Proxy Lock pada Nginx

Jika menggunakan Nginx sebagai HTTP cache proxy, aktifkan direktif proxy_cache_lock untuk mengimplementasikan request collapsing:

http {
    proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=STATIC_CACHE:10m inactive=24h max_size=1g;

    server {
        listen 80;

        location /api/ {
            proxy_pass http://backend_upstream;
            proxy_cache STATIC_CACHE;
            proxy_cache_key $request_method$scheme$proxy_host$request_uri;

            # Mengaktifkan request coalescing
            proxy_cache_lock on;
            proxy_cache_lock_timeout 2s;
            proxy_cache_lock_age 3s;

            # Kombinasi dengan stale cache
            proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
            proxy_cache_valid 200 302 10m;
        }
    }
}

Mitigasi Timeout Backend dan Connection Pool Exhaustion

Request coalescing memusatkan dependensi ratusan sub-request pada satu koneksi upstream. Pola ini memicu titik kegagalan baru jika backend mengalami degradasi:

  • Leader Hang: Jika origin lambat merespons atau mengalami socket stall, seluruh sub-request yang mengantre akan menahan koneksi klien downstream. Hal ini dapat menghabiskan limit open file descriptor atau thread worker proxy.
  • Mitigasi Lock Timeout: Batasi durasi penguncian antrean (misalnya proxy_cache_lock_timeout pada Nginx atau context timeout pada Go). Jika timeout terlampaui, bebaskan sub-request untuk melakukan fetch mandiri ke origin atau fallback ke cache stale.
  • Circuit Breaker: Pasang circuit breaker di depan upstream fetcher. Jika error rate upstream melebihi ambang batas, tolak eksekusi leader fetch secara cepat (fail-fast) agar ribuan sub-request tidak hang bersamaan.

Metrik Observabilitas Kritis

Implementasi request coalescing membutuhkan pemantauan metrik pada monitoring pipeline (Prometheus/OpenTelemetry):

  • Origin Fetch Rate: Volume request per detik yang diteruskan ke upstream. Nilai ini harus tetap datar (flat) meskipun terjadi invalidasi massal pada cache key populer.
  • Lock Wait Duration: Distribusi histogram waktu tunggu yang dihabiskan sub-request di dalam antrean menunggu leader fetch selesai.
  • Coalesced Request Ratio: Rasio antara total request masuk dibandingkan dengan request yang dieksekusi ke backend. Dihitung dengan rumus: (total_requests - origin_fetches) / total_requests.
  • Origin Failure Multiplier: Lacak jumlah sub-request yang terkena dampak saat satu leader fetch menghasilkan status 5xx atau network error.