Sistem tasarımı

Dağıtık rate limiter

Orta~45 dkAmazonGoogleMicrosoftMetaStripe
Cevaplar:

Problem

"Şirketin dışa açık bir API platformu var: satıcılar, iş ortakları ve mobil uygulama aynı gateway'den geçiyor. API key, kullanıcı, IP ve endpoint bazında limit koyabilen, 50 gateway pod'unda ve iki bölgede tutarlı çalışan bir rate limiter tasarla. Limit aşıldığında istemci ne zaman tekrar deneyeceğini bilmeli."

Algoritma tarafı (token bucket, sliding window, tek JVM içinde thread-safe implementasyon ve Redis Lua script'i) Kitap Bölüm 19'da live coding sorusu olarak çözüldü. Bu case aynı problemin sistem tarafıdır: limit nerede uygulanır, durum nerede tutulur, 50 pod nasıl aynı sayacı paylaşır, Redis her istekte çağrılabilir mi, Redis çökerse ne olur, kurallar nasıl değişir ve iki bölge nasıl koordine olur.

Mülakatçı bu soruda şunlara bakar: algoritma seçimini gereksinime bağlamak, atomikliği doğru yerde sağlamak, gecikme bütçesini hesaplamak, fail-open/fail-closed kararını bilinçli vermek ve rate limiter'ın kendisinin bir tek hata noktasına dönüşmemesi.

Netleştirme soruları ve varsayımlar

SoruNeden önemliVarsayım
Kendi servisimizi mi koruyoruz, yoksa bir bağımlılığın kotasına mı uyuyoruz?Sunucu tarafı mı istemci tarafı mıSunucu tarafı: gateway'de gelen trafiği sınırlamak
Hangi boyutlarda limit?Anahtar tasarımıAPI key (plan bazlı), kullanıcı, IP (kimliksiz istekler), endpoint
Limit ne kadar kesin olmalı? %1 aşım kabul mü?Algoritma ve yerel/global dengeKotalar sözleşmeye bağlı; birkaç yüzde sapma kabul, sistematik 2 kat aşım değil
Kısa patlamalara izin var mı?Token bucket vs sabit hızEvet: dakikada 600 istek, anlık 100'e kadar patlama
Gecikme bütçesi?Redis'e her istekte gidilebilir miRate limit kontrolü p99 2 ms'yi geçmemeli
Limit aşılınca ne olacak?Reddet mi, kuyruğa al mıReddet: 429 + Retry-After
Kurallar ne sıklıkla değişir, kim değiştirir?Dinamik konfigürasyonÜrün ekibi; deploy gerektirmeden, dakikalar içinde
Çok bölge var mı, limit global mi?Bölgeler arası koordinasyonİki bölge; API key limiti global kabul edilir ama birkaç saniyelik sapma tolere edilir
Rate limiter düşerse ne olsun?Fail-open/fail-closedKurala göre: çoğunda fail-open, kötüye kullanıma açık endpoint'lerde fail-closed

Kapsam dışı: L3/L4 DDoS koruması (bu CDN/edge katmanının işi; uygulama seviyesindeki rate limiter hacimsel bir saldırıyı karşılamaz), faturalandırma için kullanım sayımı.

Kapasite tahmini

text
Gateway tepe trafiği         : 500.000 istek/s (50 pod, pod başına ~10.000/s)
İstek başına kural            : ortalama 2 (API key + endpoint) -> 1.000.000 kontrol/s

Durum boyutu (token bucket)   : anahtar ~40 B + hash alanları (tokens, ts) + Redis yükü ≈ 150 B
Aktif istemci (son 1 dk)      : 2.000.000
Bellek                        : 2.000.000 x 2 kural x 150 B ≈ 600 MB  -> küçük

Karşılaştırma, sliding window log (dakikada 1.000 limit):
  anahtar başına en fazla 1.000 kayıt, sorted set'te kayıt başına ~60 B -> ~60 KB/anahtar
  2.000.000 anahtar x 60 KB ≈ 120 GB   -> log algoritması bu ölçekte pahalı

Redis yükü                    : 1.000.000 script çağrısı/s
  Lua script'i basit GET/SET'ten pahalıdır; shard başına güvenli tavanı yük testiyle
  ölçeriz. Ölçümde ~60.000 script/s çıktığını varsayalım: 1.000.000 / 60.000 ≈ 17 primary
Gecikme                       : aynı veri merkezinde Redis gidiş-dönüşü ~0,5-1 ms

Teşhis: Bellek sorun değil, çağrı sayısı ve gecikme sorun. Her isteği Redis'e götürmek 17+ shard'lık bir cluster ve her isteğe yaklaşık 1 ms demek. Bu yüzden tasarımın ana fikri: Redis'i doğru yerde kullanmak ve gereksiz çağrıları yerelde elemek.

API

Rate limiter dışarıya açık bir API değil, gateway'in içinde çalışan bir karar noktasıdır. İki arayüzü vardır.

Karar arayüzü (gateway filtresi veya ayrı bir gRPC servisi):

text
CheckRateLimit(domain="public-api",
               descriptors=[{api_key: "k_123", plan: "gold"}, {route: "POST /orders"}],
               cost=1)
-> { decision: ALLOW | DENY, remaining: 37, retryAfterMs: 0, matchedRule: "gold-orders-create" }

İstemciye dönen cevap:

http
HTTP/1.1 429 Too Many Requests
Retry-After: 2
RateLimit-Policy: "gold-orders";q=600;w=60
RateLimit: "gold-orders";r=0;t=2
Content-Type: application/problem+json

{ "type": "https://api.ornek.com/problems/rate-limited", "title": "Too Many Requests",
  "status": 429, "detail": "POST /orders için dakikada 600 istek limiti aşıldı." }
  • 429 RFC 6585'te tanımlıdır; cevap bir Retry-After içerebilir ve cache'lenmemelidir.
  • Retry-After saniye ya da HTTP tarihi alır. Saniye vermek saat farkı sorunlarından kaçınır.
  • RateLimit ve RateLimit-Policy başlıkları IETF HTTPAPI çalışma grubunda hâlâ taslak (draft) aşamasındadır; yaygın kullanılan X-RateLimit-Limit/Remaining/Reset başlıkları ise standart değildir. Mülakatta "taslağı takip ederiz, mevcut istemciler için X-RateLimit-*'i de döneriz" demek yeterli.
  • Kotası biten istemciye ne zaman tekrar deneyebileceğini söylemek, istemcilerin kör retry fırtınasını önler.

Kural yönetim arayüzü:

http
PUT /admin/v1/rate-limit-rules/gold-orders-create
{ "match": { "plan": "gold", "route": "POST /orders" },
  "algorithm": "TOKEN_BUCKET", "capacity": 100, "refillPerSecond": 10,
  "mode": "ENFORCE", "failMode": "OPEN" }

refillPerSecond: 10 dakikada 600 isteğe, capacity: 100 anlık 100'lük patlamaya karşılık gelir. mode alanı SHADOW olabilir: kural sadece "reddederdim" diye loglar ve metrik üretir, isteği geçirir.

Veri modeli

Kurallar (PostgreSQL'de kalıcı, gateway'lerde bellekte):

sql
CREATE TABLE rate_limit_rules (
    id                 VARCHAR(64) PRIMARY KEY,
    domain             VARCHAR(64)  NOT NULL,
    match_json         JSONB        NOT NULL,   -- {"plan":"gold","route":"POST /orders"}
    algorithm          VARCHAR(32)  NOT NULL,   -- TOKEN_BUCKET, SLIDING_WINDOW, CONCURRENCY
    capacity           INT          NOT NULL,
    refill_per_second  NUMERIC(12,4) NOT NULL,
    mode               VARCHAR(16)  NOT NULL,   -- ENFORCE, SHADOW, DISABLED
    fail_mode          VARCHAR(16)  NOT NULL,   -- OPEN, CLOSED
    version            BIGINT       NOT NULL,
    updated_at         TIMESTAMPTZ  NOT NULL DEFAULT now()
);

Sayaç durumu (Redis Cluster, sadece geçici durum):

text
rl:{k_123}:gold-orders-create   -> HASH { tokens: 37.4, ts: 1792743574402 }   PEXPIRE ~ kovanın dolma süresi
rl:{k_123}:gold-global          -> HASH { tokens: ..., ts: ... }
rl:{ip:203.0.113.7}:anon        -> HASH ...
  • {k_123} bir hash tag'dir: Redis Cluster sadece süslü parantez içini hash'ler, böylece aynı API key'in bütün kuralları aynı slot'a düşer ve tek bir Lua script'inde atomik olarak kontrol edilebilir.
  • Sayaç durumu kalıcı veri değildir. Kaybolursa sonuç, kovanın dolu başlamasıdır; yani istemci birkaç saniyelik ekstra kota kazanır. Bu yüzden Redis'te kalıcılık (AOF) şart değildir.
  • Her anahtarın TTL'i kovanın boştan dolmasına yetecek kadardır; işlem görmeyen anahtarlar kendiliğinden silinir.

Yüksek seviye tasarım

diyagram yükleniyor…
flowchart LR
    C[İstemciler] --> EDGE[CDN ve DDoS koruması]
    EDGE --> LB[Load balancer]
    LB --> GW[API gateway pod'ları]
    GW --> LL[Yerel limiter ve kural cache'i]
    GW -->|Lua script| R[(Redis Cluster)]
    GW --> SVC[Arka servisler]
    CFG[Kural yönetim servisi] --> PG[(PostgreSQL kurallar)]
    CFG -->|kural değişikliği yayını| GW
    GW -->|allow, deny, shadow metrikleri| MON[Prometheus ve alarm]

Bir isteğin yolu:

  1. Gateway isteği kimlik doğrulamadan geçirir (API key, JWT). Kimliksiz istekler için anahtar IP'dir.
  2. Bellekteki kural tablosundan eşleşen kurallar bulunur (kural seti küçüktür, her pod'da tam kopya).
  3. Yerel ön limit: Pod kendi belleğinde kaba bir token bucket tutar. Bu, tek bir istemcinin bir pod'u boğmasını ve Redis'e anlamsız trafik gitmesini engeller.
  4. Global karar: Redis'te tek bir Lua script çağrısı, istemcinin bütün kurallarını atomik olarak kontrol eder ve düşer.
  5. Karar DENY ise 429; ALLOW ise istek arka servise gider. Her iki durumda RateLimit başlıkları eklenir.

Derinlemesine

Algoritma seçimi

AlgoritmaNasıl çalışırArtıEksiNe zaman
Fixed windowDakika başına sayaç, INCR + EXPIREEn basit, en ucuzPencere sınırında 2 kat patlama (59. saniyede 600, 61. saniyede 600)Kaba koruma, günlük kota
Sliding window logHer isteğin zaman damgası sorted set'teKesinAnahtar başına O(limit) bellek; bu ölçekte ~120 GBDüşük limitli, kesinlik isteyen kurallar (ör. dakikada 5 giriş denemesi)
Sliding window counterÖnceki pencere sayısını kalan oranıyla ağırlıklandır + şimdiki pencereUcuz, sınır problemini büyük ölçüde giderirYaklaşıkIP bazlı kötüye kullanım limitleri
Token bucketKova capacity token tutar, sabit hızla dolarPatlamaya izin verir, ortalamayı korurİki alanlık durumAPI key kotaları (varsayılan)
Leaky bucket (kuyruk)İstekler kuyruğa girer, sabit hızla çıkarÇıkışı düzleştirirGecikme ekler, kuyruk belleğiBağımlılığa giden trafiği düzleştirmek
Concurrency limiterAynı anda işlemde olan istek sayısıPahalı endpoint'leri korurBitişte azaltma gerekir (sızıntı riski)Rapor üretme, büyük export

Sliding window counter'ın formülü: önceki dakikada 42, şimdiki dakikanın 15. saniyesinde 18 istek varsa tahmini hız 42 x (45/60) + 18 = 49,5. Cloudflare bu yaklaşımı 270.000 farklı kaynaktan gelen 400 milyon istek üzerinde ölçtüğünde isteklerin yalnızca %0,003'ünün yanlışlıkla izin aldığını ya da reddedildiğini raporlamıştır. Yani "yaklaşık" çoğu zaman yeterince doğrudur.

Seçimim: API key ve kullanıcı kotaları için token bucket (Stripe da istek hızı limitleri için token bucket kullandığını anlatır); kimliksiz IP limitleri için sliding window counter; pahalı endpoint'ler için ek bir concurrency limiter. Tek bir algoritmaya kilitlenmek yerine kural başına algoritma seçilebilmesi, mülakatta güçlü bir sinyaldir.

Atomiklik: neden Lua script?

50 pod aynı anahtarı okuyup "37 token var, izin ver" derse ve ikisi de 36 yazarsa iki istek tek token harcamış olur. GET → hesapla → SET üç ayrı komut olduğu sürece bu yarış kaçınılmazdır. Redis bir Lua script'ini atomik çalıştırır: script çalışırken sunucu başka komut işlemez. Bu yüzden okuma, dolum hesabı ve düşme tek script'e konur:

lua
-- KEYS[1] = kova anahtarı   ARGV[1] = capacity   ARGV[2] = refill (token/sn)   ARGV[3] = cost
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)   -- ms
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2]) / 1000                                   -- token/ms
local cost = tonumber(ARGV[3])

local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1]) or capacity
local ts = tonumber(state[2]) or now
tokens = math.min(capacity, tokens + math.max(0, now - ts) * rate)

