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

Reliability Pattern'leri: Timeout, Retry, Backoff + Jitter, Circuit Breaker ve Load Shedding

Mert Özen 8 Kas 2026 12 dk 5 görüntülenme
Reliability Pattern'leri: Timeout, Retry, Backoff + Jitter, Circuit Breaker ve Load Shedding

Yavaş ya da çökmüş bir bağımlılığa karşı sırayla p99.9'a göre seçilmiş timeout, tek katmanda ve yalnız idempotent işlemde jitter'lı retry, retry budget, bağımlılık başına circuit breaker ve bulkhead, en sonda load shedding ve graceful degradation gerekir. Retry amplification (27 ve 243 çağrı), jitter, %10 retry budget ve circuit breaker'ın gerçek simülasyon çıktıları, failover ve .NET'in standard resilience handler varsayılanları.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir; simülasyonlar sabit seed'li ve sanal saatle çalıştığı için çıktılar her koşuda aynıdır.

Önce Tahmin Et

Bookstore'da checkout isteği üç katmandan geçiyor: API (Application Programming Interface), OrderService'i çağırıyor, OrderService Inventory'yi, Inventory de kendi veritabanını. Her katmanı yazan ekip "geçici hatalara karşı" kendi çağrısına üç deneme koymuş. Kampanya gecesi Inventory'nin veritabanı bağlantı havuzu tükeniyor ve her sorgu hata dönmeye başlıyor. Bu arada HttpClient'ların hiçbirinde timeout ayarlanmamış.

İki soru. Tek bir checkout isteği, zaten çökmüş olan veritabanına kaç sorgu gönderir? Ve timeout ayarlanmamış bir HttpClient, cevap vermeyen bir service'i en fazla ne kadar bekler?

Timeout: Her Çağrının Bir Bütçesi Var

İkinci sorunun cevabıyla başlayalım: HttpClient.Timeout'un varsayılan değeri 100 saniye. Kullanıcı 3 saniyede vazgeçip sayfayı yeniler; senin thread'in, socket'in ve veritabanı bağlantın 97 saniye daha o artık kimsenin beklemediği cevabı bekler. Yük altında bu bekleyişler birikir, bağlantı havuzları dolar ve yavaş bir bağımlılık, kendisini çağıran her şeyi yavaşlatır. Michael Nygard'ın "Release It!" kitabı (2007, ikinci baskı 2018) bu zincirleme yayılmayı cascading failure olarak anlatır ve stabilite pattern'lerinin ilki olarak timeout'u koyar: dışarıya giden her çağrının bir üst sınırı olmalı.

Timeout'u neye göre seçeceğiz? AWS (Amazon Web Services) Builders' Library'deki "Timeouts, retries, and backoff with jitter" yazısı (Marc Brooker, 2019) pratik bir yol öneriyor: kabul edebileceğin yanlış timeout oranını seç, örneğin %0,1, ve bağımlılığın o yüzdelikteki latency'sini, yani p99.9'u (isteklerin %99,9'unun altında kaldığı süre) timeout yap. Ortalama latency'ye göre seçilen timeout, normal ama yavaş isteklerin önemli bir kısmını keser; serinin "Back-of-the-Envelope Estimation: QPS, Storage, Bandwidth ve Availability Hesabı" yazısında (QPS, queries per second: saniyedeki istek sayısı) ortalama ile p99'u karıştırmanın tuzak olduğunu konuşmuştuk, burada o tuzağın bedeli doğrudan hata oranı. Aynı yazı bir uyarı da ekliyor: timeout'a TLS (Transport Layer Security) el sıkışması gibi bağlantı kurulum süresi de dahildir; deploy sonrası bütün bağlantılar yeniden kurulurken çok sıkı bir timeout toplu hataya dönebilir.

İkinci kural: timeout'lar katman katman küçülmeli. Checkout'un toplam bütçesi 2 saniyeyse OrderService'in Inventory'yi 5 saniye beklemesi anlamsızdır. En iyisi bütçeyi aşağı taşımak (deadline propagation): .NET'te bu, isteğin CancellationToken'ını her çağrıya geçirmek ve gerekirse CancelAfter ile daraltmak demek. ASP.NET Core Web API serisinin "ASP.NET Core'da Async/Await ve CancellationToken: İptali Veritabanına Kadar Taşımak" yazısı bunun mekaniğini gösterdi.

