Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi System Design — #09

Rate Limiting Tasarımı: Token Bucket, Sliding Window ve Distributed Rate Limiter

Mert Özen 8 Kas 2026 13 dk 3 görüntülenme
Rate Limiting Tasarımı: Token Bucket, Sliding Window ve Distributed Rate Limiter

Tek bir rate limit için sayaç tek bir yerde durmalı: ASP.NET Core'un yerleşik limiter'ı sayacı her instance'ın belleğinde tuttuğu için üç instance'ta limit üç katına çıkar; çözüm gateway'de ya da Redis'te atomik bir sayaç. Beş algoritmanın karşılaştırması, Redis ve Lua ile gerçek çıktılı distributed sliding window limiter, 429, Retry-After, IETF RateLimit header taslağı ve partition key'in DoS riski.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir; Redis örnekleri Redis 7.4 ve StackExchange.Redis 3.4 ile koşturuldu.

Önce Tahmin Et

Bookstore'un sipariş API'si (Application Programming Interface) bir load balancer'ın arkasında üç instance olarak çalışıyor. Bir kitapçı zinciri entegrasyonu her dakika yüzlerce sipariş gönderiyor ve ekip, ASP.NET Core'un yerleşik rate limiter'ıyla bu client'a dakikada 100 istek sınırı koyuyor: AddFixedWindowLimiter, PermitLimit = 100, Window = 1 dakika. Deploy ediliyor, testte her şey doğru görünüyor.

İki soru. Client bir dakika içinde 600 istek gönderirse ve load balancer bunları üç instance'a eşit dağıtırsa kaç istek geçer? Ve tek bir instance olsaydı bile, client 100 isteği dakikanın 59,9'uncu saniyesinde, 100 isteği de 60,1'inci saniyesinde gönderse 0,2 saniyede kaç istek kabul edilirdi?

Rate Limiting Neyi Korur, Nerede Durur?

Rate limiting, bir client'ın belirli bir sürede yapabileceği istek sayısını sınırlar. Üç ayrı derdi çözer ve hangisini çözmek istediğini bilmek tasarımın yarısıdır. Birincisi kaynak koruması: tek bir client'ın döngüye giren script'i veritabanını dolduramasın. İkincisi adalet: ücretsiz katmandaki bir kullanıcı, ücretli müşterinin kapasitesini yemesin. Üçüncüsü kötüye kullanım: login endpoint'inde parola deneme, kupon endpoint'inde kod tarama yavaşlatılsın. OWASP (Open Worldwide Application Security Project) Top 10'u anlatan Web Güvenliği serisinin "OWASP A06: Insecure Design — İş Mantığı Açıkları, Race Condition ve Rate Limiting" yazısı üçüncüsünü güvenlik gözüyle anlattı; burada aynı mekanizmayı ölçek gözüyle ele alıyoruz.

Rate limiting tek bir yerde durmaz, katmanlıdır. En dışta CDN (Content Delivery Network) ya da WAF (Web Application Firewall) IP (Internet Protocol) başına kaba bir sınır koyar ve DDoS (Distributed Denial of Service) saldırısının büyük kısmını uygulamaya ulaşmadan keser. API gateway, API key ya da kullanıcı başına iş kuralı olan limiti uygular: "ücretsiz plan dakikada 60, kurumsal plan dakikada 1.000". Uygulamanın içinde de endpoint'e özgü sınırlar kalır: rapor üreten ağır bir endpoint için concurrency limiti, login için hesap başına deneme limiti. Stripe'ın 2017'de yayımladığı "Scaling your API with rate limiters" yazısı (Paul Tarjan) bu katmanlaşmanın iyi bir örneği: kullanıcı başına token bucket ile istek hızı limiti, kullanıcı başına eşzamanlı istek limiti ve bunların arkasında, filonun bir kısmını kritik isteklere ayıran iki load shedder. Load shedding bir sonraki yazının konusu; rate limiting "bu client çok istiyor" der, load shedding "ben çok yüklüyüm" der.

