โ† semua materi
05:00 Arsitektur ยท 06 Nov 2026 ยท 05:01

๐Ÿ“š Rate Limiting: Token Bucket, Sliding Window, Versi Terdistribusi

๐ŸŽฏ Tujuan Belajar

Setelah sesi ini, kamu bisa:


๐Ÿง  Penjelasan Inti

Rate limiter menjawab satu pertanyaan: "boleh request ini lewat sekarang?" berdasarkan state yang dihitung dari request sebelumnya.

Kenapa ada:

  1. Proteksi kapasitas โ€” mencegah satu klien menghabiskan resource bersama (CPU, koneksi DB, kuota upstream berbayar).
  2. Fairness โ€” distribusi kapasitas antar tenant/user.
  3. Kontrol biaya โ€” API berbayar per-call (LLM, maps, SMS) wajib di-throttle.
  4. Pertahanan abuse โ€” brute force, credential stuffing, scraping.
  5. Stabilitas sistem โ€” membatasi arrival rate agar antrean internal tak meledak (lihat: backpressure vs load shedding โ€” beda hal, dibahas di bagian trade-off).

Yang sering dilupakan: rate limiting itu bukan load shedding dan bukan concurrency limiting. Rate limit membatasi laju kedatangan; concurrency limit membatasi yang sedang diproses. Sistem bisa punya RPS rendah tapi tiap request butuh 30 detik โ†’ tetap kolaps. Dua-duanya dibutuhkan.


โš™๏ธ Cara Kerja Internal

1. Fixed Window Counter

Bagi waktu jadi window tetap (mis. per menit). Counter per window, reset saat window berganti.

key = "rl:{user}:{floor(now/60)}"
count = INCR key; if count == 1: EXPIRE key 60
allow = count <= limit

Masalahnya klasik: boundary burst. Limit 100/menit. Klien kirim 100 request di detik ke-59, lalu 100 lagi di detik ke-61 (window baru). Dalam 2 detik ia lolos 200 request โ€” 2ร— limit. Burst ini nyata di produksi dan jadi celah abuse.

2. Sliding Window Log

Simpan timestamp tiap request, buang yang lebih tua dari window, hitung sisanya.

3. Sliding Window Counter (weighted / hybrid)

Interpolasi antara dua fixed window:

prev_count, cur_count
elapsed_ratio = (now - window_start) / window_size
estimate = cur_count + prev_count * (1 - elapsed_ratio)
allow = estimate < limit

4. Token Bucket

Bucket berisi token, terisi dengan laju r token/detik hingga kapasitas b. Tiap request mengambil 1 token (atau n untuk biaya berbobot).

state: (tokens, last_refill_ts)
on_request(now, cost):
    tokens = min(b, tokens + (now - last_refill_ts) * r)
    last_refill_ts = now
    if tokens >= cost: tokens -= cost; allow
    else: deny (retry_after = (cost - tokens) / r)

Karakteristik: mengizinkan burst hingga b, lalu melaju konstan r. Ini yang biasanya diinginkan: pengguna boleh "nembak" beberapa request cepat, tapi laju rata-rata terjaga.

Lazy refill (menghitung saat request datang, bukan timer) adalah implementasi standar โ€” tidak ada background thread, tidak ada timer drift di level bucket.

5. Leaky Bucket

Antrean dengan laju keluar konstan. Input melimpah โ†’ dibuang (atau diantrekan). Output rate sempurna konstan, tapi tidak ada burst allowance. Dipakai saat downstream butuh laju sangat rata (mis. kirim ke sistem legacy, atau shaping traffic ke pihak ketiga). Token bucket = "izin burst"; leaky bucket = "paksa rata".

6. GCRA (Generic Cell Rate Algorithm)

Versi hemat memori dari leaky bucket. State-nya satu angka: TAT (theoretical arrival time).

tat = max(tat, now) + emission_interval
if tat - burst_tolerance > now: deny, retry_after = tat - burst_tolerance - now

Satu-satunya state adalah satu float/int per key. Ini yang dipakai di banyak rate limiter modern (mis. implementasi token-bucket berbasis Redis yang menyimpan dua field, atau GCRA yang menyimpan satu). Trade-off: kamu dapat burst tolerance dan rate, tapi tidak bisa menanyakan "berapa token tersisa" secara natural (bisa dihitung, tapi tidak intuitif). Kalau butuh header X-RateLimit-Remaining yang akurat untuk semua algoritma, GCRA sedikit lebih repot.


๐Ÿ—๏ธ Kompleksitas & State