Retry: Ne Zaman İlaç, Ne Zaman Zehir

Retry, geçici hatalar için doğru cevap: bir paket kayboldu, bir instance tam o an yeniden başlıyordu, bir connection pool anlık doldu. Bağımlılık gerçekten çöktüyse retry iyileştirmez, yükü katlar. Önce Tahmin Et'teki ilk soruyu simüle ettim: her katman alttakini en fazla üç kez deniyor, veritabanı her zaman hata veriyor.

public static int DatabaseCalls(int layers, int attemptsPerLayer, bool retryOnlyAtTop)
{
    int calls = 0;
    bool Call(int layer)
    {
        if (layer == layers) { calls++; return false; }   // veritabanı her zaman hata veriyor
        int attempts = retryOnlyAtTop && layer > 0 ? 1 : attemptsPerLayer;
        for (int i = 0; i < attempts; i++)
            if (Call(layer + 1)) return true;
        return false;
    }
    Call(0);
    return calls;
}
3 attempts at every layer : 27 database calls
3 attempts at top only    : 3 database calls
5 layers, 3 attempts each : 243 database calls

Tek bir checkout, zaten düşmüş bir veritabanına 27 sorgu gönderiyor. AWS'in yazısındaki örnek beş katman ve 3^5 = 243 kat; bu, bir bağımlılığın toparlanma şansını neredeyse sıfırlar. Bu yüzden iki kural var. Birincisi, retry'ı tek bir katmanda yap; genelde hatayı ilk gören, yani bağımlılığın hemen üstündeki katmanda. Google'ın SRE (Site Reliability Engineering) kitabının "Handling Overload" bölümü de aynı şeyi söylüyor: yalnız reddeden katmanın hemen üstündeki katman retry etmeli. İkincisi, retry'ı yalnız idempotent işlemlerde yap. GET ya da Idempotency-Key taşıyan bir POST güvenle tekrarlanır; anahtarsız bir "siparişi oluştur" çağrısını tekrarlamak, ilk deneme aslında başarılı olmuş ama cevabı yolda kaybolmuşsa, iki sipariş demek. Çözüm Desenleri serisinin "Idempotency Key: Çift Tıklama ve Retry'da Aynı Siparişi İki Kez Oluşturmamak" yazısı bu tarafı uçtan uca anlattı.

Neyin retry'lanacağı da açık olmalı: 503, 429, 408 ve bağlantı hataları evet; 400, 401, 404 ve iş kuralı hataları hayır. 429 ise Retry-After header'ı ile birlikte geliyorsa, beklenecek süreyi sunucu söylüyor demektir.

Exponential Backoff ve Jitter

Retry'lar arasında beklemek şart, beklemenin şekli de önemli. Exponential backoff her denemede bekleme süresini katlar: 100, 200, 400, 800 ms. Ama bin client aynı anda hata aldıysa, hepsi aynı anda 100 ms bekler ve aynı anda geri gelir; yük dağılmaz, dalga olarak tekrar eder. Çare jitter, yani beklemeye rastgelelik katmak. Marc Brooker'ın 2015'te AWS Architecture Blog'da yayımladığı "Exponential Backoff And Jitter" yazısı farklı jitter türlerini simülasyonla karşılaştırdı ve jitter'sız backoff'u açık ara en kötü seçenek olarak buldu; en basit iyi seçenek full jitter, yani random(0, cap). Bin client'ı t=0'da hata aldırıp dört kez retry ettirdim ve herhangi bir 10 ms'lik dilime düşen en yüksek çağrı sayısını ölçtüm:

for (int attempt = 0; attempt < 4; attempt++)
{
    double cap = 100 * Math.Pow(2, attempt);              // 100, 200, 400, 800 ms
    t += useJitter ? random.NextDouble() * cap : cap;     // full jitter: random(0, cap)
    long slot = (long)(t / 10);
    slots[slot] = slots.GetValueOrDefault(slot) + 1;
}
backoff, no jitter        : peak 1000 calls/10 ms
backoff, full jitter      : peak 156 calls/10 ms