Limitin anahtarı da bir tasarım kararı. Kimliği doğrulanmış trafikte kullanıcı ya da API key en doğru anahtar; IP adresi NAT (Network Address Translation) arkasındaki bir ofisin bütün çalışanlarını tek client sayar, bir botnet'i ise binlerce farklı client.

Beş Algoritma

Alex Xu'nun System Design Interview kitabının (Vol. 1, 2020) rate limiter bölümü beş algoritma sayar. Hepsi "son T sürede kaç istek geldi?" sorusuna farklı bir bellek ve doğruluk bedeliyle cevap verir.

Token bucket: Kovada en fazla N token durur, kova sabit hızla dolar, her istek bir token harcar. Kova doluysa client kısa süreli bir burst yapabilir, uzun vadede hız dolum hızını geçemez. Client başına iki sayı tutar (token sayısı ve son dolum zamanı). Stripe ve çoğu bulut API'si bunu kullanır.

Leaky bucket: İstekler bir queue'ya girer ve queue sabit hızla boşaltılır. Çıkış hızı pürüzsüzdür, burst'e izin vermez; queue doluysa istek reddedilir. Ağ trafiğini düzleştirmek için klasiktir, API'lerde kullanıcıya bekleme olarak yansıdığı için daha seyrek görülür.

Fixed window: Zaman dakikalık pencerelere bölünür, her pencerede bir sayaç tutulur. En ucuzu budur: bir INCR ve bir TTL (Time To Live). Bedeli pencere sınırında: önceki pencerenin sonundaki burst ile yenisinin başındaki burst toplanır.

Sliding log: Her isteğin zaman damgası saklanır, son T süredekiler sayılır. Kesin doğrudur ama client başına istek sayısı kadar bellek harcar; dakikada 1.000 isteğe izin verilen bir client için 1.000 zaman damgası demektir.

Sliding window counter: Fixed window'un iki sayacıyla sliding log'a yaklaşır. Şimdiki pencerenin sayacına, önceki pencerenin sayacının hâlâ son T süreye düşen payı eklenir: önceki × (kalan pay) + şimdiki. Cloudflare 2017'de "Counting things, a lot of different things" yazısında bu yaklaşımı anlattı ve 270 bin farklı kaynaktan gelen yaklaşık 400 milyon istek üzerinde yalnız %0,003'ünün yanlışlıkla kabul ya da reddedildiğini ölçtü. Varsayımı, önceki penceredeki isteklerin pencere boyunca eşit dağıldığı.

09-algorithms-tr.png

Görseldeki yük, Önce Tahmin Et'teki ikinci sorunun cevabı. Fixed window 0,2 saniyede 200 isteği kabul ediyor, yani pencere sınırında limitin iki katını. Sliding window counter 60,1'inci saniyede önceki pencereyi neredeyse tam ağırlıkla (0,998) saydığı için ikinci dalgayı reddediyor; 90'ıncı saniyede önceki pencerenin ağırlığı 0,5'e indiği için 50 isteğe yer açılıyor. Aynı limit için token bucket da benzer davranıyor: dolu kova 100 isteği karşılıyor, sonra dakikada 100 token hızla doluyor.

.NET'te dördü hazır: System.Threading.RateLimiting içinde TokenBucketRateLimiter, FixedWindowRateLimiter, SlidingWindowRateLimiter ve eşzamanlı istek sayısını sınırlayan ConcurrencyLimiter. ASP.NET Core Web API serisinin ".NET 7 ile ASP.NET Core Yenilikleri: MapGroup, TypedResults, Rate Limiting ve Output Caching" yazısı bunları middleware olarak nasıl bağlayacağını gösterdi. Bir ayrıntı: .NET'in sliding window'u Cloudflare'in ağırlıklı tahmini değil; pencereyi SegmentsPerWindow kadar segmente böler ve süresi dolan segmentin isteklerini geri verir. Segment sayısı arttıkça sliding log'a yaklaşır.

Üç Instance, Üç Kat Limit

