Ölçeklenmiş sistemlerde Sliding Window neden sorun çıkartır?

YazılarYayımlandı

Production’da Sliding Window: Tek Instance Yetmediğinde

Tek bir sunucu ya da instance’ta Sliding Window algoritmasını çalıştıran kodun koşması pek canımızı sıkmaz. Çünkü algoritmaya ait olan sayaç kendi içindedir ve limiti buradan yönetir.

Bir kuşu özgür olduğuna ikna edebilmek niye dünyanın en zor işi?” Jonathan Livingston — Martı

Tavsiyem “Sliding Window: Token Bucket’ın Yeterli Olmadığı Yerler” yazısının okunduktan sonra devam edilmesidir.


Tek bir sunucu ya da instance’ta Sliding Window algoritmasını çalıştıran kodun koşması pek canımızı sıkmaz. Çünkü algoritmaya ait olan sayaç kendi içindedir ve limiti buradan yönetir.

Asıl can sıkıcı kısım o instance çoğaldığında başlar. Uygulamayı ölçeklendirdiğimiz an artık tek bir sayaca değil birbirinden habersiz birçok sayacımız olur. Her biri kendi penceresinden bakar ve “limit korunuyor” der.

İşte asıl soru bu: ölçeklenebilir bir sistemde Sliding Window nasıl davranır ve neden sorun çıkarır?

Bu yazıda önce .NET’in içinde yer alan limiter’i production’a yakın bir ortama atacağız sonra da ölçeklendirip neyin bozulduğunu ve onu neyle çözebileceğimizi göreceğiz.

Serinin ilk yazısında örnekleri yazarken Log ve Counter için kendi kodumuzu yazdık ve süreci daha iyi anladık. Aslında buna gerek yoktu. Kullanmış olduğumuz bir çok gelişmiş framework bu gibi özellikleri paketlerinin içerisinde zaten bulunduruyor. ASP.NET Core, rate limiting’i .NET 7’den beri bünyesinde bulunduruyor. SlidingWindowLimiter’ı kullanmak için yapılması gereken ise çok basit:

builder.Services.AddRateLimiter(options =>
{
    options.AddSlidingWindowLimiter("sliding", opt =>
    {
        opt.PermitLimit = 10;                 // pencere başına izin
        opt.Window = TimeSpan.FromSeconds(60);
        opt.SegmentsPerWindow = 6;            // pencereyi 6 dilime böl
        opt.QueueLimit = 0;                   // sıraya alma, hemen reddet
    });
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});

app.UseRateLimiter();
app.MapPost("/api/transfers", TransferHandler).RequireRateLimiting("sliding");

Burada belki de en önemli parametre SegmentsPerWindow olmalıdır. Çünkü bu parametrede daha önce bahsettiğim Log ya da Counter tabanlı yaklaşım yerine Segment bazlı yaklaşım devreye girmektedir.

Mantık şu: pencere yani 60sn’lik dilimi SegmentsPerWindow kadar eşit dilime böler ve hep ileri doğru kayan penceremiz artık dilim dilim ilerler. Window = 60 saniye ve SegmentsPerWindow = 6 dediğimizde limit 10 saniyelik dilimler halinde uygulanır. Her 10 saniyede bir en eski dilim rate limit hesabına dahil edilmeden ilerlemiş olur.

Segment tabanlı Sliding Window

Elbette bunun da bir bedeli vardır. SegmentsPerWindow bir hassasiyet-maliyet ayarıdır diyebiliriz.

Az dilim ucuz ama kaba, çok dilim hassas ama pahalıdır!

Peki gerçek bir senaryoda limiti neye göre uygularız? Genelde kullanıcı başına limitler vererek müşteri bazlı bir adalet gözetiriz. Mesela: bir müşteri herhangi bir 60 saniyede en fazla 10 para transferi başlatabilir dediğimiz de nasıl bir yol izleriz. Burada genellikle izlenen yol limiter’ı CustomerId’ye göre gruplamak oluyor.

options.AddPolicy("per-customer", context =>
{
    var customerId = context.Request.Headers["X-Customer-Id"].ToString();
    if (string.IsNullOrEmpty(customerId))
        customerId = "anonymous";

    return RateLimitPartition.GetSlidingWindowLimiter(customerId, _ =>
        new SlidingWindowRateLimiterOptions
        {
            PermitLimit = 10,
            Window = TimeSpan.FromSeconds(60),
            SegmentsPerWindow = 6,
            QueueLimit = 0
        });
});