Algoritma State per key Akurasi Burst Cocok untuk
Fixed window 1 int buruk di boundary 2ร— limit internal, low stakes, murah
Sliding log O(limit) eksak tidak ada limit kecil presisi tinggi (auth)
Sliding window counter 2 int near-exact ~1ร— limit API publik default
Token bucket 2 field eksak b API umum, quota berbayar
Leaky bucket 1 timestamp / queue eksak tidak ada shaping ke downstream sensitif
GCRA 1 int eksak burst_tolerance high-throughput, hemat memori

โš™๏ธ Versi Terdistribusi โ€” Ini Bagian yang Sebenarnya Sulit

Rate limiter in-process (mis. golang.org/x/time/rate, Bucket4j lokal, express-rate-limit default memory store) hanya benar untuk satu proses. Dengan N instance, limit efektif = N ร— limit. Kalau kamu pakai autoscaling, limit-mu berubah-ubah mengikuti jumlah pod โ€” bug yang sangat sulit direproduksi di staging.

Opsi state bersama, dengan konsekuensinya:

(a) Redis INCR + EXPIRE (fixed window) โ€” atomik, satu round-trip, tapi boundary burst tetap ada dan INCR+EXPIRE bukan operasi atomik gabungan (gunakan Lua atau SET ... EX dengan pola yang benar; race pada EXPIRE bisa membuat key tanpa TTL โ†’ memory leak yang tidak pernah mati).

(b) Redis + Lua script (token bucket / GCRA) โ€” atomik, satu RTT, state kecil. Ini praktik standar. Biayanya: setiap request butuh round-trip ke Redis sebelum bisa diputuskan.

(c) Redis Cluster โ€” key harus di satu slot. Rate limit per user โ†’ key per user โ†’ tersebar. Kalau kamu pakai hash tag {user_id}, key terkait user itu menempel di satu shard โ†’ aman, tapi hot user bisa jadi hot shard. Kalau pakai multi-key (mis. limit global + limit per user dalam satu script), wajib hash tag yang sama, dan itu memaksa keduanya di shard yang sama โ€” kompromi antara atomisitas dan distribusi.

(d) Local + async sync (approximate) โ€” tiap instance punya bucket lokal yang diisi dari rate r/N, dan secara periodik melakukan reconciliation (baca/tulis ke Redis tiap X ms). Trade-off: latensi nol (tidak ada round-trip per request), tapi bisa over-admit sampai N ร— b selama jeda sinkronisasi. Untuk limit yang tidak terkait uang, ini sering paling pragmatis. Untuk quota berbayar, jangan.

(e) Edge/CDN (Cloudflare, API Gateway, Nginx limit_req) โ€” dilakukan sebelum request menyentuh aplikasi. limit_req Nginx = leaky bucket. Sangat murah dan melindungi lapisan di bawahnya, tapi granularitasnya kasar (IP, path) dan tidak tahu identitas user yang di-autentikasi aplikasi.

Poin penting: lapisan-lapisan ini tidak saling menggantikan. Pola produksi biasanya: edge limit per IP (anti-abuse kasar) + gateway/app limit per API key/user (quota + fairness) + concurrency limit per backend (proteksi resource). Tiap lapisan punya tujuan berbeda.

Fail-open vs fail-closed

Kalau Redis mati, apa yang terjadi? Dua pilihan, dan ini keputusan bisnis, bukan teknis:

Praktik yang lebih baik: fallback ke local bucket yang lebih ketat saat Redis unreachable (mis. limit lokal = limit global / N, dengan sedikit margin). Degradasi bertingkat, bukan biner.


โš–๏ธ Trade-off & Kapan JANGAN Dipakai

Jangan pakai rate limit untuk masalah concurrency

Kalau downstream bisa melayani 50 request paralel dengan latensi 100 ms, rate limit yang tepat adalah ~500 rps asumsi latensi stabil. Begitu latensi naik ke 1 detik (slow query, GC pause), 500 rps = 500 request paralel > kapasitas 50 โ†’ antrean meledak, lalu timeout, lalu retry, lalu kolaps. Di sini yang dibutuhkan concurrency limit (semaphore / queued requests limit, mis. max_inflight), bukan rate limit. Rate limit dan concurrency limit adalah dua alat berbeda.


๐Ÿญ Contoh Produksi & Failure Mode

Boundary burst fixed window โ€” nyata di banyak API. Limit "1000/jam" yang diimplementasi dengan counter per jam bisa dilalui 2000 request dalam 2 detik di pergantian jam. Solusi: sliding window atau token bucket.

Key tanpa TTL โ€” INCR berhasil, EXPIRE gagal (proses mati di antara dua perintah, atau Lua script salah). Key tumbuh tanpa batas โ†’ Redis kehabisan memori โ†’ eviction โ†’ rate limit state hilang random โ†’ semua user tiba-tiba boleh lagi, atau semuanya diblok. Wajib pakai atomic script atau SET ... EX NX + INCR dengan pola yang menjamin TTL.