Önce Tahmin Et'teki ilk sorunun cevabı Microsoft Learn'deki rate limiting sayfasında açıkça yazıyor: middleware sayaçlarını her instance'ın kendi belleğinde tutar, çok instance'lı bir uygulamada efektif limit, ayarlanan limit çarpı instance sayısıdır. GlobalLimiter için de geçerli: "global" o instance'a gelen bütün istekleri kapsar, uygulamanın tamamını değil. Aynı durumu üç ayrı limiter'la, load balancer'ın round robin dağıtımını taklit ederek denedim:

// Üç API instance'ı, her birinin bellekte kendi limiter'ı: dakikada 100 istek
FixedWindowRateLimiter[] local = [.. Enumerable.Range(0, 3).Select(_ => new FixedWindowRateLimiter(
    new FixedWindowRateLimiterOptions { PermitLimit = 100, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }))];
int localAccepted = 0;
for (int i = 0; i < 600; i++)
{
    using RateLimitLease lease = local[i % 3].AttemptAcquire();
    if (lease.IsAcquired) localAccepted++;
}
Console.WriteLine($"in-memory, 3 instances : {localAccepted} of 600 accepted");
in-memory, 3 instances : 300 of 600 accepted

Dakikada 100 diye anlaşılan client 300 istek geçirdi. Autoscaling devredeyse durum daha da tuhaflaşır: instance sayısı arttıkça limit de artar, yani yük arttıkça koruma gevşer. Sticky session ile her client'ı tek instance'a bağlamak sayıyı düzeltir gibi görünür ama serinin "Load Balancing: L4 ve L7, Algoritmalar, Health Check ve Sticky Session" yazısında (L4 ve L7, network modelinin 4. ve 7. katmanı: transport ve application) konuştuğumuz bütün bedelleri getirir ve instance yeniden başladığında sayaç sıfırlanır. Tek bir limit istiyorsan sayacın tek bir yerde durması gerekir: ya bütün trafiğin geçtiği gateway'de, ya da instance'ların paylaştığı bir store'da.

Paylaşılan Store: Redis ve Atomik Script

Paylaşılan store olarak Redis neredeyse varsayılan seçim: bellekte çalışır, milisaniyenin altında cevap verir, TTL'i ve atomik komutları vardır. Atomiklik burada süs değil. Sayacı okuyup, limitin altındaysa artırmak iki ayrı komutsa, aynı anda gelen iki istek aynı değeri okur ve ikisi de geçer. Önce bu naif hâli denedim: üç instance, aynı Redis, paralel 600 istek, her istek GET ve ardından INCR.

// Atomik olmayan GET + INCR: okuma ile yazma arasına başka istekler giriyor
long count = (long?)await d.StringGetAsync("naive:{user:ada}") ?? 0;
if (count < 100)
{
    await d.StringIncrementAsync("naive:{user:ada}");
    Interlocked.Increment(ref naive);
}
redis GET+INCR (naive) : 415 of 600 accepted (parallel)

Limit 100, geçen 415; ikinci koşuda 432. Sayı her koşuda değişiyor, çünkü bu bir race condition. Çözüm, okuma, karar ve yazmayı tek bir Lua script'inde yapmak: Redis bir script'i çalıştırırken araya başka komut almaz. Sliding window counter'ı tek bir hash key'de tutan limiter:

public sealed record RateLimitDecision(bool Allowed, int Remaining, TimeSpan RetryAfter);