Jitter'sız backoff bin çağrının bin'ini aynı 10 ms'ye yığıyor; full jitter ile tepe 156'ya iniyor. Aynı fikir retry'ın dışında da geçerli: cache TTL'leri (Time To Live), scheduled job'lar, health check aralıkları. Hepsi aynı saniyeye hizalanırsa sistem kendi kendine thundering herd üretir.

Retry Budget

Backoff ve jitter retry'ları zamana yayar ama sayılarını azaltmaz. Bağımlılık uzun süre çökükse, üç denemeli her client yükü yine üç katına çıkarır. Google SRE kitabının çözümü iki katmanlı bir bütçe: istek başına en fazla üç deneme, client başına da retry'ların toplam isteklerin %10'unun altında kalması. Kitaptaki hesap, yalnız istek başı sınırla en kötü durumda yükün üç kata yaklaştığını, %10 bütçeyle ise yaklaşık 1,1 katta kaldığını söylüyor. Simülasyon aynı sonucu veriyor:

public static int TotalAttempts(int requests, bool useBudget)
{
    int attempts = 0, retries = 0;
    for (int r = 1; r <= requests; r++)
    {
        attempts++;                                   // ilk deneme hata alıyor
        for (int retry = 0; retry < 2; retry++)       // toplamda en fazla 3 deneme
        {
            if (useBudget && retries + 1 > r * 0.10) break;   // retry'lar isteklerin %10'unu geçemez
            retries++; attempts++;
        }
    }
    return attempts;
}
3 attempts, no budget     : 30000 attempts
3 attempts, 10% budget    : 11000 attempts

Bütçe, sağlıklı zamanda neredeyse hiç devreye girmez; geçici hataların oranı %10'un çok altındadır. Devreye girdiği an, sorunun geçici olmadığının işaretidir. AWS SDK'ları (Software Development Kit) da 2016'dan beri retry'ları yerel bir token bucket ile sınırlıyor; mantık aynı.

Circuit Breaker ve Bulkhead

Retry budget "daha az dene" der; circuit breaker "bir süre hiç deneme" der. Nygard'ın "Release It!"te tanımladığı ve Martin Fowler'ın 2014 tarihli CircuitBreaker yazısıyla yaygınlaşan pattern üç state'li bir state machine: Closed'da çağrılar geçer ve sonuçları sayılır; hata oranı eşiği aşınca Open'a geçer ve çağrılar bağımlılığa hiç gitmeden hemen hata döner (fail fast); belirli bir süre sonra Half-Open'da tek bir deneme çağrısına izin verir, başarılıysa Closed'a, değilse tekrar Open'a döner. Mantığı görmek için küçük bir implementation:

public enum BreakerState { Closed, Open, HalfOpen }

public sealed class CircuitBreaker(double failureRatio, int minThroughput, TimeSpan samplingWindow, TimeSpan breakDuration)
{
    private readonly Queue<(TimeSpan At, bool Failed)> _samples = new();
    private TimeSpan _openedAt;
    public BreakerState State { get; private set; } = BreakerState.Closed;

    public bool AllowCall(TimeSpan now)
    {
        if (State == BreakerState.Open && now - _openedAt >= breakDuration)
            State = BreakerState.HalfOpen;               // tek bir deneme çağrısına izin ver
        return State != BreakerState.Open;
    }

    public void Record(TimeSpan now, bool failed)
    {
        if (State == BreakerState.HalfOpen)
        {
            State = failed ? BreakerState.Open : BreakerState.Closed;
            if (failed) _openedAt = now;
            _samples.Clear();
            return;
        }
        _samples.Enqueue((now, failed));
        while (now - _samples.Peek().At > samplingWindow) _samples.Dequeue();
        int failures = _samples.Count(s => s.Failed);
        if (_samples.Count >= minThroughput && failures >= failureRatio * _samples.Count)
        {
            State = BreakerState.Open;
            _openedAt = now;
        }
    }
}

