Tüm makaleler
Dağıtık Sistemler
Dağıtık Sistemlerde GüvenilirlikBölüm 03

Retry, Timeout ve Backoff: Ne Zaman Yeniden Denememeliyiz?

Bir servis yanıt vermediğinde yeniden denemek mantıklı görünebilir. Peki ya tekrar denemelerimiz, toparlanmaya çalışan servisi daha da zorluyorsa?

10 dk okumaRead in English

Bir sisteme dayanıklılık kazandırmak istediğimizde aklımıza gelen ilk mekanizmalardan biri retry’dır.

Bağlantı kesilirse yeniden deneriz. İstek zaman aşımına uğrarsa tekrar göndeririz. Servis geçici olarak kullanılamıyorsa birkaç saniye bekleyip yeniden bağlanırız.

Bunlar çoğu zaman işe yarayan yaklaşımlardır.

Ancak yeniden deneme mekanizması, yanlış tasarlandığında sistemi koruyan bir özellik olmaktan çıkıp sorunun kendisine dönüşebilir.

Özellikle birden fazla servisin birbirini çağırdığı sistemlerde küçük bir kesinti, kontrolsüz tekrar denemeler nedeniyle çok daha büyük bir yük problemine dönüşebilir.

Timeout bize ne söylüyor?

Timeout, bir işlemin başarısız olduğunu değil, belirlenen süre içinde beklenen sonucun alınamadığını söyler.

Bir HTTP çağrısını düşünelim. İstemci isteği gönderiyor, ancak beş saniye içinde yanıt alamıyor.

Bunun birkaç olası nedeni vardır:

  • İstek karşı tarafa hiç ulaşmamıştır.
  • İstek ulaşmış, fakat henüz işlenmemiştir.
  • İşlem devam ediyordur.
  • İşlem tamamlanmış, ancak yanıt istemciye ulaşamamıştır.

Bu durumlar istemci açısından benzer görünebilir. Fakat yeniden deneme kararları açısından aynı değildir.

Örneğin salt okunur bir sorguyu yeniden denemek çoğu durumda kabul edilebilirken, ödeme veya fiziksel komut gibi yan etkisi olan bir işlemi tekrar göndermek istenmeyen sonuçlar oluşturabilir.

İlk makalede ele aldığım idempotency ihtiyacı burada yeniden karşımıza çıkıyor.

Timeout bir gözlemdir; işlemin sonucuna ilişkin kesin bir kanıt değildir.

Retry neden bazen arızayı büyütür?

Bir servis geçici olarak yavaşladığında istemciler timeout almaya başlayabilir.

Her istemcinin başarısız isteği hemen üç kez daha gönderdiğini düşünelim.

Zaten zorlanan servis, normal trafikle birlikte ek isteklerle karşılaşır.

Daha fazla istek, daha fazla gecikmeye; daha fazla gecikme de daha fazla timeout ve retry’a neden olabilir.

Bu davranış, bir tür geri besleme döngüsü oluşturur.

Çok katmanlı sistemlerde etki daha da büyüyebilir. Örneğin beş katmanın her birinin bağımsız olarak toplam üç deneme gerçekleştirdiği en kötü senaryoda, alt katmana ulaşabilecek deneme sayısı 3⁵, yani 243 olabilir.

Gerçek bir sistemde her istek mutlaka bu kadar çoğalmaz. Ancak örnek, tekrar denemelerin neden tek bir katmanda ve belirli sınırlarla ele alınması gerektiğini gösterir.

Exponential backoff neyi değiştirir?

En basit retry yaklaşımı, başarısız işlemi hemen yeniden denemektir.

Daha kontrollü bir yaklaşım olan exponential backoff ise başarısız denemeler arasında giderek artan süreler bekler.

Örneğin:

İlk istek       → hemen
1. retry        → 200 ms sonra
2. retry        → 400 ms sonra
3. retry        → 800 ms sonra

Bu sayede servis toparlanmaya çalışırken üzerine aynı hızda yeni istekler gönderilmez.

Ancak tek başına exponential backoff da yeterli olmayabilir.

Aynı anda hata alan yüzlerce istemci aynı bekleme sürelerini kullanırsa, yeniden denemeleri de aynı anda gerçekleşebilir.