public sealed class RedisSlidingWindowLimiter(IDatabase db, int limit, TimeSpan window)
{
    // Partition başına tek hash key: start, cur, prev. Redis script'i bir bütün olarak çalıştırdığı için atomik.
    private const string Script = """
        local limit  = tonumber(ARGV[1])
        local window = tonumber(ARGV[2])
        local now    = tonumber(ARGV[3])
        if now < 0 then
          local t = redis.call('TIME')
          now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
        end
        local start = now - (now % window)
        local s = redis.call('HMGET', KEYS[1], 'start', 'cur', 'prev')
        local oldStart = tonumber(s[1]) or start
        local cur  = tonumber(s[2]) or 0
        local prev = tonumber(s[3]) or 0
        if start == oldStart + window then
          prev = cur; cur = 0
        elseif start ~= oldStart then
          prev = 0; cur = 0
        end
        local elapsed  = now - start
        local estimate = prev * (window - elapsed) / window + cur
        if estimate + 1 > limit then
          local wait
          if cur + 1 <= limit and prev > 0 then
            wait = window - elapsed - (limit - cur - 1) * window / prev
          else
            wait = (window - elapsed) + window * (1 - (limit - 1) / math.max(cur, 1))
          end
          redis.call('HSET', KEYS[1], 'start', start, 'cur', cur, 'prev', prev)
          redis.call('PEXPIRE', KEYS[1], window * 2)
          return {0, 0, math.max(1, math.ceil(wait))}
        end
        cur = cur + 1
        redis.call('HSET', KEYS[1], 'start', start, 'cur', cur, 'prev', prev)
        redis.call('PEXPIRE', KEYS[1], window * 2)
        return {1, math.floor(limit - estimate - 1), 0}
        """;

    // nowMs < 0: Redis sunucusunun saati kullanılır (production). Sabit değer yalnız demoyu deterministik yapmak için.
    public async Task<RateLimitDecision> TryAcquireAsync(string partitionKey, long nowMs = -1)
    {
        RedisResult result = await db.ScriptEvaluateAsync(Script,
            [new RedisKey($"rl:{{{partitionKey}}}")],
            [limit, (long)window.TotalMilliseconds, nowMs]);
        RedisResult[] r = (RedisResult[])result!;
        return new RateLimitDecision((int)r[0] == 1, (int)r[1], TimeSpan.FromMilliseconds((long)r[2]));
    }
}

Üç tasarım kararı var. Zamanı Redis'in TIME komutundan alıyoruz, çünkü üç instance'ın saatleri arasındaki kayma sayacın hangi pencereye yazılacağını değiştirir; tek saat, store'un saati. Sayaçlar tek bir hash key'de duruyor ve key adı süslü parantezle (rl:{user:ada}) yazılıyor: Redis'in dokümantasyonu script'in eriştiği her key'in KEYS ile verilmesini istiyor, Redis Cluster'da da süslü parantez içindeki kısım (hash tag) key'in hangi slot'a düşeceğini belirliyor. Ve key iki pencere sonra kendiliğinden siliniyor (PEXPIRE); bir daha gelmeyen client için Redis'te çöp kalmıyor.

Aynı üç instance'ı bu kez kendi Redis bağlantılarıyla, paralel olarak çalıştırdım; ardından pencere sınırı senaryosunu, zamanı parametre olarak vererek:

RedisSlidingWindowLimiter[] shared = [.. muxes.Select(m =>
    new RedisSlidingWindowLimiter(m.GetDatabase(), 100, TimeSpan.FromMinutes(1)))];
RateLimitDecision[] decisions = await Task.WhenAll(Enumerable.Range(0, 600)
    .Select(i => shared[i % 3].TryAcquireAsync("user:ada")));
Console.WriteLine($"redis, 3 instances     : {decisions.Count(d => d.Allowed)} of 600 accepted (parallel)");
in-memory, 3 instances : 300 of 600 accepted
redis, 3 instances     : 100 of 600 accepted (parallel)
redis GET+INCR (naive) : 415 of 600 accepted (parallel)
boundary burst, fixed  : 200 of 200 accepted within 0.2 s
boundary burst, sliding: 100 of 200 accepted, first Retry-After 500 ms
at 90.0 s, sliding     : 50 more accepted (previous window weighted 0.5)

Paylaşılan ve atomik sayaçla üç instance tam 100 istek geçirdi. Pencere sınırında INCR tabanlı fixed window 200 isteğe izin verirken sliding window counter 100'de durdu ve ilk reddedilen isteğe 500 ms sonra tekrar denemesini söyledi: o anda önceki pencerenin ağırlığı bir istek açacak kadar düşmüş oluyor.