local allowed, retry_ms = 0, 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
else
  retry_ms = math.ceil((cost - tokens) / rate)
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / rate) + 1000)
return {allowed, math.floor(tokens), retry_ms}

Bu script Redis 7.0 üzerinde denendi: kapasite 10, dolum saniyede 5 iken art arda 12 çağrının ilk 10'u izin aldı, sonraki ikisi yaklaşık 200 ms'lik retry_ms ile reddedildi (saniyede 5 token, yani bir token 200 ms'de dolar); kapasite 50 iken 200 eşzamanlı istemciden tam 50 izin çıktı.

Üç tasarım detayı:

  • Saat: Zaman redis.call('TIME') ile Redis'ten alınır. Gateway pod'larının saatleri birkaç milisaniye kayabilir; her pod kendi saatini gönderirse kova ileri geri gider. Redis 7'de script'ler komut etkileriyle replike edildiği için script içinde TIME kullanmak replikayı bozmaz.
  • Birden fazla kural: Aynı API key'in kuralları hash tag ile aynı slot'ta olduğu için tek script hepsini kontrol eder: önce hepsinin yeterli token'ı var mı bakar, sonra hepsinden düşer. Böylece "dakika limiti izin verdi, saniye limiti reddetti ama dakika token'ı yine de harcandı" tutarsızlığı oluşmaz.
  • Script yükleme: Gateway başlangıçta SCRIPT LOAD ile SHA'yı alır, her istekte EVALSHA gönderir; script metni her istekte ağdan geçmez. NOSCRIPT hatası gelirse (failover sonrası boş replika) script yeniden yüklenir.

Redis'e her istekte gitmek zorunda mıyız?

Her kontrol için bir gidiş-dönüş, 1 milyon çağrı/s ve istek başına yaklaşık 1 ms demek. Üç teknikle azaltılır:

  1. Yerel ön limit: Her pod, her anahtar için kaba bir yerel kova tutar (ör. global limitin pod sayısına bölünmüşünün birkaç katı). Saniyede bin istek atan agresif bir istemci, Redis'e gitmeden pod'da reddedilir. Envoy dokümantasyonu da aynı deseni önerir: büyük patlamaları yerel token bucket emer, ince kontrolü global limit yapar.
  2. Token kiralama (leasing): Pod, yüksek hacimli bir anahtar için Redis'ten tek seferde 50 token alır ve bunları yerelde harcar. Redis çağrısı 50'de bire iner. Bedeli kesinliktir: kiralanmış ama kullanılmamış token'lar başka pod'da beklerken diğer pod reddedebilir; kira süresi kısa tutulur (ör. 1 saniye) ve kalan iade edilir. Bu teknik yüksek hacimli büyük müşteriler için anlamlıdır; düşük hacimli anahtarlarda gereksizdir.
  3. Az sayıda kalıcı bağlantı: Pod'dan Redis'e giden çağrılar her istekte yeni bağlantı açmadan, az sayıda kalıcı bağlantı üzerinden asenkron olarak çoklanır (Lettuce'un varsayılan çalışma biçimi tek bağlantı üzerinde komutları çoklamaktır); bağlantı kurma maliyeti kritik yoldan çıkar.

Mülakatta söylenecek denge: "Varsayılanım her istekte tek bir EVALSHA. Ölçüm Redis'in darboğaz olduğunu gösterirse önce yerel ön limit, sonra sadece büyük anahtarlar için token kiralama eklerim."

Limit nerede uygulanır?

YerArtıEksi
CDN / edgeTrafik altyapıya girmeden dururKimlik bilgisi sınırlı, iş kuralı yok
API gateway (seçim)Tek yerde, tüm servisler için tutarlı; servisler bilmezGateway bir darboğaz ve tek hata noktası olmamalı
Servis içi middlewareİş kuralına özgü limitler (kullanıcı başına kupon denemesi)Her serviste tekrar eden kod
İstemci SDK'sıUyumlu istemcilerde gereksiz istek hiç atılmazKötü niyetli istemciye karşı koruma değil

Genel kota gateway'de, iş kuralı limitleri serviste uygulanır. Resilience4j'nin RateLimiter'ı instance içidir; 20 pod'un her biri ayrı sayar, bu yüzden global limit için tek başına yetmez (Kitap Bölüm 14).

Redis erişilemezse: fail-open mi, fail-closed mi?

Rate limiter koruma için vardır; korunan sistemden daha az erişilebilir olmamalıdır.

  • Fail-open (varsayılan): Redis çağrısı kısa bir timeout'u (ör. 5 ms) aşar ya da hata dönerse istek geçer, bir metrik artar. Stripe da rate limiter hatalarının ve Redis kesintisinin API'yi bozmaması için hataların her seviyede yakalanıp fail-open olmasını önerir.
  • Yerel moda düşmek: Tamamen açmak yerine pod yerel kovaya geçer (global limit / pod sayısı). Kesinlik azalır ama kötüye kullanım yine sınırlanır.
  • Fail-closed: Giriş denemesi, SMS doğrulama kodu gönderme, kupon doğrulama gibi kötüye kullanıma açık endpoint'lerde Redis yoksa reddetmek daha güvenlidir.

Redis çağrısı bir circuit breaker arkasındadır: hata oranı artınca devre açılır, gateway Redis'i her istekte beklemeden doğrudan yerel moda geçer. Bu karar kural başına failMode alanıyla ayarlanır.

Çok bölge

İki bölge arasında senkron sayaç paylaşmak, her isteğe bölgeler arası gecikme (on milisaniyeler) eklemek demektir; kabul edilemez. Seçenekler:

  • Bölge başına limit: Global limit bölgelere bölünür (trafik oranına göre, ör. %60/%40). Basit ve hızlıdır; bir bölge kapanırsa diğer bölgenin payı yükseltilmelidir.
  • Asenkron senkronizasyon: Her bölge yerelde karar verir ve birkaç saniyede bir kullandığı miktarı diğer bölgeye yayınlar; her bölge kendi kovasından diğer bölgenin tüketimini düşer. Birkaç saniyelik pencerede limit aşılabilir.
  • İstemciyi tek bölgeye sabitlemek: API key'in "ev bölgesi" vardır; trafik oraya yönlendirilir. Sayaç tek yerdedir ama bölgesel failover'da kova sıfırdan başlar.

Varsayılan seçimim bölge başına limit; sözleşmeye bağlı büyük müşteriler için asenkron senkronizasyon.

Kural değişikliği ve güvenli yayına alma

  • Kurallar veritabanında versiyonlu tutulur; gateway'ler değişikliği push ile (ya da birkaç saniyede bir poll ederek) alır ve atomik olarak yeni kural setine geçer.
  • Yeni bir kural önce SHADOW modda yayına alınır: kimi reddedeceği metrik ve loglarda görülür, kimse etkilenmez. Stripe da limitleri karanlıkta (dark launch) çalıştırıp engelleyeceği trafiği izlemeyi önerir.
  • Kural başına bir kill switch vardır: yanlış bir kural büyük bir müşteriyi kestiğinde deploy beklemeden kapatılır.

İstemci tarafı: retry fırtınasını önlemek

429 alan istemci hemen ve sürekli tekrar denerse limit aşımı kendini besler. İstemci SDK'sı Retry-After'a uyar, yoksa exponential backoff + jitter uygular ve retry sayısını sınırlar (Kitap Bölüm 14). Reddedilen istekler token harcamaz; ama sürekli reddedilen bir istemci IP veya API key bazında daha sert bir "ceza" kuralına girebilir.

Gözlemlenebilirlik

  • Kural başına allowed, denied, shadow_denied sayaçları; en çok reddedilen 20 anahtar.
  • Redis script gecikmesi (p50/p99), timeout ve fail-open sayısı, circuit breaker durumu.
  • Alarm: "fail-open oranı %1'i geçti" (koruma fiilen kapalı), "belirli bir planın red oranı aniden arttı" (yanlış kural ya da istemci hatası).

Darboğazlar, hatalar ve trade-off'lar

SenaryoEtkiÖnlem
Redis shard'ı düşer, replika terfi ederAsenkron replikasyon nedeniyle son sayaç güncellemeleri kaybolur, kova birkaç token fazla verirKabul edilebilir: sayaç durumu geçicidir; kalıcılık şart değil
Redis tamamen erişilemezGlobal limit yokCircuit breaker, yerel moda düşme, kural başına fail-open/closed
Tek bir dev müşteri (hot key)Tek slot, tek node ısınırYerel ön limit, token kiralama
Gateway pod sayısı değişir (HPA)Yerel limitler limit / pod sayısı ile hesaplanıyorsa kayarYerel limit sadece kaba ön filtre; asıl karar global
Yanlış kural yayına çıkarMeşru trafik reddedilirShadow mod, kill switch, red oranı alarmı
NAT arkasındaki binlerce kullanıcı tek IP'deIP limiti meşru kullanıcıları keserKimliği doğrulanmış isteklerde IP değil kullanıcı/API key; IP limiti sadece kimliksiz yolda ve cömert
İstemciler 429 sonrası hemen tekrar denerRetry fırtınasıRetry-After, SDK'da backoff + jitter

Trade-off özeti:

  • Kesinlik ve gecikme: Her istekte global kontrol kesin ama 1 ms pahalı; yerel ön limit ve kiralama ucuz ama yaklaşık.
  • Koruma ve erişilebilirlik: Fail-open sistemin ayakta kalmasını, fail-closed kötüye kullanımın önlenmesini seçer; endpoint başına karar verilir.
  • Global ve bölgesel: Bölgeler arası senkron sayaç yerine birkaç saniyelik aşım kabul edilir.

Üç seviyede cevap

Soru: "50 pod'lu bir gateway'de kullanıcı başına dakikada 600 istek limitini nasıl uygularsın?"

Her pod'da bir ConcurrentHashMap<String, Bucket> tutarım, kullanıcı başına token bucket. İstek gelince kovadan token düşerim, token yoksa 429 dönerim.

Her pod kendi sayacını tutarsa load balancer istekleri dağıttığı için kullanıcı 50 kat limit alır. Durumu Redis'te tutarım. GET, hesapla, SET ayrı komutlar olursa yarış olur, bu yüzden token bucket mantığını tek bir Lua script'ine koyarım; Redis script'i atomik çalıştırır. Reddedilen isteğe 429 ve Retry-After dönerim.

Önce algoritmayı gereksinimden seçerim: dakikada 600 ve kısa patlamalara izin isteniyorsa token bucket, kapasite 100, dolum saniyede 10. Durum Redis Cluster'da, kullanıcının bütün kuralları hash tag ile aynı slot'ta, tek bir EVALSHA hepsini atomik kontrol eder; zamanı pod'dan değil Redis'in TIME'ından alırım ki pod saatleri arasındaki kayma kovayı bozmasın. Sonra maliyeti hesaplarım: 500 bin istek/s ve iki kuralla 1 milyon script çağrısı, istek başına yaklaşık 1 ms. Bu yüzden önüne yerel bir ön limit koyarım, ölçüm gerektirirse büyük anahtarlar için token kiralama eklerim. Redis'e 5 ms timeout ve circuit breaker koyarım; Redis yoksa kural bazında yerel moda ya da fail-closed'a düşerim. Yeni kuralları önce shadow modda yayına alırım.

Junior'ın çözümü tek pod'da doğru ama dağıtık ortamda 50 kat limit veriyor; bu sorunun asıl tuzağı. Mid tuzağı görüyor ve atomikliği doğru yerde (Lua) sağlıyor. Senior algoritmayı gereksinime bağlıyor, saat kaymasını ve çok kurallı atomikliği düşünüyor, maliyeti sayıyla hesaplayıp Redis'i optimize ediyor ve rate limiter'ın kendi arıza modunu tasarlıyor. Ayrımı yapan soru genelde "Redis düşerse ne olur?" sorusudur.

Takip sorusu: Kullanıcının hem saniye hem dakika limiti var. Saniye limiti reddettiğinde dakika kovasından token düşmemesini nasıl garanti edersin?

Soru: "Redis çökerse rate limiter ne yapmalı?"

Redis çökerse istekleri reddederiz, çünkü limiti kontrol edemiyoruz ve güvenli olan bu.

Rate limiter yüzünden tüm API'nin çökmesini istemem; Redis'e kısa bir timeout koyar, hata olursa isteği geçiririm (fail-open) ve bir metrik artırırım. Ama giriş denemesi gibi kötüye kullanıma açık endpoint'lerde reddederim.

Rate limiter korumak için var; korunan sistemden daha az erişilebilir olmamalı. Kararı kural bazında veririm. Varsayılan fail-open ama tam açık değil: Redis çağrısı bir circuit breaker arkasında, devre açılınca pod yerel kovaya düşer, limit global / pod sayısı ile yaklaşık uygulanır. Böylece kötüye kullanım hâlâ sınırlı, ama Redis kesintisi API kesintisine dönüşmez. SMS kodu gönderme, giriş denemesi ve kupon doğrulama gibi her izin verilen isteğin para ya da güvenlik maliyeti olan endpoint'ler fail-closed. Fail-open oranı için alarm koyarım, çünkü fail-open sessizce korumasız çalışmak demek. Bir de replika terfisinde son yazmaların kaybolabileceğini biliyorum; bu sadece birkaç fazla token demek, sayaç geçici veri olduğu için kabul ederim.

Junior "güvenli" diye fail-closed seçiyor ama bunun Redis kesintisini tüm API'nin kesintisine çevirdiğini görmüyor. Mid doğru varsayılanı ve istisnayı biliyor. Senior ara bir mod (yerel limite düşme) tasarlıyor, kararı maliyete göre kural bazında veriyor, fail-open'ın görünmez riskini alarma bağlıyor ve replikasyon kaybının etkisini doğru büyüklükte değerlendiriyor.

Takip sorusu: Fail-open sırasında bir bot SMS doğrulama endpoint'ine saldırdı ve 200 bin SMS gitti. Postmortem'de hangi tasarım kararını değiştirirsin?

Soru: "Sliding window log mu, token bucket mı? Hangisini seçersin?"

Sliding window log en doğrusu çünkü her isteği tutuyor, kesin sonuç veriyor. Onu seçerim.

Sliding window log kesin ama her istek için bir kayıt tutar, bellek limitle büyür. Token bucket iki değer tutar ve patlamalara izin verir. API kotası için token bucket'ı, giriş denemesi gibi düşük limitli ve kesinlik isteyen yerlerde log'u seçerim.

Bellekle başlarım: dakikada 1.000 limitli bir kural için log anahtar başına 1.000'e kadar kayıt demek, 2 milyon aktif anahtarda yaklaşık 120 GB; token bucket aynı yük için 600 MB civarı. Kesinlik farkı da pratikte küçük: sliding window counter bile büyük ölçekli ölçümlerde isteklerin binde birinin çok altında hata yapıyor. Bu yüzden API key kotalarında token bucket, kimliksiz IP limitlerinde sliding window counter, dakikada 5 giriş denemesi gibi küçük limitlerde log kullanırım; orada bellek ihmal edilebilir ve kesinlik güvenlik açısından önemli. Sistemi tek algoritmaya kilitlemem, kural başına seçilebilir yaparım.

Junior kesinliği tek ölçüt sayıyor. Mid doğru ayrımı yapıyor. Senior seçimi sayılarla (bellek, hata oranı) yapıyor ve kural başına algoritma seçimini tasarımın parçası haline getiriyor. Bu soru adayın "en iyi algoritma" yerine "bu kural için doğru algoritma" düşünüp düşünmediğini ölçer.

Takip sorusu: Fixed window sayacının pencere sınırında neden iki kat patlamaya izin verdiğini bir örnekle gösterir misin?

Mülakatçı için

  • Sunucu tarafı ve istemci tarafı rate limiting ayrımını yapıyor; limit boyutlarını (API key, kullanıcı, IP, endpoint) soruyor.
  • Pod başına yerel sayacın dağıtık ortamda N kat limit vereceğini kendiliğinden fark ediyor.
  • En az üç algoritmayı karşılaştırıyor ve seçimini patlama toleransı, bellek ve kesinlik üzerinden savunuyor.
  • Atomikliği Lua script (veya eşdeğer atomik işlem) ile sağlıyor ve GET/SET yarışını açıklıyor.
  • Saat kaynağını (pod saati mi, Redis saati mi) düşünüyor.
  • Redis çağrı sayısını ve gecikme maliyetini hesaplıyor; yerel ön limit veya token kiralama gibi bir optimizasyon öneriyor.
  • Fail-open/fail-closed kararını endpoint'in riskine göre veriyor ve fail-open'ı izlenebilir yapıyor.
  • 429 + Retry-After dönüyor ve istemci retry davranışını (backoff, jitter) düşünüyor.
  • Kural değişikliğini deploy'suz ve güvenli (shadow mod, kill switch) yapıyor.
  • Çok bölge veya hot key gibi en az bir ölçek problemini ele alıyor.

Takip soruları:

  • Bir işlem 1 token değil, payload boyutuna göre değişken maliyetli. Script nasıl değişir?
  • Concurrency limiter'da istek bittiğinde sayacı azaltmayı unutan bir pod çökerse ne olur? (İpucu: TTL'li kiralama.)
  • Limitler müşteri sözleşmesinin parçası ve aşım faturalandırılacak. Hangi kararlar değişir?
  • Rate limiter ile load shedding arasındaki fark nedir?

Seviye beklentisi: Mid seviyede Redis + Lua ile atomik token bucket ve 429 beklenir. Senior'dan maliyet hesabı, yerel/global denge, arıza modu, güvenli kural yayını ve çok bölge tartışması beklenir. "Bucket4j kullanırım" deyip mekanizmayı anlatamayan aday zayıftır; kütüphane adı ancak mekanizmadan sonra değer taşır.

Hızlı tekrar

S: Her pod kendi sayacını tutarsa 50 pod'lu sistemde ne olur?

C: Load balancer istekleri dağıttığı için kullanıcı fiilen 50 kat limit alır. Global durum paylaşılan bir depoda (Redis) tutulmalıdır.

S: Token bucket'ta capacity ve dolum hızı neyi belirler?

C: Dolum hızı uzun vadeli ortalamayı (saniyede 10 = dakikada 600), capacity izin verilen anlık patlamayı (100) belirler.

S: Redis'te rate limit mantığı neden Lua script'inde olmalı?

C: GET → hesapla → SET ayrı komutlarsa iki pod aynı değeri okuyup ikisi de izin verir. Redis script'i atomik çalıştırır; arada başka komut işlenmez.

S: Script'te zamanı neden pod'dan değil redis.call('TIME') ile alırsın?

C: Pod saatleri birbirinden kayabilir; farklı saatlerle güncellenen kova ileri geri gider. Tek saat kaynağı Redis'tir.

S: Sliding window counter formülü nedir?

C: önceki_pencere_sayısı x (pencerenin kalan oranı) + şimdiki_pencere_sayısı. Örnek: 42 x 0,75 + 18 = 49,5.

S: Rate limiter için fail-open ne zaman, fail-closed ne zaman?

C: Genel API trafiğinde fail-open (tercihen yerel limite düşerek); SMS gönderme, giriş denemesi, kupon gibi her isteğin maliyeti olan endpoint'lerde fail-closed.

S: 429 cevabında istemciye ne verilmeli?

C: Retry-After (saniye ya da HTTP tarihi) ve problem detayı; tercihen kota başlıkları. 429 cevabı cache'lenmemelidir.

S: Token kiralama (leasing) ne kazandırır, ne kaybettirir?

C: Pod Redis'ten bir seferde N token alır; Redis çağrısı N'de bire iner. Bedeli kesinlik: kiralanmış ama kullanılmamış token'lar başka pod'da reddedilmeye yol açabilir.

Kaynaklar