Jitter: Yeniden denemeleri zamana yaymak

Jitter, bekleme sürelerine kontrollü rastgelelik eklenmesidir.

Örneğin bütün istemcilerin tam olarak 400 milisaniye beklemesi yerine, her birinin belirli bir aralıkta farklı süre beklemesi sağlanabilir.

Amaç yalnızca geciktirmek değil, tekrar denemelerin aynı anda birikmesini önlemektir.

Şema — Jitter, eşzamanlı retry'ları nasıl dağıtır?

Üstte üç istemci aynı anda hata alıyor ve hepsi 400 ms bekleyip aynı anda yeniden deniyor. Altta aynı üç istemci 0 ile 400 ms arasında rastgele bekliyor ve denemeleri farklı anlara düşüyor. Jitter olmadan: herkes 400 ms bekler t₀ · 3 istemci aynı anda hata alır t₀ + 400 ms · 3 retry aynı anda Jitter ile: herkes 0–400 ms arasında rastgele bekler t₀ · 3 istemci aynı anda hata alır Retry'lar zamana yayılır zaman →
Şematik gösterim; ölçüm verisi değildir. Deneyde ölçülen etki Şekil 1'de.

Burada kullanılan rastgelelik algoritması önemlidir. Full jitter, equal jitter ve decorrelated jitter gibi farklı yaklaşımlar farklı gecikme dağılımları üretir.

Hiçbiri tek başına bütün yük problemlerini çözmez. Retry sayısı, toplam zaman bütçesi ve servis kapasitesi de hesaba katılmalıdır.

Küçük bir .NET örneği

Aşağıdaki örnek, salt okunur bir HTTP GET isteğinde sınırlı retry ve jitter kullanır.

using System.Net;

static bool IsTransient(HttpStatusCode status) =>
    status is HttpStatusCode.RequestTimeout
        or HttpStatusCode.TooManyRequests
        or HttpStatusCode.BadGateway
        or HttpStatusCode.ServiceUnavailable
        or HttpStatusCode.GatewayTimeout;

static async Task<HttpResponseMessage> GetWithRetryAsync(
    HttpClient client,
    string url,
    CancellationToken cancellationToken)
{
    const int maxAttempts = 4;

    for (var attempt = 1; ; attempt++)
    {
        using var timeout = CancellationTokenSource
            .CreateLinkedTokenSource(cancellationToken);

        timeout.CancelAfter(TimeSpan.FromSeconds(2));

        HttpResponseMessage? response = null;

        try
        {
            response = await client.GetAsync(
                url,
                timeout.Token);

            if (!IsTransient(response.StatusCode) ||
                attempt == maxAttempts)
            {
                return response;
            }
        }
        catch (OperationCanceledException)
            when (!cancellationToken.IsCancellationRequested &&
                  attempt < maxAttempts)
        {
            // Bu deneme zaman aşımına uğradı.
        }
        catch (HttpRequestException)
            when (attempt < maxAttempts)
        {
            // Geçici bir ağ hatası olabilir.
        }

        response?.Dispose();

        var capMs = Math.Min(
            200 * (1 << (attempt - 1)),
            2000);

        var delay = TimeSpan.FromMilliseconds(
            Random.Shared.Next(0, capMs + 1));

        await Task.Delay(delay, cancellationToken);
    }
}

Bu örnek, gerçek sistemlerde ihtiyaç duyacağımız bütün korumaları içermez.

Özellikle toplam işlem süresi bütçesi, Retry-After başlığı, merkezi retry politikaları, metrikler ve yük sınırlandırması ayrıca ele alınmalıdır.

Kodun amacı belirli bir kütüphaneyi önermek değil, retry sayısının ve bekleme davranışının açıkça tanımlanması gerektiğini göstermektir.

Kod notu: Bu örnek bilinçli olarak GET ile sınırlandırılmıştır. Aynı yaklaşımı, idempotency güvencesi olmayan POST işlemlerine doğrudan uygulamak güvenli değildir.

Davranışı test etmek: Retry gerçekten neyi iyileştiriyor?

Retry, backoff ve jitter mekanizmalarının davranışlarını yalnızca teorik olarak değerlendirmek yerine, bunları kontrollü bir arıza senaryosunda karşılaştırmak istedim.

