Yeniden Deneme Fırtınası: Üstel Geri Çekilme, Jitter, Deneme Bütçesi ve Devre Kesici
Yavaşlayan bir bağımlılığı her katmanın tekrar denemesi kısa bir yavaşlamayı uzun bir kesintiye çevirir. Tekrar denemenin tek katmanda yapılması, tam jitter, trafiğe oranlı deneme bütçesi, aşağı akan son tarih ve devre kesicinin doğru kurulması.
Bir bağımlılık yavaşladığında sistemin geri kalanının onu kurtarmaya çalışması iyi niyetli ama çoğu zaman yıkıcı bir reflekstir. Her istemci zaman aşımına uğrayan isteği hemen tekrar dener, zaten zorlanan servise gelen yük iki, üç, katmanlı mimaride çok daha fazla katına çıkar ve kısa bir yavaşlama uzun bir kesintiye dönüşür. Buna yeniden deneme fırtınası denir. Sorun tekrar denemenin kendisi değil; ne zaman, ne kadar ve hangi katmanda denendiğinin düşünülmemiş olmasıdır.
Çarpan etkisi: tekrar deneme neden katlanır
Üç katmanlı bir çağrı zinciri düşünün: ön yüz bir API'yi, API bir iç servisi, iç servis de veritabanını çağırıyor. Her katman başarısız isteği üç kez deniyorsa, en alttaki tek bir başarısızlık en üstte 3 x 3 x 3 = 27 deneme anlamına gelir. Veritabanı zaten yük yüzünden yavaşlamışsa, ona ulaşan istek sayısı tam da en az kaldırabileceği anda katlanarak artar.
Buradan çıkan ilk kural basittir: tekrar deneme tek bir katmanda yapılır. Genellikle bu, başarısızlığa en yakın ve işlemin güvenle tekrarlanabileceğini bilen katmandır. Diğer katmanlar hatayı yukarı iletir. Bir HTTP istemci kütüphanesinin, bir servis ağı vekilinin ve uygulama kodunun aynı anda tekrar denediği kurulumlar şaşırtıcı derecede yaygındır; çünkü her biri ayrı ayrı açıldığında makul görünür.
Üç yaklaşım ve hangisi ne zaman
Sabit aralıkla tekrar deneme. Her başarısızlıktan sonra aynı süre beklenir. Tek bir istemcisi olan basit betikler dışında kullanılmamalıdır. Binlerce istemci aynı anda hata aldığında, aynı anda tekrar dener; bekleme süresi yalnızca dalgayı ileri öteler.
Üstel geri çekilme. Bekleme süresi her denemede katlanır: 100 ms, 200 ms, 400 ms gibi. Toplam yükü azaltır ama tek başına yetmez. Hata aynı anda başladıysa istemciler hâlâ eşzamanlıdır; dalgalar seyrekleşir ama keskinliğini korur.
Üstel geri çekilme ve tam jitter. Her denemede üst sınır katlanır, gerçek bekleme süresi sıfır ile bu sınır arasında rastgele seçilir. İstemciler zamana yayılır, servis bir dalga yerine düzgün bir akış görür. Varsayılan seçim budur. Sınırın etrafında küçük bir oynama yapan "yarım jitter" yaklaşımı da işe yarar, ama tam jitter hem daha basittir hem de yükü daha iyi dağıtır.
import random
import time
def retry(call, attempts=4, base=0.1, cap=5.0, deadline=None):
for attempt in range(attempts):
try:
return call()
except RetryableError:
if attempt == attempts - 1:
raise
sleep = random.uniform(0, min(cap, base * 2 ** attempt))
if deadline is not None and time.monotonic() + sleep >= deadline:
raise
time.sleep(sleep)
Buradaki RetryableError bilinçli bir ayrımdır: her hata tekrar denenmez. Bağlantı reddi, zaman aşımı, 503 ve 429 tekrar denemeye adaydır. 400, 401, 404 ve doğrulama hataları tekrar denenmez; aynı istek aynı cevabı alır, yalnızca yük üretir. 429 ya da 503 ile birlikte Retry-After başlığı geliyorsa kendi hesabınız yerine o süreye uyun.
Deneme sayısı değil, deneme bütçesi
"En fazla üç deneme" kuralı tek bir isteğin davranışını sınırlar, sistemin toplam davranışını sınırlamaz. Servis tamamen çöktüğünde her istek üç kez denenir ve yük yine üç katına çıkar. Asıl ihtiyaç, tekrar denemelerin toplam trafiğe oranını sınırlamaktır.
Deneme bütçesi şöyle çalışır: başarılı her istek kovaya küçük bir pay ekler, her tekrar deneme kovadan bir birim harcar. Kova boşsa istemci tekrar denemez, hatayı hemen döner. Sağlıklı dönemde ara sıra gelen geçici hatalar sorunsuz tekrar denenir; bağımlılık topyekûn bozulduğunda ise ek yük toplam trafiğin küçük bir yüzdesiyle sınırlı kalır.
Bunu sıfırdan yazmanız gerekmeyebilir. gRPC istemcileri, servis yapılandırmasında hem tekrar deneme politikasını hem de bu tür bir kısmayı destekler:
{
"methodConfig": [{
"name": [{ "service": "orders.OrderService" }],
"timeout": "2s",
"retryPolicy": {
"maxAttempts": 3,
"initialBackoff": "0.1s",
"maxBackoff": "1s",
"backoffMultiplier": 2,
"retryableStatusCodes": ["UNAVAILABLE"]
}
}],
"retryThrottling": {
"maxTokens": 10,
"tokenRatio": 0.1
}
}
retryThrottling bölümünde başarısız her çağrı jeton sayısını bir azaltır, başarılı her çağrı tokenRatio kadar artırır; sayı üst sınırın yarısına düştüğünde tekrar deneme durur. Envoy gibi vekillerde de kümeye ait devre kesici eşikleri altında yüzde olarak tanımlanan bir deneme bütçesi vardır. Hangi aracı kullanırsanız kullanın mantık aynıdır: tekrar denemeler sabit bir sayı değil, trafiğin oranı olarak sınırlanmalıdır.
Zaman aşımı bütçesi aşağı doğru akar
Tekrar deneme ile zaman aşımı ayrı düşünüldüğünde ilginç bir hata çıkar: kullanıcıya dönen istek 2 saniyede vazgeçer, ama alt katman kendi 2 saniyelik zaman aşımıyla üç kez dener ve 6 saniye boyunca çalışmaya devam eder. Kimsenin beklemediği bir cevap için kaynak harcanır.
Doğrusu, en üstte bir son tarih belirleyip bunu her çağrıyla aşağı iletmektir. Her katman kalan süreyi bilir, kalan süre bir denemeye yetmiyorsa denemez. gRPC son tarihi kendiliğinden yayar; HTTP tabanlı servislerde bunu bir başlıkla ya da bağlam nesnesiyle kendiniz taşımanız gerekir. Yukarıdaki örnekteki deadline parametresi tam olarak bu işe yarar: beklemeden sonra süre dolacaksa beklemenin anlamı yoktur.
Bir de alt sınır vardır: tekrar deneme yapan katmanın zaman aşımı, çağırdığı servisin normal gecikme dağılımının üst yüzdeliklerinin biraz üzerinde olmalıdır. Medyan gecikmeye göre ayarlanmış bir zaman aşımı, yavaş ama sağlıklı isteklerin önemli bir kısmını kesip tekrar denemeye çevirir ve yükü kendi elinizle artırırsınız.
Devre kesici: denemeyi tamamen bırakmak
Deneme bütçesi tekrar denemeyi sınırlar, ilk denemeyi değil. Bağımlılık dakikalarca yanıt vermiyorsa, her isteğin ilk denemesi de zaman aşımını bekleyerek iş parçacıklarını ve bağlantıları tutar. Devre kesici bu durumda bağımlılığı tamamen devreden çıkarır.
Üç hali vardır. Kapalı halde istekler normal geçer ve hata oranı izlenir. Hata oranı belirli bir pencerede eşiği aşınca devre açılır ve istekler bağımlılığa hiç gitmeden hemen başarısız olur. Bir süre sonra yarı açık hale geçilir, sınırlı sayıda deneme isteği gönderilir; bunlar başarılıysa devre kapanır, değilse yeniden açılır.
Devre kesicinin değeri hızlı başarısızlıktadır: bekleyen istekler birikmez, çağıran servis ayakta kalır ve kullanıcıya önbellekten, varsayılan değerden ya da kısıtlı işlevle bir cevap verilebilir. Açık devrede ne döneceğinizi önceden tasarlamadıysanız devre kesici yalnızca hatayı hızlandırır; bu da bir kazançtır ama tam kazanç değildir.
Tuzaklar
Idempotent olmayan işlemi tekrar denemek. Zaman aşımı isteğin başarısız olduğu anlamına gelmez; sunucu işlemi yapmış ama cevap yolda kaybolmuş olabilir. Ödeme, sipariş oluşturma ya da e-posta gönderme gibi işlemler bir idempotency anahtarı olmadan tekrar denenirse ikiz kayıt üretir. Tekrar denenecek her yazma işlemi sunucu tarafında aynı anahtarla gelen ikinci isteği tanımalıdır.
Kuyruk tüketicilerinde sonsuz tekrar. Mesaj işlenemediğinde hemen kuyruğa geri dönüyorsa, bozuk tek bir mesaj tüketiciyi sıkı bir döngüye sokar. Kuyruk düzeyinde de geri çekilme, deneme sınırı ve bu sınır aşıldığında mesajı ayrı bir ölü mesaj kuyruğuna taşımak gerekir.
Toparlanma anındaki gürleyen sürü. Bağımlılık geri geldiğinde bütün devre kesiciler aynı anda yarı açık hale geçerse, toparlanan servis ilk saniyede bütün trafiği birden alır ve yeniden düşer. Devre kesicinin açık kalma süresine de jitter ekleyin, yarı açık haldeki deneme isteklerini az tutun.
Vekil ile uygulamanın üst üste denemesi. Servis ağı ya da yük dengeleyici tekrar deniyorsa uygulama kodu denememeli, ya da tersi. Hangi katmanın denediğini bir yerde yazılı tutun; yoksa bir yıl sonra biri "dayanıklılık için" üçüncü bir katman ekler.
Tekrar denemeyi ölçmemek. Başarılı görünen ama ikinci ya da üçüncü denemede başarılı olan istekler bir bozulmanın erken işaretidir. Tekrar deneme sayısını ayrı bir metrik olarak yayınlayın; ilk denemedeki başarı oranı düşerken toplam başarı oranı sabit kalıyorsa sorun yaklaşıyordur.
Ne zaman tekrar denememeli
Etkileşimli ve gecikmeye duyarlı yollarda, örneğin bir kullanıcının beklediği sayfa isteğinde, bazen en iyi davranış tek denemedir: hızlıca hata dönmek ve kullanıcıya yeniden deneme seçeneği vermek, arka planda saniyelerce beklemekten iyidir. Hatanın kaynağı aşırı yükse ve bunu bir 429 ya da 503 ile açıkça söylüyorsa, Retry-After dışında hiçbir tekrar deneme yardımcı olmaz. Ve işlem idempotent değilse, idempotency anahtarı kurulana kadar tekrar deneme hiç açılmamalıdır.
Pratik bir başlangıç noktası: tek katmanda, tam jitter ile en fazla iki üç deneme; trafiğin yüzde onu civarında bir deneme bütçesi; en üstten aşağı akan bir son tarih ve kritik bağımlılıklar önünde bir devre kesici. Bunların hepsini ilk günden kurmak zorunda değilsiniz, ama sıralama önemlidir: önce hangi katmanın denediğini netleştirin, sonra jitter ekleyin, sonra bütçeyi koyun.