Sanal bir saatle 12 saniye boyunca her 50 ms'de bir çağrı yaptım; bağımlılık ilk 6 saniye çökük. Breaker'ın eşiği 2 saniyelik pencerede en az 10 çağrı ve %50 hata, Open süresi 2 saniye:

  t= 0.45s Closed -> Open
  t= 2.45s Open -> HalfOpen
  t= 2.45s HalfOpen -> Open
  t= 4.45s Open -> HalfOpen
  t= 4.45s HalfOpen -> Open
  t= 6.45s Open -> HalfOpen
  t= 6.45s HalfOpen -> Closed
circuit breaker           : 123 calls sent, 117 failed fast without a call

240 çağrının 117'si bağımlılığa hiç gitmeden döndü; çökük service, toparlanmak için ihtiyaç duyduğu nefes alanını aldı ve çağıran taraf thread'lerini timeout beklemekle harcamadı. Çıktıda bedel de görünüyor: bağımlılık 6,0'ıncı saniyede düzeldi, breaker 6,45'e kadar fail fast yapmaya devam etti. AWS'in yazısı bu yüzden circuit breaker'ın sisteme mod değiştiren (modal) bir davranış eklediğini ve toparlanmayı geciktirebileceğini not ediyor. Breaker'ı bağımlılık başına kur (bütün dış çağrılar için tek breaker, sağlam bir bağımlılığı da kapatır) ve Open'dayken kullanıcıya ne göstereceğini önceden tasarla.

Bulkhead, gemilerin su geçirmez bölmelerinden adını alır: kaynakları bağımlılık başına ayırırsın ki biri tükendiğinde diğerleri etkilenmesin. Ödeme service'i yavaşladığında onun çağrıları en fazla 20 eşzamanlı slot kullanabiliyorsa, katalog sayfası kendi slot'larıyla çalışmaya devam eder. .NET'te en basit hâli bağımlılık başına bir ConcurrencyLimiter ya da ayrı bir HttpClient ve connection limiti.

Load Shedding ve Graceful Degradation

Buraya kadarki pattern'ler çağıran tarafın kendini korumasıydı. Load shedding, çağrılan tarafın kendini korumasıdır: sunucu kapasitesinin sonuna geldiğini anladığında (CPU (Central Processing Unit) kullanımı, queue derinliği, eşzamanlı istek sayısı), yeni istekleri işlemeye çalışıp hepsini yavaşlatmak yerine bir kısmını hemen ve ucuza reddeder. AWS Builders' Library'deki "Using load shedding to avoid overload" yazısı bunu goodput kavramıyla anlatır: aşırı yük altındaki bir sunucu, client'ın zaten timeout'a düşüreceği istekler için çalışırsa throughput yüksek görünür ama işe yarayan iş (goodput) sıfıra iner.

Load shedding'in incelikli kısmı neyi atacağına karar vermek. Önceki yazıda andığımız Stripe örneğinde filonun bir kısmı kritik isteklere ayrılıyor, aşırı yükte ilk önce test mode trafiği atılıyor. Bookstore için öncelik sırası açık: checkout ve ödeme en son atılır, öneri listesi ve "bunu alanlar şunu da aldı" kutusu ilk. Bu da graceful degradation'a bağlanıyor: öneri service'i cevap vermiyorsa sayfa hata vermez, öneri kutusu boş ya da cache'teki dünkü listeyle gelir; arama yavaşsa otomatik tamamlama kapanır. Hangi özelliğin kapatılabileceği bir ürün kararıdır ve kriz anında değil önceden, feature flag'leriyle birlikte verilmelidir.

Redundancy ve Failover

Son savunma hattı yedeklilik. Active-passive kurulumda bir kopya trafiği taşır, diğeri bekler; birincil düşünce health check'ler bunu fark eder ve trafik yedeğe çevrilir. Basittir ama yedeğin gerçekten çalıştığını ancak failover anında öğrenirsin, geçiş de saniyeler ile dakikalar arasında sürer. Active-active kurulumda bütün kopyalar trafik taşır; biri düşünce kalanlar yükü üstlenir. Kapasiteyi buna göre planlamak gerekir: iki bölgeli active-active bir sistemde her bölge tek başına bütün yükü taşıyabilmeli, yani normalde en fazla %50 doluluğa göre boyutlanmalı. Serinin "Back-of-the-Envelope Estimation" yazısındaki availability hesabı burada geri döner: iki bağımsız kopyadan en az birinin ayakta olma ihtimali, tek kopyanınkinden çok daha yüksektir, ama yalnız failover mekanizmasının kendisi güvenilirse. Veri katmanında failover, replication lag ve iki tarafın da kendini birincil sanması (split brain) gibi sorunlar getirir; bunların teorisi ilerideki Distributed Systems Temelleri serisinin konusu.