Bunun için Distributed Reliability Lab kapsamında iki deney hazırladım. İlk deneyde (E03-A) geçici hatalar karşısındaki genel retry davranışını, ikinci deneyde (E03-B) ise çok sayıda istemcinin aynı anda etkilendiği bir kesinti sonrası toparlanmayı inceledim.

İlk sonuçlar önemli bir noktayı gösterdi: Farklı retry politikaları, beklediğimizden daha benzer toplam istek sayıları üretebiliyordu. Üstelik jitter kullanmak her koşulda daha yüksek başarı oranı sağlamıyordu.

Bu nedenle ikinci deneyde özellikle isteklerin zamana nasıl dağıldığına odaklandım.

Deney ortamı

E03-B deneyinde 200 istemci bulunuyor.

Bağımlı servis (dependency), 8,5–10,0 saniyeleri arasında donuyor. Servis 10,0 saniyede yeniden çalışmaya başladığında bekleyen isteklere HTTP 503 yanıtı veriyor.

Böylece aynı anda hata alan çok sayıda istemcinin yeniden deneme davranışını gözlemleyebiliyorum.

Bağımlı servis sentetik bir modeldir: 10 işçi ve istek başına 40 ms ile saniyede 250 istek kapasitesi, 100 isteklik bir kuyruk ve kapasite dolduğunda anında HTTP 503. Yeniden başladıktan sonraki beş saniye boyunca (10–15 s) kapasite saniyede 125 isteğe düşer. Deneyleri tek bir dizüstü bilgisayarda, Docker içinde çalıştırdım. Sonuçlar bu modele ve bu koşullara aittir; gerçek bir servisin davranışını birebir temsil ettikleri iddiasında değilim.

Dört stratejiyi karşılaştırdım:

  • A — Anında retry: Başarısız istekler bekleme olmadan yeniden deneniyor.
  • B — Exponential backoff: Denemeler arasında artan gecikmeler uygulanıyor.
  • C — Backoff + jitter: Artan gecikmelere rastgelelik ekleniyor.
  • D — Backoff + jitter + retry budget: C stratejisine ek olarak yeniden deneme hacmi sınırlandırılıyor.

Ortak sınırlar en fazla dört deneme, deneme başına iki saniye ve mantıksal işlem başına toplam beş saniye.

D stratejisinde ayrıca retry sayısı, ilk deneme hacminin yaklaşık %10’u düzeyinde sınırlandırılıyor.

İlk gözlem: Toplam istek sayısı her şeyi anlatmıyor

Seed 42 ile gerçekleştirilen E03-B çalışmasında B ve C stratejileri benzer toplam istek sayıları üretti: sırasıyla 2.956 ve 2.959.

Bir saniyelik ölçüm penceresinde de tepe yükleri neredeyse aynıydı: B için 229, C için 225 istek.

Yalnızca bu iki ölçüte baksaydım jitter’ın kayda değer bir fark oluşturmadığını düşünebilirdim.

Ancak ölçüm aralığını 100 milisaniyeye indirdiğimde farklı bir tablo ortaya çıktı.

B stratejisinin tepe değeri 143, C stratejisinin ise 76 istekti.

Yani bu deneyde jitter, toplam istek sayısını anlamlı ölçüde değiştirmediği halde kısa süreli retry yoğunlaşmasını yaklaşık %47 azalttı.

Bu farkın nedeni, aynı anda hata alan istemcilerin sabit bekleme süreleri kullandığında yeniden aynı anlarda istek göndermeye eğilimli olmasıdır.

Jitter bu eşzamanlılığı azaltarak denemeleri daha geniş bir zaman aralığına dağıtıyor.

Şekil 1 — Yeniden denemelerin zamansal dağılımı

  • A · Anında retry
  • B · Exponential backoff
  • C · Backoff + jitter
