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?
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?
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
Veriyi tablo olarak göster
| Süre | A · Anında retry | B · Exponential backoff | C · Backoff + jitter |
|---|---|---|---|
| 8 s | 7 | 10 | 9 |
| 8,1 s | 10 | 9 | 9 |
| 8,2 s | 12 | 11 | 12 |
| 8,3 s | 11 | 12 | 11 |
| 8,4 s | 9 | 9 | 8 |
| 8,5 s | 9 | 9 | 9 |
| 8,6 s | 6 | 6 | 7 |
| 8,7 s | 10 | 8 | 8 |
| 8,8 s | 8 | 10 | 10 |
| 8,9 s | 7 | 7 | 7 |
| 9 s | 10 | 10 | 10 |
| 9,1 s | 9 | 8 | 8 |
| 9,2 s | 12 | 11 | 10 |
| 9,3 s | 12 | 13 | 14 |
| 9,4 s | 10 | 10 | 11 |
| 9,5 s | 6 | 7 | 5 |
| 9,6 s | 7 | 7 | 8 |
| 9,7 s | 8 | 8 | 8 |
| 9,8 s | 8 | 9 | 9 |
| 9,9 s | 11 | 11 | 10 |
| 10 s | 232 | 11 | 76 |
| 10,1 s | 4 | 3 | 73 |
| 10,2 s | 5 | 143 | 19 |
| 10,3 s | 6 | 6 | 13 |
| 10,4 s | 7 | 11 | 11 |
| 10,5 s | 8 | 8 | 15 |
| 10,6 s | 4 | 36 | 8 |
| 10,7 s | 3 | 4 | 4 |
| 10,8 s | 5 | 6 | 4 |
| 10,9 s | 1 | 1 | 2 |
| 11 s | 1 | 0 | 0 |
| 11,1 s | 8 | 4 | 4 |
| 11,2 s | 6 | 5 | 5 |
| 11,3 s | 3 | 2 | 3 |
| 11,4 s | 8 | 8 | 1 |
| 11,5 s | 6 | 3 | 4 |
| 11,6 s | 4 | 5 | 5 |
| 11,7 s | 4 | 2 | 7 |
| 11,8 s | 8 | 5 | 4 |
| 11,9 s | 7 | 3 | 6 |
| 12 s | 3 | 5 | 3 |
| 12,1 s | 11 | 11 | 7 |
| 12,2 s | 8 | 5 | 5 |
| 12,3 s | 8 | 8 | 9 |
| 12,4 s | 13 | 7 | 12 |
| 12,5 s | 14 | 9 | 12 |
| 12,6 s | 6 | 10 | 9 |
| 12,7 s | 11 | 9 | 13 |
| 12,8 s | 14 | 10 | 8 |
| 12,9 s | 14 | 5 | 9 |
| 13 s | 12 | 8 | 7 |
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ı
| Strateji | Başarıetkilenen işlemler (8,5–15 sn) | Toplam istekilk denemeler + retry'lar | Retrygönderilen | Retrybütçenin reddettiği | Tepe1 sn | Tepe100 ms | p95tüm işlemler | p99tüm işlemler | Toparlanma |
|---|---|---|---|---|---|---|---|---|---|
| A · Anında retry | %92,9 | 3.040 | 220 | 0 | 275 | 232 | 628 ms | 1.600 ms | 1 sn |
| B · Exponential backoff | %100,0 | 2.956 | 182 | 0 | 229 | 143 | 844 ms | 2.132 ms | 0 sn |
| C · Backoff + jitter | %100,0 | 2.959 | 171 | 0 | 225 | 76 | 870 ms | 2.043 ms | 0 sn |
| D · C + retry bütçesi | %78,0 | 2.858 | 10 | 126 | 120 | 19 | 131 ms | 1.192 ms | 1 sn |
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
- Timeouts, retries, and backoff with jitter — Amazon Builders’ Library
- Build resilient HTTP apps — Microsoft Learn (Microsoft.Extensions.Http.Resilience)