Yukarıda ki yapıyı elbette direk production’a almıyoruz. Bu test amaçlı. Burada müşteriyi X-Customer-Id header’ından okuyoruz. Prod ortamda bunu JWT claimden almak daha doğru olacaktır. Sonuçta kullanıcının kendi kimliğini taklit ederek bir zaafiyete yol açmak istemeyiz.

Peki madem kullandığımız framework içinde bu yapı var ise neden kendi limiter’ımızı konuşuyoruz. Aslında çoğu uygulama için mevcut çözümler yeterlidir. Tek instance, standart ihtiyaçlar, ekstra bağımlılık istemeyen servis için framework’ün verdiği segment tabanlı limiter en pratik tercihtir.

Ama uygulamanın sahip olduğu tüm instance’larda global bir rate limit istiyorsak (en önemli neden aslında bu), OpenTelemetry ile özel metriklere ihtiyaç duyuluyor ise, kesin bir Log davranışı isteniyor ise işte o zaman hazır verilen limiter’in yetersiz olduğunu hatta çuvalladığını görebiliriz. Aslında global olarak anlandırdığımız yapıda ki global teriminin Sahte Global olduğunu anlarız.

Ölçeklendirince Ne Bozulur?

Şimdi önce şunu hatırlayalım: SlidingWindowLimiter’ın state’i kendi içinde yaşıyor. Yazının başında buna kısa bir değinmiştim. Tek instance’da bu bir sorun değildir. Ama uygulama ölçeklenip artık üç replikaya sahip olduğunda artık 3 farklı bellek ve sayaç vardır. LB (load balancer) istekleri dağıttıkça her replika yalnız kendine gelenleri sayar. Doğal olarak framework içinde ki limiter’da koyduğumuz limitler kendi içinde tutarlı bir davranış sergiler. Ama hatalıdır!

API-1  -> 100 request sayar
API-2  -> 100 request sayar
API-3  -> 100 request sayar
──────────────────────────────
Global limit: 100    Gerçekte geçen: ~300

İşte bizim global dediğimiz yapı aslında Sahte Global ünvanını burada kazanır. Sayaç tek ve ortak olmadıkça global limit her replikanın kendi penceresinde ürettiği bir yanılsamadan ibarettir.

Global Limit Sorunu

İşte bu sorunun çözümü tek ve ortak bir sayaç üzerinden state yönetimini yapmaktır. Her bir mimari kurul burada farklı çözüm önerileri getirebilir. Benim getirdiğim çözüm önerisi bahsi geçen ortak state’in Redis üzerinden yönetilmesine yöneliktir.

Redis ile Gerçek Global Limit

State’i replikaların dışında ortak bir yere taşıdığımız an gerçek global limiti uygulama imkanımız da beraberinde gelir. Tüm replikalar aynı veriyi okur dolayısıyla tek bir gerçek sayaç oluşur.

Sliding Window’un Log varyantını Redis tarafında bir Sorted Set ile oluşturabiliriz. Ne yapmıştık bu varyantta: zaman damgası oluşturup her bir istek için bu damgayı saklamıştık. Burada da zaman dangasını set ederiz, timestamp alanına score adını veririz işlem tamamlanmış olur. Son 60 saniyeyi say dediğimizde de işlem üç adıma inip eskiyi siler, kalanı sayar ve yeniyi ekler. İşte tam oldu dediğimiz yerde bir race condition durumu vardır. İki replikanın araya girdiği bir anda ikisi de “sayı limitin altında” görüp aynı anda ekleme yapabilir. Bu durumun yaşanmaması için üç adımında tek ve atomik çalışması gerekir. Bunu redis tarafında lua scriptiyle yapabiliriz.

-- KEYS[1] = rate limit anahtarı (örn. rate:customer_42)
-- ARGV[1] = now (ms), ARGV[2] = window (ms), ARGV[3] = limit, ARGV[4] = tekil üye
local clearBefore = tonumber(ARGV[1]) - tonumber(ARGV[2])
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, clearBefore)   -- window dışını sil
local count = redis.call('ZCARD', KEYS[1])                 -- kalanları say
if count < tonumber(ARGV[3]) then
    redis.call('ZADD', KEYS[1], ARGV[1], ARGV[4])          -- yeni timestamp
    redis.call('PEXPIRE', KEYS[1], ARGV[2])                -- key için TTL
    return 1