E03-B, seed 42: 8 ile 13. saniyeler arasında bağımlı servise ulaşan isteklerin 100 ms'lik dilimlerdeki sayısı. A 10,0 saniyede tek bir sivri tepe yapıyor, B'nin denemeleri dalgalar halinde geliyor, C'ninkiler daha geniş bir aralığa yayılıyor.Donma (8,5–10 s)0501001502002508 s9 s10 s11 s12 s13 sÇalışmanın başından itibaren süreİstek / 100 msA · Anında retryB · Exponential backoffC · Backoff + jitter
Şekil 1. E03-B deneyinde bağımlı servise ulaşan isteklerin 100 ms'lik zaman dilimlerindeki dağılımı. Anında retry (A), servis yeniden çalışmaya başladığında keskin bir istek tepesi oluşturuyor. Backoff (B), denemeleri geciktiriyor ancak belirli anlarda yeni dalgalar oluşturabiliyor. Backoff ve jitter birlikte kullanıldığında (C), yeniden denemeler daha geniş bir zaman aralığına dağılıyor. Gerçek deney verileri, seed 42. Grafikte 8–13. saniyeler gösteriliyor; ölçüm aralığı 100 ms. Kaynak: distributed-reliability-lab, E03-B, seed 42, commit e8743ae (experiments/retry-resilience/results/sample/e03-b).
Veriyi tablo olarak göster
SüreA · Anında retryB · Exponential backoffC · Backoff + jitter
8 s7109
8,1 s1099
8,2 s121112
8,3 s111211
8,4 s998
8,5 s999
8,6 s667
8,7 s1088
8,8 s81010
8,9 s777
9 s101010
9,1 s988
9,2 s121110
9,3 s121314
9,4 s101011
9,5 s675
9,6 s778
9,7 s888
9,8 s899
9,9 s111110
10 s2321176
10,1 s4373
10,2 s514319
10,3 s6613
10,4 s71111
10,5 s8815
10,6 s4368
10,7 s344
10,8 s564
10,9 s112
11 s100
11,1 s844
11,2 s655
11,3 s323
11,4 s881
11,5 s634
11,6 s455
11,7 s427
11,8 s854
11,9 s736
12 s353
12,1 s11117
12,2 s855
12,3 s889
12,4 s13712
12,5 s14912
12,6 s6109
12,7 s11913
12,8 s14108
12,9 s1459
13 s1287

Buradaki önemli nokta yalnızca tepe değerinin düşmesi değil.

Bir dayanıklılık mekanizmasının etkisi, seçilen ölçüm çözünürlüğüne bağlı olarak farklı görünebilir.

Bir saniyelik toplulaştırma, yüz milisaniyelik bir zaman diliminde oluşan yoğunlaşmayı gizleyebilir. Özellikle kısa süreli kapasite sınırları veya kuyruk taşmalarıyla karşılaşan servislerde bu fark önemli olabilir.

Retry bütçesinin farklı bir amacı var

D stratejisini eklediğimde sonuç daha da ilginçleşiyor.

Seed 42 çalışmasında toplam istek sayısı 2.858’e, bir saniyelik tepe 120’ye, 100 ms’lik tepe ise 19’a düşüyor.

Ancak etkilenen işlemlerin başarı oranı da %78’e geriliyor.

B ve C aynı deneyde %100 başarı sağlarken, D yükü daha sıkı sınırlamak için bazı işlemlerin yeniden denenmesini engelliyor.

Bu sonuç, retry budget mekanizmasının başarısız olduğunu göstermiyor.

Aksine, bize farklı bir tasarım hedefini hatırlatıyor: Bir bağımlı servisi korumak ile tekil istemcinin isteğini başarıyla tamamlamak her zaman aynı yönde hareket etmeyebilir.

Retry bütçesi, kaynak tüketimini sınırlandırırken bazı çağrıların başarısızlıkla sonuçlanmasını kabul eden bilinçli bir politika olabilir.

Bunun uygun olup olmadığı, sistemin iş gereksinimlerine ve hata karşısında sunabildiği alternatif davranışlara bağlıdır.

Şekil 2 — Stratejilerin karşılaştırılması