Bedeli de yazalım. Her istek artık Redis'e bir round trip yapıyor; aynı veri merkezinde bu genelde milisaniyenin altında, ama limiter artık kritik yolda bir bağımlılık. Redis'e ulaşılamazsa ne yapacağına önceden karar ver. Fail open (isteği geçir) çoğu API için doğru, çünkü rate limiter'ın düşmesi bütün API'nin düşmesine dönüşmemeli; login ya da SMS (Short Message Service) gönderen endpoint gibi kötüye kullanımın pahalı olduğu yerlerde fail closed (reddet) ya da instance içi yedek bir limiter daha güvenli. Çok yüksek trafikte bir ara yol da var: her instance, yerel bir token bucket'tan küçük kotalar harcar ve Redis'e yalnız kota bittiğinde gider. Kesinlikten biraz verir, round trip sayısını büyük ölçüde azaltır.

429, Retry-After ve RateLimit Header'ları

Reddedilen isteğin cevabı da sözleşmenin parçası. HTTP (Hypertext Transfer Protocol) 429 Too Many Requests, RFC (Request for Comments) 6585 ile tanımlandı; Retry-After header'ı (RFC 9110) client'a ne zaman tekrar deneyeceğini saniye ya da tarih olarak söyler. ASP.NET Core'un yerleşik middleware'i reddettiği isteğe varsayılan olarak 503 döndürür; RejectionStatusCode ile 429 yapmak ve OnRejected içinde lease'in MetadataName.RetryAfter bilgisini header'a yazmak senin işin. Kendi limiter'ımızı bir endpoint filter'la bağladım:

// Yalnız bilinen API key'ler: partition key hiçbir zaman doğrudan istekten gelmiyor
Dictionary<string, string> apiKeys = new() { ["key-ada-123"] = "client:ada" };

app.MapPost("/orders", () => Results.Accepted())
   .AddEndpointFilter(async (ctx, next) =>
   {
       HttpContext http = ctx.HttpContext;
       if (!apiKeys.TryGetValue(http.Request.Headers["X-Api-Key"].ToString(), out string? client))
           return Results.Unauthorized();

       RateLimitDecision d = await http.RequestServices
           .GetRequiredService<RedisSlidingWindowLimiter>().TryAcquireAsync(client);
       http.Response.Headers["RateLimit-Policy"] = "\"orders\";q=5;w=60";
       http.Response.Headers["RateLimit"] = $"\"orders\";r={d.Remaining}";
       if (d.Allowed)
           return await next(ctx);

       int seconds = (int)Math.Ceiling(d.RetryAfter.TotalSeconds);
       http.Response.Headers.RetryAfter = seconds.ToString();
       return Results.Problem(statusCode: StatusCodes.Status429TooManyRequests,
           title: "Too many requests", detail: $"Retry after {seconds} s.");
   });

Limit dakikada 5. Altı istek ve altıncısının tam cevabı:

$ for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code} " -X POST -H "X-Api-Key: key-ada-123" http://localhost:5391/orders; done
202 202 202 202 202 429

$ curl -si -X POST -H "X-Api-Key: key-ada-123" http://localhost:5391/orders
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Date: Sun, 08 Nov 2026 00:56:12 GMT
Server: Kestrel
Retry-After: 59
Transfer-Encoding: chunked
RateLimit-Policy: "orders";q=5;w=60
RateLimit: "orders";r=0

{"title":"Too many requests","status":429,"detail":"Retry after 59 s."}

RateLimit-Policy ve RateLimit header'ları IETF'in (Internet Engineering Task Force) HTTP API'leri üzerine çalışan grubunun (httpapi) taslağından: draft-ietf-httpapi-ratelimit-headers-11, Mayıs 2026 tarihli ve hâlâ bir Internet-Draft, RFC değil. Policy header'ı kotayı (q) ve pencereyi (w) söyler, RateLimit kalan kotayı (r). Taslak yıllar içinde biçim değiştirdi; eski sürümlerdeki RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset üçlüsünü ve yaygın X-RateLimit-* header'larını hâlâ birçok API'de görürsün. Kullanacaksan dokümantasyonunda taslak sürümünü belirt ve client'ların asıl olarak Retry-After'a güvenmesini iste; o standart.