Autoscaling mengubah limit โ€” limiter lokal per pod. Trafik naik โ†’ autoscaler naik ke 10 pod โ†’ limit efektif naik 10ร—. Penyerang yang punya kapasitas menghasilkan lonjakan justru mendapat lebih banyak kuota. Ini adalah alasan paling umum kenapa rate limiter "tidak jalan" di produksi.

Redis sebagai single point of failure โ€” semua traffic butuh Redis sebelum bisa diputuskan. Redis latency 5 ms โ†’ setiap request +5 ms. Redis down โ†’ seluruh API down (fail-closed tanpa fallback). Mitigasi: local caching/approximate mode, Redis clustering + replica read, atau bataskan rate limit ke subset request (mis. 1 dari 8 request dicek, sisanya diadmit) โ€” probabilistic admission, teknik yang mengkompromikan akurasi demi ketersediaan.

Hot key di Redis Cluster โ€” satu user API-key besar membuat satu shard jadi bottleneck seluruh cluster. Mitigasi: sharding key per key (key:{id}:shard dengan token pool terbagi), atau pindahkan limit user besar ke jalur khusus.

429 tanpa Retry-After + klien retry eksponensial tanpa jitter โ€” ribuan klien retry serentak di waktu yang sama; beban setelah 429 bisa lebih tinggi daripada sebelum. Rate limit memperparah insiden karena klien yang salah konfigurasi (ยฑ retry storm) adalah pola yang berulang di dunia nyata.

Limit per IP di belakang NAT/proxy โ€” 500 pengguna kantor berbagi satu IP publik; edge rate limit per IP memblokir semua orang karena satu pengguna scrap. Perlu X-Forwarded-For yang dipercaya dengan hati-hati (bisa dipalsukan) dan/atau limit per identitas aplikasi, bukan per IP.


โš ๏ธ Miskonsepsi Umum

  1. "Rate limit = mencegah serangan DDoS." Tidak. Rate limit melindungi terhadap abuse berkapasitas terbatas. DDoS volumetrik (ratusan Gbps) harus di-handle di network/edge, tidak oleh aplikasi.
  2. "Token bucket jarang menolak, jadi lebih baik." Token bucket mengizinkan burst hingga b โ€” untuk downstream yang sensitif terhadap lonjakan mendadak, ini justru kelemahan. Pilih algoritma berdasarkan bentuk traffic yang kau inginkan, bukan yang paling "ramah".
  3. "Satu rate limiter cukup untuk semua." Satu limiter untuk semua endpoint sering salah: endpoint baca murah vs endpoint tulis mahal vs endpoint auth (perlu ketat) vs endpoint webhook (perlu longgar). Biasanya beberapa limiter dengan budget berbeda, dihitung dari biaya resource masing-masing.
  4. "429 cukup tanpa detail." Retry-After, X-RateLimit-Limit/Remaining/Reset adalah bagian dari kontrak API. Tanpa itu klien tidak bisa berperilaku benar. Tapi ingat: header Remaining dari algoritma berbasis estimasi (sliding counter) hanyalah perkiraan โ€” jangan janjikan presisi di dokumentasi.
  5. "Redis Lua selalu cepat." Script dengan banyak key atau logic berat memblokir Redis single-threaded. Rate limit script harus O(1) atau O(k) kecil, dan hindari EVAL dengan argumen besar.
  6. "Rate limit di reverse proxy sudah melindungi aplikasi." Hanya jika proxy melihat identitas yang benar dan traffic memang lewat proxy. Traffic internal service-to-service, job background, dan jalur admin sering bypass limiter. Ini sumber insiden yang konsisten: limiter menghalangi user, tapi job internal yang buggy tetap menghantam DB sampai mati.

โœ… Cek Pemahaman

  1. Kamu punya limit 1000 req/menit dengan fixed window. Penyerang mengirim 1000 request di detik ke-59 tiap menit. Berapa request yang ia loloskan per menit efektif, dan apa perubahan minimal yang menghentikannya?
  2. API-mu berjalan di 8 pod dengan autoscaling 2โ€“20 pod. Rate limiter token bucket lokal dengan r = 100/s per pod. Limit yang kamu janjikan ke pelanggan adalah "100 req/s per API key". Apa yang salah, dan dua cara memperbaikinya dengan trade-off masing-masing?
  3. Downstream hanya tahan 50 request paralel; rata-rata latensi 200 ms. Kamu diberi pilihan: (a) rate limit 250 rps, (b) concurrency limit 50. Kapan (a) cukup, kapan (b) wajib? Jelaskan skenario di mana (a) gagal tapi (b) berhasil.

๐Ÿ”‘ Poin Kunci


โžก๏ธ Eksplorasi Lanjut