else
    return 0
end

Yukarıda ki script tek adımda çalışır ve okuma ile yazma arasına başka bir replika giremez. PEXPIRE ile de keylerin TTL süresini belirleyip sonsuza dek sanklanmasının önüne geçmiş oluyoruz.

.NET tarafında ise bu yapıyı StackExchange.Redis kütüphanesini kullanarak çağırabiliriz.

long result = (long)await db.ScriptEvaluateAsync(
    SlidingWindowScript,
    new RedisKey[] { $"rate:{customerId}" },
    new RedisValue[] { nowMs, windowMs, limit, Guid.NewGuid().ToString() });

bool allowed = result == 1;   // 1 = izin, 0 = 429

Her istek için ayrı bir Guid oluşturarak aynı milisaniyede gelen iki isteğin birbirini ezmesinin önüne geçmiş oluyoruz. Sonuç 1 ise istek geçer 0 ise 429 Too Many Request döneriz.

Redis ile Sliding Window Mimarisi

Üç stateless replika tek bir redis’e bakar ve ZREMRANGEBYSCORE — ZCARD — ZADD — PEXPIRE süreci tek bir Lua script içinde atomik çalışır.

Bu kurulumda mimari sadeleşir: load balancer istekleri stateless replikalara dağıtır, replikalar hiçbir sayaç tutmaz ve sayaç artık merkezi olmuştur. Bir replika yeniden başlasa da limit bozulmaz; çünkü hafıza replikada değil, ortak state’te yaşar.

Peki bu çözüm kesin çözüm müdür?

Merkezi bir sayaç oluşturmak için Redis’i kullanmış olmamız bize 100% sorunsuz bir sistem vaadinde bulunmamaktadır. Burada karşılabileceğimiz bazı sorunları kısaca belirtmek isterim.

  1. Clock drift: Eğer timestamp sunucu saatinden alınıyor ise replikaların saatleri birbirinden farklı olabilir. Bu durumda Sliding Window tarafında oluşan pencere aralığı bulanıklaşır. Zamanı sunucudan almak yerine Redis’in kendi TIME komutundan almak daha iyi olacaktır.
  2. Redis latency: Artık bir katmanın daha olduğunu bilmemiş gerekiyor. Her istek redis tarafına gidip gelecek. Redis’i uygulamaya yakın konumlandırmak ve gereksiz round-trip kaçınmak gerekecektir.
  3. Replica restart & state consistency: State merkezi bir yapıya kavuştuğu için artık replikalar rahatça yeniden başlayabilir. Bu bir artıdır. Fakat Redis tek kaynak olduğunda bir failover senaryosunda replikasyonun gecikmesi kısa süreliğine bir çift sayıma sebep olabilir. Rate limitin çok katı olması gereken yerlerde bu ihtimali de dikkate almak gerekir.

Burada farklı sorunlarda elbette oluşabilir. Her birini tek tek ele almıyorum. Ayrıca bu sorunların varlıklarıda Redis çözümünü çürütmez. Sadece şunu fark etmemizi sağlar:

Bu listenin hiçbiri Redis yaklaşımını çürütmez. Global limit hazır gelen bir çözüm değil, kendi mühendislik sorularını da beraberinde getiren bir karardır.

Sonuç olarak tek instance üzerinde sorunsuz çalışan sistem ölçeklendiğinde yanıltıcı çıktılar verebilir. İlk yazıda yüzeysel geçtiğim örnekleri bu yazı da production’a yakın bir şekilde tasarlamaya çalıştım. Fakat bu da cevap için yeterli değil. Serinin son yazısında bu sefer bu iddia’ları Azure ortamında gerçekten çalıştıracağız. Container Apps üzerinde çok replikalı bir API, Managed Redis, Azure Load Testing ve Application Insights ile gerçek bir production ortamının değerlerini görüp önce sorunu sonra da çözümü gözlemleyeceğiz.

Buraya kadar okuyup zaman ayırdığınız için teşekkür ederim. Geri dönüşleriniz benim için değerlidir.

Kaynaklar