StratejiBaşarıetkilenen işlemler (8,5–15 sn)Toplam istekilk denemeler + retry'larRetrygönderilenRetrybütçenin reddettiğiTepe1 snTepe100 msp95tüm işlemlerp99tüm işlemlerToparlanma
A · Anında retry%92,93.0402200275232628 ms1.600 ms1 sn
B · Exponential backoff%100,02.9561820229143844 ms2.132 ms0 sn
C · Backoff + jitter%100,02.959171022576870 ms2.043 ms0 sn
D · C + retry bütçesi%78,02.8581012612019131 ms1.192 ms1 sn
Şekil 2. E03-B deneyinin seed 42 çalışmasındaki sonuçları. Başarı oranı yalnızca kesintiden etkilenen mantıksal işlemleri ifade eder; toplam istek ve tepe yük ölçümleri ise bağımlı servise ulaşan istekleri gösterir. A, B, C ve D aynı temel deneme ve timeout sınırlarıyla çalıştırılmıştır. D stratejisinde ek retry bütçesi uygulanmıştır. Etkilenen işlemler, donmanın başladığı 8,5. saniye ile yavaş açılışın bittiği 15,0. saniye arasında başlayan mantıksal işlemlerdir; p95 ve p99 başarılı ve başarısız tüm işlemleri kapsar. Kaynak: distributed-reliability-lab, E03-B, seed 42, commit e8743ae (experiments/retry-resilience/results/sample/e03-b).

Bu sonuçları değerlendirirken bir ölçüte dikkat etmek gerekiyor: Daha düşük p99 gecikme her zaman daha iyi bir kullanıcı deneyimi anlamına gelmeyebilir.

Örneğin D stratejisinin p99 değeri 1.192 ms ile en düşük sonuçtur. Ancak başarı oranı da en düşüktür.

Başarısız işlemlerin gecikme hesabına nasıl dahil edildiği, ölçümün neyi temsil ettiğini doğrudan etkiler. Dolayısıyla gecikme ve başarı oranını birbirinden bağımsız birer kalite puanı olarak okumamak gerekir.

Deneyi farklı koşullarda tekrarlamak

Seed 42 tek başına bir sonuca varmak için yeterli değildi.

Bu nedenle E03-B’yi beş farklı seed ile çalıştırdım. Temel eğilim korundu: Jitter, 100 ms düzeyindeki yoğunlaşmayı azaltırken B ve C’nin toplam yükleri birbirine yakın kaldı.

Ancak çalışma koşullarını değiştirdiğimde farklı sonuçlar da ortaya çıktı.

Örneğin daha küçük kuyruk kapasitesinde jitter başarı oranını artırabildi. Kesinti süresi uzatıldığında veya istemci sayısı yükseltildiğinde ise C’nin toplam istek sayısı ve bir saniyelik tepe yükü B’den yüksek olabildi.

Bu alternatif konfigürasyonların her biri tek çalıştırmaya dayandığından, bunları genel üstünlük kanıtı olarak değil, yeni doğrulama gerektiren gözlemler olarak değerlendirmek daha doğru.

E03-A’da ise seed değişiminin başarı oranları üzerindeki etkisi, kimi zaman stratejiler arasındaki farktan daha büyüktü.

Bunun bize söylediği şey açık:

Bir retry stratejisinin başarısını yalnızca tek bir senaryoya, tek bir rastgelelik değerine veya tek bir metriğe göre değerlendiremeyiz.

Deneyden çıkardığım sonuç

Bu çalışmada backoff ve jitter’ın aynı problemi çözmediğini daha açık biçimde gördüm.

Backoff, yeniden denemeleri geciktirir. Jitter, özellikle birbirine yakın zamanlarda gerçekleşen denemelerin eşzamanlılığını azaltabilir. Retry budget ise ne kadar yeniden deneme yüküne izin verileceğini sınırlar.

Bunlar birbirini tamamlayabilen, fakat etkileri ve bedelleri farklı olan mekanizmalardır.

Dolayısıyla doğru soru “Hangi retry algoritması daha iyi?” değil.

“Bu sistemin arıza ve toparlanma koşullarında hangi davranışı kabul edilebilir buluyoruz?”

Bu sorunun yanıtı başarı oranını, kısa süreli yük yoğunluğunu, toplam istek maliyetini, gecikmeyi ve sistemin toparlanma davranışını birlikte değerlendirmeyi gerektirir.

Deneyi kendiniz çalıştırın

Bu yazıdaki iki deneyin kodu, testleri ve ham sonuçları distributed-reliability-lab deposunda açık kaynak olarak duruyor. Çalıştırmak için yalnızca Docker gerekiyor:

git clone https://github.com/hemreg/distributed-reliability-lab.git
cd distributed-reliability-lab/experiments/retry-resilience
docker compose up --build