İyi bir 429'un client tarafı da var. Client Retry-After'ı okumalı, header yoksa jitter'lı exponential backoff ile beklemeli; 429 alan bir istemciyi hemen tekrar denemeye programlamak, limiter'ı bir yük üretecine çevirir. Bu konu bir sonraki yazının ana malzemesi.

Partition Key ve DoS Riski

Microsoft Learn'deki sayfa açık bir uyarı içeriyor: partition'ları kullanıcı girdisinden üretmek uygulamayı DoS (Denial of Service) saldırısına açar. İki sebebi var. Birincisi bellek: her farklı anahtar için bir limiter (ya da Redis'te bir key) oluşur; saldırgan her isteğe rastgele bir X-Api-Key koyarsa milyonlarca partition yaratır ve hiçbiri limite takılmaz. İkincisi sahte kimlik: IP adresine göre partition'lamak, kaynak adresi taklit edilebilen trafikte saldırganın istediği kadar "farklı client" olmasına izin verir.

Yukarıdaki filter bu yüzden önce API key'i bilinen bir listeyle eşleştiriyor ve partition key olarak isteğin kendisini değil, eşleşen client kimliğini kullanıyor; bilinmeyen key limiter'a hiç ulaşmadan 401 alıyor. Kimliği doğrulanmamış trafik için doğru araç kaba bir IP limiti artı edge'deki DDoS koruması; kimliği doğrulanmış trafik için kullanıcı ya da tenant kimliği.

Sık Düşülen Tuzaklar

Instance başına sayacı global limit sanmak

Yerleşik middleware'in GlobalLimiter'ı bile instance başınadır. Autoscaling ile limit, yük arttıkça gevşer.

Okuma ve yazmayı ayrı komutlarla yapmak

Paralel trafikte GET sonra INCR limiti delip geçer; yukarıda 100 yerine 415. Karar ve artış tek atomik adımda olmalı: Lua script'i ya da INCR'ın dönüş değerine göre karar.

Instance'ların saatine güvenmek

Pencere hesabını her instance kendi saatiyle yaparsa aynı istek iki farklı pencereye yazılabilir. Zamanı store'dan al.

Redis'i tek hata noktası yapmak

Redis düşünce API de düşüyorsa rate limiter koruma değil risk olmuş demektir. Fail open ya da fail closed kararını endpoint bazında ver ve bir timeout koy.

429'u retry'lanabilir hata gibi hemen tekrar denemek

Client'taki agresif retry, limiter'ın arkasındaki sistemi değil limiter'ın kendisini yorar. Retry-After'a uy.

Küçük Bir Deneme

★☆☆ RedisSlidingWindowLimiter'ı pencere başlangıcını 0 kabul ederek elle çalıştır: 30'uncu saniyede 70 istek, 75'inci saniyede kaç isteğe izin verilir? Hesabını script'e nowMs vererek doğrula.

★★☆ Aynı arayüzle Redis tabanlı bir token bucket yaz: hash'te tokens ve ts tut, her istekte geçen süre kadar token ekle. Pencere sınırı senaryosunda sonuç sliding window counter'la aynı mı?

★★★ Redis'e ulaşılamadığında devreye giren yerel bir yedek limiter tasarla: instance sayısı bilinmiyorsa yerel limiti nasıl seçersin? Login endpoint'i ile katalog endpoint'i için fail open mı, fail closed mu seçerdin?

Rate limiting, client'ın çok istemesine karşı bir savunma. Peki sorun client'ta değil de çağırdığımız bağımlılıkta ise, ödeme servisi yavaşladığında ya da cevap vermediğinde ne yapacağız? Bir sonraki yazıda reliability pattern'lerine bakacağız: timeout, retry, backoff ve jitter, circuit breaker ve load shedding.