.NET'te Hepsi Bir Arada

Bu pattern'leri her HttpClient için elle yazmak gerekmiyor. Microsoft.Extensions.Http.Resilience paketinin AddStandardResilienceHandler'ı Polly üzerinde beş strateji zincirliyor; Microsoft Learn'deki varsayılanlar, dıştan içe:

SıraStratejiVarsayılan
1Rate limiter (bulkhead)1.000 eşzamanlı istek, queue 0
2Total timeout30 s, retry'lar dahil
3Retry3 retry, exponential, jitter açık, 2 s taban gecikme
4Circuit breaker%10 hata oranı, en az 100 çağrı, 30 s örnekleme, 5 s Open
5Attempt timeoutDeneme başına 10 s

Sıralama bu yazının mantığıyla birebir örtüşüyor: toplam timeout retry'ların dışında, deneme başına timeout içinde; breaker retry'ın içinde olduğu için her deneme breaker'a sayılıyor. İki ayara dikkat: varsayılan retry bütün HTTP (Hypertext Transfer Protocol) method'larında çalışır, idempotent olmayan çağrılar için DisableForUnsafeHttpMethods() ile kapatmalısın; ve 30 saniyelik toplam timeout, checkout gibi kullanıcının beklediği bir yol için çoğu zaman fazla uzun. Paketin ayrıntılı kullanımı ilerideki Backend kategorisindeki External Service Integration ve Resilience serisinin konusu; production'daki incident tarafı da DevOps kategorisindeki SRE ve Production Incident'ları serisinin.

Sık Düşülen Tuzaklar

Her katmanda retry

Üç katman, üçer deneme: bir istek 27 çağrıya dönüşüyor. Retry'ı tek bir katmana koy, üst katmanlara "tekrar deneme" bilgisini hata koduyla taşı.

Varsayılan timeout'a güvenmek

100 saniyelik HttpClient.Timeout hiçbir kullanıcı akışına uymaz. Timeout'u bağımlılığın p99.9'una göre seç ve toplam bütçeyi CancellationToken ile aşağı taşı.

Jitter'sız backoff

Hata alan herkes aynı anda geri gelir. Backoff her zaman jitter'lı olsun; aynısı TTL'ler ve zamanlanmış işler için de geçerli.

İdempotent olmayan işlemi retry'lamak

Cevabı kaybolan bir "ödeme al" çağrısını tekrarlamak çift tahsilat demektir. Ya Idempotency-Key kullan ya da retry'ı kapat.

Tek breaker'la bütün bağımlılıkları kapatmak

Breaker ve bulkhead bağımlılık başına kurulur; yoksa yavaş bir öneri service'i ödeme çağrılarını da durdurur.

Küçük Bir Deneme

★☆☆ DatabaseCalls'u iki katmanda retry olacak şekilde değiştir (en üst ve en alt). Üç katmanlı zincirde veritabanı kaç çağrı görür?

★★☆ Backoff simülasyonuna "equal jitter"ı (cap / 2 + random(0, cap / 2)) ekle. Tepe değer full jitter'a göre nasıl? Brooker'ın yazısındaki sonuçla karşılaştır.

★★★ Bookstore'un ürün detay sayfası beş bağımlılık çağırıyor: katalog, fiyat, stok, yorumlar, öneriler. Her biri için timeout, retry, breaker ve degradation kararını içeren bir tablo hazırla. Hangisi düşünce sayfa tamamen hata vermeli?

Sistemi artık hem fazla istemeye hem de bağımlılıkların çökmesine karşı koruyoruz. Case study'lere geçmeden önce bir yapı taşı kaldı: bu kadar instance, shard ve service arasında benzersiz ve sıralanabilir ID'leri nasıl üreteceğiz? Bir sonraki yazıda auto-increment'ten UUIDv7 ve Snowflake'e uzanan seçeneklere bakacağız.