Komut yaklaşık 4–5 dakika sürer. Önce E03-A’yı, ardından E03-B’yi çalıştırır ve sonunda bu yazıdaki tabloların karşılığını konsola yazar. Sonrasında docker compose down ile ortamı temizleyebilir, ./test-e03.sh ile birim testlerini çalıştırabilirsiniz. Testler bekleme takvimlerini, retry döngüsünün deneme ve süre sınırlarını, retry bütçesini ve arıza zaman çizelgesini doğrular; deneyin sonuçlarını ya da bir stratejinin üstünlüğünü doğrulamaz. Dizüstü bilgisayarda makinenin uykuya geçmesi deneyi duraklatır; macOS’ta caffeinate -di docker compose up --build bunu önler.

Parametreler ortam değişkenleriyle değiştirilebilir; örneğin farklı bir seed için E03_SEED=7 docker compose up --build. Her parametrenin anlamı ve neden seçildiği deponun README dosyasında açıklanıyor.

Sayılarınız buradakilerden biraz farklı çıkabilir, çünkü zamanlama makineden makineye değişir. Aynı seed ile temiz bir klondan yaptığım bağımsız bir çalıştırmada B ve C’nin 100 ms’lik tepeleri 144 ve 77, D’nin başarı oranı %77,9 oldu. Stratejiler arasındaki fark ve sıralama korunmalıdır. Korunmuyorsa bu da paylaşılmaya değer bir sonuçtur; depoda bir issue açarak bildirebilirsiniz.

Ne zaman yeniden denememeliyiz?

Bence retry tasarımının en önemli kısmı, hangi durumlarda duracağımızı tanımlamak.

Örneğin:

  • Geçersiz bir isteğin aynen tekrar edilmesi sonucu değiştirmeyecekse,
  • İşlemin gerçekleşip gerçekleşmediği belirsiz ve tekrarın yan etkisi riskliyse,
  • Toplam zaman bütçesi tükenmişse,
  • Servis açıkça yeniden denemeyi durdurmamızı veya beklememizi istiyorsa,
  • Sistemin kapasitesi yeni denemeleri kaldıramıyorsa,

retry yerine farklı bir davranış gerekebilir.

Bu davranış; hızlı hata dönmek, isteği sonraya bırakmak, mevcut durumu sorgulamak, kuyruğa almak veya operatör müdahalesi istemek olabilir.

Endüstriyel sistemlerde özellikle komutun semantiğini ve cihazın mevcut durumunu dikkate almadan tekrar göndermek doğru değildir.

Circuit breaker ve backpressure

Retry tek başına bir dayanıklılık stratejisi değildir.

Bazı koşullarda circuit breaker ile başarısız çağrıları geçici olarak durdurmak, backpressure ile talep hızını sınırlamak veya load shedding ile belirli istekleri reddetmek gerekebilir.

Bunların da maliyeti vardır. Örneğin agresif bir circuit breaker, servis toparlanmış olsa bile belirli süre boyunca istekleri engelleyebilir.

Bu nedenle kullanılan mekanizma kadar eşik değerleri, gözlemlenebilirlik ve toparlanma davranışı da önemlidir.

Sonuç: Retry bir refleks değil, politikadır

Dağıtık sistemlerde retry mekanizmalarının amacı yalnızca başarısız bir isteği yeniden göndermek değildir.

E03 deneyleri, farklı retry politikalarının başarı oranı, toplam yük ve kısa süreli istek yoğunluğu üzerinde farklı etkiler oluşturabildiğini gösterdi.

Jitter belirli koşullarda eşzamanlı retry dalgalarını dağıttı. Retry bütçesi yükü daha sıkı sınırladı, ancak bazı işlemlerin başarıyla tamamlanma olasılığını da düşürdü.

Bu nedenle tek başına bir retry algoritması seçmek yeterli değildir. İşlemin semantiğini, toplam süre bütçesini, bağımlı servisin kapasitesini ve başarısızlığın iş açısından etkisini birlikte değerlendirmek gerekir.

Güvenilirlik, her hatadan sonra yeniden denemek değil; hangi koşulda yeniden denemenin doğru olduğunu bilmektir.

İleri okuma

#retry#timeout#backoff#jitter#dayanıklılık