Dayanıklılık: timeout, retry, circuit breaker, bulkhead ve load shedding
Yavaşlayan bir bağımlılığın tüm sistemi nasıl çökerttiğini Little's law ile hesaplamayı, timeout, retry, circuit breaker, bulkhead, rate limiter ve fallback'i Resilience4j ile doğru sırada ve doğru değerlerle kurmayı öğretir.
Bu bölümde
Bir servisin çökmesinden çok daha sık görülen problem, bir bağımlılığın yavaşlamasıdır. Bu bölümde yavaşlığın thread havuzlarını nasıl tükettiğini Little's law ile hesaplamayı, ardından timeout, retry, circuit breaker, bulkhead, rate limiter, fallback ve load shedding'in her birinin hangi problemi çözdüğünü ve birlikte nasıl kurulduğunu öğreneceksin.
Mülakatta konu genelde bir senaryo olarak gelir: "Kargo API'si kampanya günü 30 saniyeye çıktı, checkout servisi de çöktü. Neden, ne yapardın?" Çoktan seçmeli sorularda ise circuit breaker durumları, retry'ın ne zaman güvenli olduğu ve katmanlar arası retry çarpımı sorulur.
Konu anlatımı
Neden dayanıklılık: Little's law ve cascading failure
Little's law, kararlı bir sistemde içerideki ortalama iş sayısını verir:
L = λ × W
L: sistemde aynı anda bulunan istek sayısı (ör. meşgul thread sayısı)
λ: geliş hızı (istek/sn)
W: bir isteğin sistemde kaldığı ortalama süre (sn)
Checkout servisine saniyede 400 istek geliyor. Her istek kendi işi için 50 ms harcıyor ve kargo API'sini çağırıyor, kargo API'si normalde 50 ms'de cevap veriyor. W = 0,1 sn; L = 400 × 0,1 = 40 meşgul thread. Tomcat'in varsayılan havuzu 200 thread (server.tomcat.threads.max); rahat.
Kampanya günü kargo API'si 30 saniyeye çıkıyor. W ≈ 30 sn; ihtiyaç L = 400 × 30 = 12.000 thread. Havuzda 200 var. Havuz yaklaşık 200 / 400 = 0,5 saniyede dolar. Bundan sonra:
- Kargo çağırmayan endpoint'ler de (ürün detayı, sepet) thread bulamaz; Tomcat'in kabul kuyruğunda bekler, sonra bağlantı reddedilir.
- Checkout'u çağıran üst servisler (API gateway, BFF) aynı problemi yaşar; onların thread'leri de checkout'u bekleyerek dolar.
- Health check endpoint'i de thread bulamaz; Kubernetes pod'u sağlıksız sayıp restart eder, kalan pod'lara daha çok yük biner.
Buna cascading failure (zincirleme çöküş) denir: Tek bir bağımlılığın yavaşlığı, kaynak tükenmesi üzerinden onunla hiç ilgisi olmayan işlevlere ve üst katmanlara yayılır. Sistemin geri kalanı sağlıklıyken kullanıcı tamamen çökmüş bir site görür.
Bu hesaptan iki ders çıkar:
- Yavaşlık, hatadan daha tehlikelidir. Kargo API'si anında 503 dönseydi W düşük kalırdı ve thread'ler serbest kalırdı. Yavaş cevap kaynağı tutar.
- Timeout tek başına yetmez. Timeout'u 2 saniye yapsan bile L = 400 × 2 = 800 thread gerekir; havuz yine dolar. Yavaş bağımlılığa giden çağrıların hızlıca kesilmesi (circuit breaker) ve eşzamanlı sayısının sınırlanması (bulkhead) gerekir.
Dayanıklılık desenlerinin her biri bu denklemin bir parçasını kontrol eder: timeout W'nin üst sınırını, circuit breaker bağımlılık bozukken W'yi milisaniyelere, bulkhead bir bağımlılığın kullanabileceği L'yi, rate limiter ve load shedding λ'yı.
Timeout türleri
Bir ağ çağrısında birden fazla bekleme noktası vardır ve her birinin ayrı bir timeout'u olur:
| Timeout | Neyi sınırlar | Tipik değer (aynı veri merkezi) |
|---|---|---|
| Connect | TCP bağlantısının kurulması | 100 ms - 1 sn |
| Read / socket / response | Bağlantı kurulduktan sonra veri beklemek | Bağımlılığın p99'u + pay |
| Pool acquire | Havuzdan bağlantı almak (HTTP client pool, HikariCP) | Kısa: 100 ms - 1 sn |
| Toplam (deadline) | Çağrının tamamı, retry'lar dahil | Çağıranın kendi SLA'sı |
Connect timeout kısa olmalıdır: aynı ağdaki bir sunucuyla TCP el sıkışması milisaniyeler sürer. Uzun bir connect beklemesi neredeyse her zaman hedefin ayakta olmadığı veya ağın sorunlu olduğu anlamına gelir.
Read timeout çoğu istemcide iki veri paketi arasındaki en uzun bekleme süresidir, toplam süre değildir. Cevabı damla damla gönderen bir sunucu her paketi read timeout'tan kısa aralıklarla gönderirse çağrı çok daha uzun sürebilir. Toplam süre için ayrıca bir üst sınır (çağrı seviyesinde deadline) gerekir.
Pool acquire timeout en çok unutulanıdır. HikariCP'nin connectionTimeout varsayılanı 30 saniyedir: havuz doluysa thread bir connection için 30 saniye bekler, bekleme süresince de Tomcat thread'ini tutar. Yük altında "DB yavaş" sanılan problemin kaynağı çoğu zaman bu beklemedir (bkz. Bölüm 8 ve 10).
Veritabanı tarafında da aynı mantık geçerlidir: PostgreSQL'de statement_timeout (tek sorgu), lock_timeout (kilit bekleme) ve idle_in_transaction_session_timeout (açık bırakılmış transaction) varsayılan olarak 0, yani kapalıdır. JDBC sürücüsünün socketTimeout varsayılanı da 0'dır.
Varsayılan sonsuz timeout tehlikesi. Java'nın klasik HttpURLConnection'ında connect ve read timeout varsayılanı 0'dır, yani sonsuz. java.net.http.HttpClient'ta da connect timeout ve istek timeout'u verilmezse sınır yoktur. Spring'in SimpleClientHttpRequestFactory'si HttpURLConnection kullanır. Yani "hiçbir şey ayarlamadım" demek "sonsuza kadar beklerim" demektir. Spring Boot 3.5'te tüm otomatik yapılandırılan RestClient/RestTemplate'ler için spring.http.client.connect-timeout ve spring.http.client.read-timeout; Spring Boot 4'te spring.http.clients.connect-timeout ve spring.http.clients.read-timeout ile global varsayılan verilebilir.
Bağımlılığa özel bir istemci kurmak daha iyidir; her bağımlılığın timeout'u kendi gecikme profiline göre seçilir:
@Configuration
public class ShippingClientConfig {
@Bean
public RestClient shippingRestClient(RestClient.Builder builder) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(500))
.build();
var factory = new JdkClientHttpRequestFactory(httpClient);
factory.setReadTimeout(Duration.ofMillis(800)); // kargo API p99 ~ 400 ms
return builder
.baseUrl("https://shipping.example.com")
.requestFactory(factory)
.build();
}
}
Timeout değeri nasıl seçilir?
- Bağımlılığın gecikme dağılımını ölç: p50, p99, p99.9. Timeout'u p99.9'un biraz üstüne koymak, sağlıklı çağrıların neredeyse hiçbirini kesmeden takılan çağrıları keser.
- Çağıranın bütçesini aşma: Checkout'un kendi SLA'sı 2 saniyeyse, kargo çağrısına 5 saniye timeout vermek anlamsızdır; kullanıcı zaten gitmiştir.
- Retry varsa bütçeyi böl: 3 deneme × 800 ms + backoff, toplam bütçenin içinde kalmalı.
- "En uzun süreyi verelim, güvenli olsun" yanlış bir sezgidir: uzun timeout thread ve connection'ları tutarak cascading failure'ı besler.
Retry
Retry, geçici (transient) hataları kullanıcıya göstermeden atlatmanın aracıdır: bir paket kayboldu, bağlantı sıfırlandı, bir pod deploy sırasında kısa süre 503 döndü. Ama yanlış kurulmuş retry, dayanıklılık deseni değil, bir yük çarpanıdır.
Sadece idempotent işlemler retry edilir. Timeout aldığın bir istek sunucuda başarılı olmuş olabilir: istek ulaştı, işlendi, cevap yolda kayboldu. GET, PUT, DELETE tanımı gereği idempotenttir; POST /payments değildir. İdempotent olmayan bir çağrıyı retry etmek çift çekim, çift sipariş demektir. Bunu güvenli yapmanın yolu idempotency key'dir (bkz. Bölüm 13).
Hangi hatalar retry edilir:
| Retry edilir | Retry edilmez |
|---|---|
| Bağlantı kurulamadı (istek hiç gitmedi; POST için bile güvenli) | 400, 401, 403, 404, 409, 422 (aynı istek aynı sonucu verir) |
| 502, 503, 504 | İş kuralı hataları (stok yok, geçersiz adres) |
429 (Retry-After dikkate alınarak) | Circuit breaker açık (CallNotPermittedException) |
| Read timeout (sadece idempotent işlemde) | Bulkhead dolu (BulkheadFullException) |
Exponential backoff + jitter. Sabit aralıkla retry, toparlanmakta olan bir servisi düzenli dalgalarla tekrar ezer. Exponential backoff her denemede bekleme süresini katlar (100 ms, 200 ms, 400 ms...). Ama tek başına yetmez: aynı anda hata alan binlerce istemci aynı anda bekler ve aynı anda tekrar dener (thundering herd). Jitter bekleme süresine rastgelelik ekleyerek denemeleri zamana yayar. AWS'nin önerdiği "full jitter":
sleep = random(0, min(cap, base * 2^attempt))
Retry amplification. Retry'lar katmanlar arasında çarpılır. Gateway → Checkout → Shipping-Adapter → Kargo API zincirinde her katman başarısız bir çağrıyı toplam 3 deneme ile yaparsa, en alttaki API tek bir kullanıcı isteği için 3 × 3 × 3 = 27 çağrı alır. Her katman "3 kez retry" (ilk deneme + 3 tekrar = 4 deneme) yaparsa 4³ = 64 olur. Bağımlılık zaten aşırı yük yüzünden yavaşken bu, onu toparlanamayacak hale getirir (retry storm).
Çözümler:
- Tek katmanda retry. Genelde hataya en yakın katmanda (bağımlılığı doğrudan çağıran adapter) veya en üstte; ara katmanlar retry etmez, hatayı hızlıca yukarı iletir.
- Retry budget. Retry'ların toplam trafiğe oranını sınırlamak. Örneğin bir token bucket: her başarılı çağrı 0,1 token ekler, her retry 1 token harcar; token yoksa retry yapılmaz. Böylece normal günde retry'lar serbestken, bağımlılık geniş çaplı hata verirken retry trafiği toplamın yaklaşık %10'unda kalır.
- Circuit breaker ile birlikte. Hata oranı yükselince devre açılır ve retry'lar anında başarısız olur. (Resilience4j'deki bir tuzak için aşağıdaki sıra bölümüne bak.)
- Deadline. Kalan süre bir sonraki denemeye yetmiyorsa deneme yapılmaz.
Circuit breaker
Circuit breaker, sürekli başarısız olan veya yavaşlayan bir bağımlılığa çağrı göndermeyi geçici olarak keser. Amaç iki yönlüdür: çağıranı korumak (thread'ler beklemek yerine milisaniyede hata alır) ve bağımlılığa nefes aldırmak (toparlanırken üzerine yük binmez).
Üç ana durum:
stateDiagram-v2
[*] --> CLOSED
CLOSED --> OPEN: hata oranı veya yavaş çağrı oranı eşiği aştı
OPEN --> HALF_OPEN: waitDurationInOpenState doldu
HALF_OPEN --> CLOSED: deneme çağrıları eşiğin altında
HALF_OPEN --> OPEN: deneme çağrıları yine eşiğin üstünde- CLOSED: Çağrılar normal geçer, sonuçlar sliding window'a kaydedilir.
- OPEN: Çağrılar bağımlılığa hiç gönderilmez. Resilience4j anında
CallNotPermittedExceptionfırlatır; uygulama fallback döner veya hatayı iletir. - HALF_OPEN: Bekleme süresi dolunca sınırlı sayıda deneme çağrısına izin verilir (
permittedNumberOfCallsInHalfOpenState). Bunlar başarılıysa CLOSED'a, değilse tekrar OPEN'a döner.
Resilience4j'de ayrıca DISABLED (her şey geçer, metrik yok), FORCED_OPEN (her şey reddedilir) ve METRICS_ONLY (metrik toplanır ama asla açılmaz) özel durumları vardır. METRICS_ONLY, yeni bir circuit breaker'ı production'da eşiklerini gözlemleyerek devreye almak için kullanışlıdır.
Sliding window. Karar son N çağrıya (COUNT_BASED) veya son N saniyeye (TIME_BASED) bakılarak verilir. minimumNumberOfCalls, oran hesaplanmadan önce gereken en az çağrı sayısıdır; düşük trafikte 2 çağrıdan 1'inin fail olması devreyi açmasın diye vardır.
İki eşik: failureRateThreshold (hata oranı) ve slowCallRateThreshold (yavaş çağrı oranı). Yavaş çağrı, slowCallDurationThreshold'dan uzun süren çağrıdır. Yavaşlığın hatadan daha tehlikeli olduğunu hatırla: kargo API'si 503 değil 30 saniye dönüyorsa devreyi açacak olan yavaş çağrı eşiğidir.
Resilience4j varsayılanları (değerleri bilinçli seçmenin neden şart olduğunu gösterir):
| Ayar | Varsayılan |
|---|---|
failureRateThreshold | 50 (%) |
slowCallRateThreshold | 100 (%) |
slowCallDurationThreshold | 60000 ms |
slidingWindowType / slidingWindowSize | COUNT_BASED / 100 |
minimumNumberOfCalls | 100 |
waitDurationInOpenState | 60000 ms |
permittedNumberOfCallsInHalfOpenState | 10 |
automaticTransitionFromOpenToHalfOpenEnabled | false |
Varsayılanlarla 30 saniyelik çağrılar "yavaş" sayılmaz (eşik 60 sn) ve yavaş çağrı eşiği %100'dür; yani q15'teki senaryoda varsayılan ayarlı bir circuit breaker yavaşlık yüzünden hiç açılmaz. slowCallDurationThreshold mutlaka bağımlılığın gecikme profiline göre ayarlanmalıdır.
Hangi hatalar sayılır. recordExceptions ve ignoreExceptions ile belirlenir. İş kuralı hataları (400, geçersiz adres) devreyi açmamalıdır; onlar bağımlılığın sağlıksız olduğunu değil, isteğin hatalı olduğunu gösterir. Aksi halde hatalı istek gönderen tek bir istemci herkes için devreyi açabilir.
Circuit breaker instance başınadır. 20 pod varsa 20 ayrı circuit breaker vardır; her biri kendi gördüğü trafiğe göre karar verir. Düşük trafikli pod'larda minimumNumberOfCalls'a geç ulaşılır.
Bulkhead
Gemi bölmelerinden adını alır: bir bölme su alsa bile gemi batmaz. Bulkhead, bir bağımlılığın kullanabileceği eşzamanlılığı sınırlar; böylece o bağımlılık yavaşladığında sadece ona ayrılan kapasite dolar.
Little's law hesabına dönelim: Kargo çağrılarına en fazla 40 eşzamanlı çağrı izni verirsen, kargo API'si 30 saniyeye çıktığında en fazla 40 thread kargo'yu bekler; 41. çağrı anında reddedilir ve fallback'e düşer. Kalan 160 thread ürün detayı, sepet ve ödeme isteklerine hizmet etmeye devam eder.
Resilience4j iki bulkhead sunar:
SemaphoreBulkhead | FixedThreadPoolBulkhead | |
|---|---|---|
| Mekanizma | Çağıran thread'de çalışır, semafor ile eşzamanlı sayıyı sınırlar | Çağrıyı ayrı, sınırlı bir thread havuzuna ve kuyruğa verir |
| Varsayılanlar | maxConcurrentCalls 25, maxWaitDuration 0 | maxThreadPoolSize CPU sayısı, coreThreadPoolSize CPU sayısı - 1, queueCapacity 100 |
| Dönüş tipi | Senkron veya asenkron | CompletionStage (asenkron) |
| Avantaj | Basit, ek thread yok | Çağıranın thread'i bağımlılığa hiç bağlanmaz, timeout ile birleşmesi kolay |
| Dezavantaj | Çağıran thread bekler | Ek thread havuzu, context propagation (MDC, security, tracing) zahmeti |
Dolu bulkhead BulkheadFullException fırlatır. maxWaitDuration 0 olarak kalmalıdır: bulkhead dolduğunda beklemek, tam da kaçınmak istediğin thread bloklanmasıdır.
Bulkhead değeri yine Little's law ile seçilir: bağımlılığa giden istek/sn × kabul edilebilir en uzun süre. Saniyede 400 kargo çağrısı ve 100 ms normal süre için 40 normalde yeterlidir; normal trafikte bulkhead'in hiç dolmaması, sadece bozulmada devreye girmesi istenir.
Bağlantı havuzları da doğal bulkhead'lerdir: kargo API'sine ayrı bir HTTP connection pool ve ödeme API'sine ayrı bir pool, birinin tükenmesinin diğerini etkilemesini engeller. Tüm dış çağrıları tek bir paylaşılan havuzdan yapmak bulkhead'i ortadan kaldırır.
Rate limiter
Rate limiter belirli bir zaman diliminde izin verilen çağrı sayısını sınırlar. İki farklı kullanımı vardır:
- İstemci tarafı: Bağımlılığın kotasına uymak (kargo API'si dakikada 6.000 istek kabul ediyor) ve onu kendi trafiğinle ezmemek.
- Sunucu tarafı: Kendi servisini aşırı yükten veya kötüye kullanımdan korumak (kullanıcı başına, API anahtarı başına limit); reddedilen isteğe
429 Too Many RequestsveRetry-Afterdönmek.
Resilience4j RateLimiter varsayılanları: limitForPeriod 50, limitRefreshPeriod 500 ns, timeoutDuration 5 sn. Son değer önemlidir: izin yoksa thread 5 saniyeye kadar bekler. Fail-fast istiyorsan timeoutDuration'ı 0 yap; izin verilmeyen çağrı RequestNotPermitted alır.
Resilience4j rate limiter'ı instance içindir; 20 pod'un her biri ayrı sayar. Bağımlılığın toplam kotasını korumak için ya kotayı pod sayısına bölersin (pod sayısı değişince bozulur) ya da paylaşılan bir sayaç (Redis) veya API gateway seviyesinde limit kullanırsın. Algoritmalar (token bucket, sliding window) için (bkz. Bölüm 19), HTTP tarafı (429, Retry-After) için (bkz. Bölüm 18).
Fallback ve graceful degradation
Fallback, çağrı başarısız olduğunda (hata, timeout, devre açık, bulkhead dolu) dönülen yedek cevaptır. Graceful degradation ise sistemin bir özelliği kaybettiğinde tamamen çökmek yerine azaltılmış işlevle çalışmaya devam etmesidir.
Kargo ücreti için fallback seçenekleri:
- Son bilinen değer: Aynı bölge ve desi için önbellekteki son ücret.
- Tarife tablosu: Yerel olarak tutulan bölge/desi bazlı sabit tarife.
- Özelliği gizlemek: Tahmini teslim tarihini göstermemek, ücreti "ödeme adımında hesaplanacak" diye ertelemek.
- İşi sonraya bırakmak: Kargo kaydını senkron yerine kuyruğa atmak.
Fallback kuralları:
- Fallback bir iş kararıdır. Kargo ücretini düşük göstermek şirkete para kaybettirir, yüksek göstermek satış kaybettirir. Hangi fallback'in kabul edilebilir olduğu ürün ve finans ile konuşulur.
- Bazı işlemler fail-closed olmalıdır. Ödeme onayı, fraud kontrolü, yetkilendirme için "varsayılan olarak onayla" fallback'i kabul edilemez; hata dönülür.
- Fallback başka bir kırılgan bağımlılığı çağırmamalı. Kargo API'si çökünce yedek olarak başka bir uzak servisi çağırmak, problemi oraya taşır.
- Fallback test edilmeli ve izlenmeli. Hiç çalışmayan kod yolu ilk gerçek kesintide bozuk çıkar. Fallback kullanım oranı ayrı bir metrik olmalıdır; aksi halde sistem haftalarca "sessizce bozuk" çalışabilir.
Resilience4j'de katmanların sırası
Resilience4j'nin Spring Boot annotation'ları birlikte kullanıldığında aspect'ler varsayılan olarak şu sırayla sarılır (dıştan içe):
Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( Function ) ) ) ) )
Yani Retry en dıştadır ve en son uygulanır. Sıra resilience4j.retry.retryAspectOrder, resilience4j.circuitbreaker.circuitBreakerAspectOrder gibi property'lerle (yüksek değer = yüksek öncelik) veya annotation yerine fonksiyonel zincirleme (Decorators) ile değiştirilebilir.
Bu sıranın sonuçları:
- Her retry denemesi circuit breaker'dan geçer ve ayrı bir çağrı olarak sayılır. Bağımlılık bozukken retry'lar hata oranını hızla yükseltir ve devre daha çabuk açılır.
- Timeout her denemeye ayrı uygulanır. Toplam süre
maxAttempts × timeout + bekleme süreleriolur; çağıranın bütçesiyle karşılaştırılmalıdır. - Bulkhead en içte, TimeLimiter'ın içindedir. TimeLimiter'ın sayacı içteki çağrı bir future döndürdükten sonra başlar: thread pool bulkhead'in kuyruğunda beklenen süre timeout'a dahildir, semaphore bulkhead'in izin için senkron beklediği süre (
maxWaitDuration) dahil değildir. - RateLimiter'da beklenen süre timeout'a dahil değildir; varsayılan 5 saniyelik bekleme dışarıda kalır. Bu da
timeoutDuration'ı 0 yapmak için bir sebeptir.
Retry, circuit breaker açıkken kendiliğinden durmaz. Resilience4j Retry'ın varsayılanı tüm exception'ları retry etmektir (retryExceptionPredicate = throwable -> true). Devre açıkken her deneme anında CallNotPermittedException alır, ama Retry bunu da yeniden dener ve aralarda waitDuration kadar bekler. Thread boşuna bekler, log'lar dolar. CallNotPermittedException ve BulkheadFullException retry konfigürasyonunun ignoreExceptions listesine eklenmelidir. Bunun yanında maxAttempts ilk denemeyi de sayar: varsayılan 3, bir çağrı + iki tekrar demektir.
@TimeLimiter senkron metotlarda çalışmaz. Resilience4j'nin Spring aspect'i @TimeLimiter için dönüş tipinin CompletionStage (veya reaktif bir tip) olmasını ister; aksi halde IllegalReturnTypeException fırlatır. Varsayılan timeoutDuration 1 sn'dir. cancelRunningFuture (varsayılan true) future'ı iptal eder ama CompletableFuture.cancel() alttaki thread'i kesmez; arka plandaki HTTP çağrısı devam edebilir. Senkron RestClient çağrılarında asıl timeout, HTTP istemcisinin connect/read timeout'udur.
q15'teki kargo çağrısı için eksiksiz bir kurulum:
resilience4j:
circuitbreaker:
instances:
shipping:
sliding-window-type: COUNT_BASED
sliding-window-size: 50
minimum-number-of-calls: 20
failure-rate-threshold: 50
slow-call-duration-threshold: 1s # p99 ~ 400 ms; 1 sn üstü "yavaş"
slow-call-rate-threshold: 50
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 5
automatic-transition-from-open-to-half-open-enabled: true
record-exceptions:
- java.io.IOException
- org.springframework.web.client.HttpServerErrorException
- org.springframework.web.client.ResourceAccessException
ignore-exceptions:
- com.example.checkout.shipping.InvalidAddressException
retry:
instances:
shipping:
max-attempts: 2 # 1 çağrı + 1 tekrar
wait-duration: 100ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
enable-randomized-wait: true
randomized-wait-factor: 0.5
retry-exceptions:
- java.io.IOException
- org.springframework.web.client.ResourceAccessException
- org.springframework.web.client.HttpServerErrorException$ServiceUnavailable
ignore-exceptions:
- io.github.resilience4j.circuitbreaker.CallNotPermittedException
- io.github.resilience4j.bulkhead.BulkheadFullException
bulkhead:
instances:
shipping:
max-concurrent-calls: 40
max-wait-duration: 0
@Component
public class ShippingFeeClient {
private final RestClient shippingRestClient;
private final ShippingTariffCache tariffCache;
public ShippingFeeClient(RestClient shippingRestClient, ShippingTariffCache tariffCache) {
this.shippingRestClient = shippingRestClient;
this.tariffCache = tariffCache;
}
// Kargo ücreti sorgusu GET ve idempotent: retry güvenli.
@Retry(name = "shipping")
@CircuitBreaker(name = "shipping", fallbackMethod = "fallbackFee")
@Bulkhead(name = "shipping")
public ShippingFee quote(ShippingQuoteRequest request) {
return shippingRestClient.get()
.uri("/fees?region={r}&desi={d}", request.region(), request.desi())
.retrieve()
.body(ShippingFee.class);
}
// Fallback imzası: aynı parametreler + sonda Throwable.
private ShippingFee fallbackFee(ShippingQuoteRequest request, Throwable cause) {
return tariffCache.lastKnownFee(request.region(), request.desi())
.orElseGet(() -> ShippingFee.flatRate(new BigDecimal("49.90")));
}
}
Fallback burada @CircuitBreaker'a bağlıdır; dış katmandaki Retry'ın denemeleri bittikten sonra değil, her denemenin circuit breaker katmanında çalışır. Fallback'in dönülmesi CB katmanında hatayı yuttuğu için Retry'a başarılı bir sonuç ulaşır ve retry yapılmaz. Bu davranış istenmiyorsa fallback en dış katmana (Retry) bağlanır. Annotation'ları üst üste koymak kolaydır; hangi katmanın hangi exception'ı gördüğünü bilmek, sonucu tahmin edebilmenin tek yoludur.
Load shedding
Bulkhead tek bir bağımlılığı izole eder; load shedding ise servisin kendisini aşırı yükten korur. Kapasitenin üstünde gelen trafiği kabul edip hepsini yavaşça işlemek yerine, fazlasını erken ve ucuza reddeder.
Mantığı: Aşırı yük altında kuyrukta bekleyen istekler zaman aşımına uğrar; istemci çoktan vazgeçmişken sunucu hâlâ onları işler. Throughput yüksek görünür ama kullanıcıya faydalı iş (goodput) sıfıra iner. İsteklerin bir kısmını hemen 503 ile reddetmek, kalanların zamanında tamamlanmasını sağlar.
Teknikler:
- Sınırlı kuyruklar: Sonsuz kuyruk yoktur. Tomcat'in
server.tomcat.threads.maxveserver.tomcat.accept-countdeğerleri, thread havuzu executor'larındakiqueueCapacity, Kafka consumer'damax.poll.records. Kuyruk dolunca hızlıca reddetmek. - Eşzamanlılık limiti: Servise aynı anda giren istek sayısını sınırlamak; limit aşılınca
503. Netflix'in concurrency-limits kütüphanesindeki gibi adaptive limitler, gecikme arttıkça limiti otomatik düşürür. - Zaman aşımına uğramış işi yapmamak: İstek kuyrukta istemcinin deadline'ından uzun beklediyse işlemeden atmak.
- Önceliklendirme: Aşırı yükte önce düşük öncelikli trafiği (öneri widget'ı, analitik, batch) reddetmek; checkout ve ödemeyi en son.
Load shedding ile rate limiting farkı: Rate limiting sabit bir politikadır ("kullanıcı başına saniyede 10 istek"); load shedding servisin o anki gerçek kapasitesine tepki verir.
Health check'ler bağımlılıkları kontrol etmemeli
Kubernetes'te iki temel probe vardır (ayrıntı için bkz. Bölüm 15):
- Liveness: "Bu process kurtarılamaz durumda mı, yeniden başlatılmalı mı?" Fail ederse pod restart edilir.
- Readiness: "Bu pod şu an trafik almalı mı?" Fail ederse pod servis endpoint'lerinden çıkarılır, restart edilmez.
Liveness probe'una veritabanı veya downstream kontrolü koymak, bağımlılık arızasını kendi servisinin çöküşüne çevirir. DB 30 saniyeliğine erişilemez oldu; tüm pod'ların liveness'ı fail eder; Kubernetes hepsini aynı anda restart eder. Pod'lar ayağa kalkarken JVM ısınması, cache'lerin boşalması ve DB'ye aynı anda açılan yüzlerce bağlantı, DB geri geldiğinde ona ikinci bir darbe vurur. Restart DB'yi düzeltmez; process sağlıklıdır, sorun dışarıdadır.
Readiness'ta da dikkatli olunmalıdır: Tüm pod'lar ortak bir bağımlılığı kontrol ediyorsa o bağımlılık çöktüğünde tüm pod'lar aynı anda trafikten çıkar ve kullanıcı 503 yerine bağlantı hatası alır; servis fallback ile kısmen çalışabilecekken tamamen kapanır. AWS Builders' Library'nin sağlık kontrolü yazısı bu yüzden load balancer'ların "hepsi sağlıksızsa hepsine trafik ver" (fail open) davranışını anlatır.
Spring Boot'ta liveness ve readiness grupları varsayılan olarak sadece uygulamanın kendi durumunu (livenessState, readinessState) içerir, DB veya diğer health indicator'ları içermez. Bu bilinçli bir tasarımdır. Bir bağımlılığı readiness'a eklemek istiyorsan (management.endpoint.health.group.readiness.include=readinessState,db), o bağımlılık olmadan servisin hiçbir isteğe faydalı cevap veremediğinden emin olmalısın.
Quiz'deki sorular: çözüm ve nedenleri
q15: Kargo API'si yavaşladı: dayanıklılık kuralları
Senaryo. Checkout servisi kargo ücretini harici bir API'den alıyor. Kampanya günü API bazen 30 saniyeye kadar yavaşlıyor, bazen 503 dönüyor. Ekip Resilience4j ile timeout, retry ve circuit breaker ekleyecek. Hangi ifadeler doğru?
Doğru cevaplar: a, b, d, f.
a) "Retry'lar exponential backoff + jitter ile yapılmalı; aksi halde tüm client'lar aynı anda tekrar dener (thundering herd)." Doğru. Exponential backoff yükü zamanla azaltır, jitter aynı anda hata alan istemcilerin denemelerini zamana yayar. Sadece backoff olsaydı tüm istemciler 100, 200, 400 ms'de senkronize dalgalar halinde gelirdi.
b) "3 katmanlı bir zincirde her katman 3 kez retry ederse en alttaki servis tek kullanıcı isteği için 27 çağrı alabilir." Doğru, bir terim nüansıyla. Retry'lar çarpılır: 3 × 3 × 3 = 27. Bu hesap "her katman toplam 3 deneme yapar" varsayımıyla doğrudur. "3 kez retry" kelimenin tam anlamıyla ilk çağrı + 3 tekrar = 4 deneme demektir ve 4³ = 64 eder. Resilience4j'de maxAttempts=3, ilk çağrıyı da saydığı için toplam 3 denemedir. Sonuç değişmez: katmanlı retry yükü üssel artırır; retry tek katmanda yapılır ve retry budget ile sınırlanır.
c) "Circuit breaker OPEN durumdayken istekler downstream'e gönderilir ama sonuçları göz ardı edilir." Yanlış. OPEN'da istekler hiç gönderilmez; anında CallNotPermittedException veya fallback döner. Göndermeye devam etmek hem thread'leri tutar hem downstream'i toparlanmaktan alıkoyar.
d) "HALF_OPEN durumunda sınırlı sayıda deneme isteğine izin verilir; başarılı olursa CLOSED'a, olmazsa tekrar OPEN'a döner." Doğru. Sayı permittedNumberOfCallsInHalfOpenState ile ayarlanır (varsayılan 10). OPEN'dan HALF_OPEN'a geçiş varsayılan olarak bir sonraki çağrı geldiğinde olur; automaticTransitionFromOpenToHalfOpenEnabled ile zamanlayıcıya bağlanabilir.
e) "POST /payments gibi idempotent olmayan çağrılar idempotency key olmadan güvenle retry edilebilir." Yanlış. Timeout alınan istek sunucuda başarılı olmuş olabilir; retry çift çekim yapar.
f) "Bulkhead, tek bir bağımlılığın yavaşlamasının tüm thread'leri tüketmesini engellemek için o bağımlılığa giden eşzamanlı çağrı sayısını sınırlar." Doğru. Kargo API'si yavaşladığında sadece ona ayrılan kapasite dolar; ödeme ve stok çağrıları etkilenmez.
g) "Timeout'u p99'a göre değil, mümkün olan en uzun süreye (ör. 60 sn) ayarlamak en güvenlisidir." Yanlış. Uzun timeout thread ve connection'ları tutar; Little's law'a göre 400 istek/sn × 60 sn = 24.000 eşzamanlı istek eder ve cascading failure'ı besler.
Arkasındaki kavram ve production karşılığı. Senaryonun en kritik detayı "bazen 30 saniye" ifadesidir. 503'ler circuit breaker'ın hata oranıyla yakalanır; 30 saniyelik cevaplar ise ancak doğru ayarlanmış bir slowCallDurationThreshold ile yakalanır. Resilience4j varsayılanı 60 saniye olduğu için hiçbir ayar yapılmazsa yavaşlık devreyi hiç açmaz. Doğru kurulum: HTTP istemcisinde 500 ms connect + ~1 sn read timeout, sadece idempotent kargo ücreti sorgusunda tek bir jitter'lı retry, hata ve yavaş çağrı oranına göre açılan circuit breaker, 40 eşzamanlı çağrılık bulkhead, son bilinen tarifeye dayanan fallback ve fallback kullanım oranı için alarm.
Quiz'in öğrenme notunda "Retry en dışta durur: her deneme circuit breaker'dan geçer, CB açıksa retry anında vazgeçer" yazıyor. İlk yarısı doğru, ikinci yarısı Resilience4j'nin varsayılan davranışı için doğru değil. Retry varsayılan olarak tüm exception'ları, CallNotPermittedException dahil, yeniden dener. Anında vazgeçmesi için CallNotPermittedException retry'ın ignoreExceptions listesine eklenmelidir. Aynı notta TimeLimiter sarma sırasında yer alıyor ama @TimeLimiter'ın sadece CompletionStage dönen metotlarda çalıştığı belirtilmemiş; senkron çağrıda timeout HTTP istemcisinde ayarlanır.
Üç seviyede cevap
Soru: "Harici bir API'yi senkron çağırıyorsun. Hangi dayanıklılık önlemlerini alırsın?"
seviye: Orta etiketler: [resilience, timeout, circuit-breaker] kaynak: Tahmini
Çağrıyı try-catch ile sararım, hata olursa birkaç kez tekrar denerim. Resilience4j ile @CircuitBreaker ve @Retry eklerim.
Dört şey eklerim: connect ve read timeout, sadece idempotent çağrılarda exponential backoff + jitter ile retry, circuit breaker ve fallback. Timeout'u API'nin p99'una göre seçerim. Circuit breaker hata oranı eşiği aşınca açılır, OPEN'da çağrı göndermez, HALF_OPEN'da birkaç deneme yapar. 4xx hatalarını retry etmem ve circuit breaker'da saymam. Gerekirse bulkhead ile eşzamanlı çağrı sayısını sınırlarım.
Önce bağımlılığı tanırım: gecikme dağılımı (p50/p99/p99.9), hata profili, rate limit'i, çağrının idempotent olup olmadığı ve bu veri olmadan ne kadar iyi cevap verebileceğim. Sonra kaynak hesabı yaparım: Little's law ile normal trafikte kaç eşzamanlı çağrı olacağını ve bağımlılık yavaşladığında ne olacağını.
Kurulum: bağımlılığa özel HTTP istemcisi ve connection pool (kendi bulkhead'i), kısa connect ve p99.9'a dayalı read timeout, toplam süre çağıranın SLA'sı içinde. Retry sadece idempotent çağrıda, tek katmanda, en fazla bir veya iki tekrar, full jitter ile ve retry budget'la sınırlı. Circuit breaker hem hata oranına hem yavaş çağrı oranına göre; slowCallDurationThreshold'u mutlaka ayarlarım çünkü varsayılanı 60 sn. Retry'ın CallNotPermittedException'ı yeniden denememesi için ignore listesine eklerim. Bulkhead, yavaşlıkta diğer endpoint'leri korur. Fallback'i ürünle birlikte belirlerim; ödeme gibi işlerde fail-closed kalırım.
Gözlemlenebilirlik olmadan bunların hiçbirinin değeri yok: circuit breaker durum geçişleri, fallback oranı, bulkhead reddi, timeout sayısı Micrometer metrikleri olarak alarmlı olmalı. Davranışı WireMock gibi bir araçla gecikme ve 503 enjekte ederek test ederim. Senkron çağrı şart değilse (kargo kaydı oluşturmak gibi) çağrıyı tamamen asenkrona, bir kuyruğun arkasına alırım; en iyi dayanıklılık, kritik yolda olmayan bağımlılıktır.
Junior araç isimlerini sayıyor ama timeout'tan hiç bahsetmiyor; oysa timeout'suz bir circuit breaker yavaşlığa karşı geç kalır. Ayrıca ayrım yapmadan retry ediyor. Mid doğru dört deseni ve 4xx ayrımını biliyor. Senior ölçümle başlıyor, Little's law ile kapasite düşünüyor, varsayılan değerlerin tuzaklarını (yavaş çağrı eşiği, CB açıkken retry) biliyor, fallback'i iş kararı olarak ele alıyor, test ve metrik ekliyor ve "bu çağrı senkron olmak zorunda mı" diye sorarak problemi kökten değiştiriyor.
Takip sorusu: Circuit breaker 20 pod'un her birinde ayrı çalışıyor. Bunun sakıncası var mı, ne zaman önemli olur?
Soru: "Retry ne zaman durumu kötüleştirir?"
seviye: İleri etiketler: [retry, backoff] kaynak: Tahmini
Çok fazla retry yaparsak sistem yavaşlar. O yüzden retry sayısını 3 ile sınırlarım.
Üç durumda: idempotent olmayan işlemlerde çift etki yaratır (çift ödeme); aşırı yüklenmiş bir servise retry yükü artırır ve toparlanmasını engeller; birden fazla katman retry ederse denemeler çarpılır. Çözüm olarak idempotency key, exponential backoff + jitter, tek katmanda retry ve circuit breaker kullanırım.
Retry, hatanın sebebi geçici ve yerelse işe yarar; sebep aşırı yükse yangına benzin döker. Kötüleştirdiği durumlar:
- Aşırı yük: Bağımlılık kapasitesinin üstündeyken her başarısız istek 2-3 isteğe dönüşür, toparlanma imkânsızlaşır (retry storm). Bunu retry budget ile sınırlarım: retry trafiği toplamın küçük bir yüzdesini geçemez.
- Katmanlar: Üst üste retry yapan 4 katmanın her biri 3 deneme yaparsa en alttaki servis 3⁴ = 81 çağrı görür. Retry'ı tek katmanda yaparım, diğer katmanlar hızlıca hata iletir.
- Zaman bütçesi: Kullanıcı 2 saniyede vazgeçerken 3 × 2 saniyelik denemeler kimsenin beklemediği iş üretir. Deadline'ı katmanlar arasında taşırım, kalan süre yetmiyorsa denemem.
- Idempotency: Timeout'tan sonra POST retry'ı çift etki üretir; idempotency key olmadan yapmam.
- Kalıcı hatalar: 400 veya iş kuralı hatası tekrar denenince düzelmez; sadece yük ve gecikme ekler.
- Senkronize dalgalar: Jitter yoksa binlerce istemci aynı anda tekrar dener.
Retry'ın etkisini ölçerim: retry oranı, ikinci denemede başarı oranı. İkinci denemede başarı oranı düşükse o retry hiçbir işe yaramıyor, sadece yük ekliyor demektir.
Junior retry'ın tehlikesini "yavaşlık" olarak görüyor ve sayıyı sınırlamayı çözüm sanıyor; 3 deneme de katmanlarda çarpılır. Mid üç temel riski biliyor. Senior retry'ın aşırı yükte neden zararlı olduğunu mekanizmasıyla anlatıyor, retry budget ve deadline propagation gibi ileri çözümleri öneriyor ve retry'ın faydasını ölçülebilir bir metrikle sorguluyor.
Takip sorusu: Retry budget'ı somut olarak nasıl uygularsın? Hangi metriğe göre?
Soru: "Circuit breaker nasıl çalışır, değerlerini nasıl seçersin?"
seviye: İleri etiketler: [circuit-breaker, resilience4j] kaynak: Tahmini
Circuit breaker hata sayısı artınca açılır ve istekleri engeller, bir süre sonra tekrar kapanır. Değerleri genelde varsayılan bırakırım.
Üç durumu var. CLOSED'da çağrılar geçer ve sonuçlar sliding window'da tutulur. Hata oranı eşiği aşılırsa OPEN olur ve çağrılar gönderilmeden CallNotPermittedException ile reddedilir. Bekleme süresi dolunca HALF_OPEN'a geçer, birkaç deneme çağrısına izin verir; başarılıysa CLOSED, değilse yine OPEN. Değerler: sliding window 50-100 çağrı, hata eşiği %50, OPEN süresi birkaç saniye ile birkaç on saniye arası. Yavaş çağrı eşiğini de ayarlarım.
Mekanizmayı mid'in anlattığı gibi kurarım; değerleri ise ölçümden türetirim ve varsayılanlara güvenmem, çünkü Resilience4j varsayılanları (minimum 100 çağrı, yavaş çağrı eşiği 60 sn ve %100, OPEN süresi 60 sn) çoğu senaryoya uymaz.
- Sliding window ve minimum çağrı: Pod başına trafiğe göre seçerim. Pod başına saniyede 5 çağrı varsa 100 çağrılık pencere 20 saniyelik veri demektir; arıza tespiti çok geç kalır. Düşük trafikte
TIME_BASEDpencere daha öngörülebilir. - Yavaş çağrı:
slowCallDurationThresholdbağımlılığın p99'unun biraz üstü; oranı %50 civarı. Yavaşlık hatadan daha tehlikeli olduğu için en önemli ayar bu. - Hangi hatalar: Sadece bağımlılığın sağlıksızlığını gösteren hatalar (IO, 5xx, timeout) sayılır; 4xx ve iş hataları ignore edilir.
- OPEN süresi: Bağımlılığın tipik toparlanma süresine göre; çok kısa olursa toparlanmayı engeller, çok uzun olursa gereksiz yere degrade kalırım.
- HALF_OPEN: Az sayıda deneme çağrısı, ki toparlanan servise ani yük binmesin.
Yeni bir circuit breaker'ı önce METRICS_ONLY ile çalıştırıp gerçek trafikte eşiklerin ne zaman tetikleneceğini görürüm. Durum geçişlerini metrik ve alarm olarak izlerim. Circuit breaker pod başına çalıştığı için pod'lar farklı zamanlarda açılır; bu genelde sorun değil, hatta yükü kademeli keser. Circuit breaker'ı neyin yerine koymadığımı da bilirim: timeout'suz bir circuit breaker, pencere dolana kadar thread'lerin 30 saniye beklemesini engelleyemez.
Junior "varsayılanı bırakırım" diyor; q15 senaryosunda bu, devrenin hiç açılmaması demek. Mid mekanizmayı doğru anlatıyor ve makul değerler veriyor. Senior değerleri trafik ve gecikme ölçümünden türetiyor, varsayılanların somut tuzaklarını sayıyor, hangi hatanın sayılacağını ayırıyor, METRICS_ONLY ile güvenli devreye alma ve gözlemlenebilirlik ekliyor.
Takip sorusu: Pod başına saniyede 2 çağrı alan düşük trafikli bir bağımlılık için sliding window'u nasıl kurarsın?
Soru: "Liveness ve readiness probe'larına veritabanı kontrolü koymalı mıyız?"
seviye: Orta etiketler: [kubernetes, health-check] kaynak: Tahmini
Evet, servis DB olmadan çalışamıyorsa liveness probe'u DB'yi kontrol etmeli ki DB yoksa pod yeniden başlasın.
Liveness'a koymam; DB çökünce tüm pod'lar restart olur ve restart DB'yi düzeltmez. Liveness sadece process'in kendisinin takılıp takılmadığını kontrol etmeli. Readiness'a, servis DB olmadan hiç çalışamıyorsa koyabilirim; o zaman pod trafikten çıkar ama restart olmaz.
Liveness'a asla bağımlılık koymam: ortak bir bağımlılık çöktüğünde tüm pod'ları aynı anda restart ettirir. Pod'lar ayağa kalkarken soğuk JVM, boş cache'ler ve aynı anda açılan bağlantı havuzları, DB geri döndüğünde ona ikinci bir yük dalgası olur. Liveness sadece "bu process kendini kurtaramaz mı" sorusunu cevaplar: deadlock, tükenmiş heap gibi.
Readiness'ta da dikkatliyim. Tüm pod'lar aynı DB'yi kontrol ediyorsa DB arızasında tüm pod'lar aynı anda endpoint'ten çıkar; istemci net bir 503 ve anlamlı bir hata mesajı yerine bağlantı hatası alır, okuma yapan ve cache'ten cevap verebilecek endpoint'ler de kapanır. Readiness'ı genelde başlangıç (warm-up bitti mi) ve kapanış (graceful shutdown başladı mı) için kullanırım; bağımlılık arızasını circuit breaker ve fallback ile istek seviyesinde yönetirim. Spring Boot'un probe grupları zaten varsayılan olarak sadece uygulamanın kendi durumunu içerir; bunu değiştirmek için iyi bir gerekçe isterim.
Bağımlılıkların sağlığını ayrıca izlerim: /actuator/health altındaki detaylı göstergeler ve metrikler alarm üretir, ama orkestratörün otomatik aksiyonlarına bağlanmaz.
Junior en yaygın ve en tehlikeli hatayı öneriyor: liveness'ı bağımlılığa bağlamak. Mid liveness/readiness ayrımını doğru yapıyor. Senior readiness'ın da tüm filoyu aynı anda kapatabileceğini görüyor, restart'ın DB'ye ikinci dalga yük getirmesini mekanizmasıyla anlatıyor ve bağımlılık arızasını probe yerine istek seviyesinde yönetmeyi öneriyor.
Takip sorusu: Uygulama açılışta 40 saniye cache ısıtıyor. Bu süre boyunca probe'ları nasıl yapılandırırsın?
Sorulmadı ama sorulabilir
Deadline propagation
Her katmanın kendi bağımsız timeout'u olduğunda kullanıcının vazgeçtiği bir istek alt katmanlarda işlenmeye devam eder. Deadline propagation'da en üst katman bir son tarih belirler (ör. "bu istek 10:00:02.000'ye kadar tamamlanmalı") ve bunu alt çağrılara (header veya gRPC metadata ile) taşır. Her katman kalan süreyi hesaplar, kendi timeout'unu buna göre kısaltır ve süre dolduysa işi hiç başlatmaz. gRPC bunu deadline kavramıyla yerleşik olarak sunar. Google SRE kitabının cascading failure bölümü bunu temel önlemlerden biri sayar.
Mülakat özeti: "Timeout'ları bağımsız değil, kalan süre bütçesi olarak katmanlar arası taşırım; süresi dolmuş işi hiç başlatmam."
Tail latency ve hedged request
Bir istek 50 alt çağrıya dağılıyorsa (fan-out), her birinin p99'u bile toplam isteğin tipik gecikmesine dönüşür: 50 çağrıdan birinin yavaş gelmesi neredeyse kesindir. Hedged request, bir çağrı p95 süresini geçtiyse aynı isteği başka bir replikaya da gönderip ilk gelen cevabı kullanmaktır. Ek yükü küçüktür (sadece yavaş kuyruktaki %5 için) ama sadece idempotent okuma işlemlerinde kullanılabilir. Yük altında hedging de yük artırdığı için bir bütçe ile sınırlanır.
Mülakat özeti: "Fan-out'ta tail latency belirleyicidir; idempotent okumalarda hedged request ile kuyruğu kısaltırım."
Virtual thread'ler bulkhead'i gereksiz kılar mı?
Java 21 virtual thread'leri (Spring Boot 3.2+ spring.threads.virtual.enabled=true) bloklayan I/O sırasında platform thread'i bırakır; Tomcat'in 200 thread sınırı pratikte ortadan kalkar. Little's law hesabındaki "thread havuzu tükenmesi" bu yüzden çok daha geç gelir. Ama problem kaybolmaz, yer değiştirir: 12.000 eşzamanlı bekleyen istek, 12.000 açık HTTP bağlantısı, bellek ve downstream'e 12.000 eşzamanlı yük demektir. Bağlantı havuzları ve DB connection'ları yine sınırlıdır. Bu yüzden virtual thread'lerle bulkhead (semafor tabanlı eşzamanlılık limiti) daha da önemli hale gelir; sınır artık thread havuzu tarafından örtük olarak konmaz.
Mülakat özeti: "Virtual thread'ler thread tükenmesini ortadan kaldırır ama eşzamanlılığı sınırsız yapar; sınırı bulkhead ile ben koyarım."
Spring Framework 7'nin yerleşik dayanıklılık özellikleri
Spring Framework 7.0 (Spring Boot 4) çekirdeğe @Retryable ve @ConcurrencyLimit (org.springframework.resilience.annotation paketi) ile programatik RetryTemplate (org.springframework.core.retry) ekledi; @EnableResilientMethods ile etkinleştirilir. @Retryable varsayılan olarak her exception'da ilk denemeden sonra en fazla 3 kez, 1 saniye arayla tekrar dener; delay, multiplier, jitter, maxDelay ve includes ile ayarlanır. @ConcurrencyLimit metot seviyesinde bir eşzamanlılık sınırıdır, yani basit bir bulkhead. Circuit breaker içermez; onun için Resilience4j veya Spring Cloud Circuit Breaker gerekir.
Mülakat özeti: "Spring 7 basit retry ve eşzamanlılık limiti için ek bağımlılık gerektirmez; circuit breaker, rate limiter ve metrikli zengin kurulum için Resilience4j kullanırım."
Dayanıklılığı test etmek
Dayanıklılık kodu, sadece arıza anında çalışan koddur; test edilmezse ilk kesintide bozuk çıkar.
- Entegrasyon testi: WireMock ile bağımlılığa gecikme (
withFixedDelay) ve 503 enjekte edip timeout, circuit breaker'ın açılması ve fallback'in döndüğünü doğrulamak. Toxiproxy ile ağ seviyesinde gecikme ve bağlantı kesintisi. - Yük testi: Bağımlılık yavaşken servisin diğer endpoint'lerinin p99'unun bozulmadığını göstermek (bulkhead'in kanıtı).
- Chaos engineering: Production benzeri ortamda kontrollü arıza (bir bağımlılığı yavaşlatmak, bir pod'u öldürmek) ve sistemin beklenen şekilde degrade olduğunu gözlemek.
Mülakat özeti: "Dayanıklılık davranışını gecikme ve hata enjekte ederek test ederim; çalıştığını görmediğim bir fallback'e güvenmem."
Kuyruk tabanlı asenkron entegrasyon
Senkron dayanıklılık desenleri, yavaş bir bağımlılığın etkisini sınırlar ama ortadan kaldırmaz. İş kullanıcının cevabı için şart değilse (kargo kaydı, fatura, bildirim) çağrı kritik yoldan çıkarılır: istek outbox veya Kafka üzerinden kuyruğa yazılır, ayrı bir worker kendi hızında, retry ve circuit breaker ile işler. Bağımlılık saatlerce çökse bile kullanıcı akışı etkilenmez; kuyruk tampon görevi görür. Bedeli nihai tutarlılık ve "işlem sürüyor" durumlarının UI'da gösterilmesidir (bkz. Bölüm 12 ve 13).
Mülakat özeti: "En sağlam bağımlılık, kritik yolda olmayandır; cevap için şart olmayan çağrıları kuyruğun arkasına alırım."
Sık yapılan hatalar
- HTTP istemcisinde timeout ayarlamamak → Varsayılanlar çoğu zaman sonsuzdur, thread'ler sonsuza kadar bekler → Bağımlılığa özel connect ve read timeout.
- Timeout'u "güvenli olsun" diye çok uzun seçmek → Kaynakları uzun süre tutar, cascading failure'ı besler → p99.9 + pay, çağıranın SLA'sı içinde.
- Pool acquire timeout'u unutmak → HikariCP varsayılanı 30 sn; thread havuz kuyruğunda bekler → Kısa acquire timeout ve havuz metriği.
- Idempotent olmayan çağrıyı retry etmek → Timeout'ta başarılı olmuş istek tekrar edilir, çift etki → Idempotency key veya retry yok.
- Her katmanda retry → Denemeler çarpılır (3 katman × 3 deneme = 27) → Tek katmanda retry, retry budget.
- Jitter'sız backoff → İstemciler senkronize dalgalarla gelir → Full jitter.
- 4xx'i retry etmek veya circuit breaker'da saymak → Kalıcı hata düzelmez; hatalı istemci herkes için devreyi açar → Sadece 5xx, IO ve timeout.
- Resilience4j varsayılanlarıyla circuit breaker → Yavaş çağrı eşiği 60 sn, minimum 100 çağrı; yavaşlıkta devre açılmaz → Değerleri ölçümden türet.
- Retry'ın
CallNotPermittedException'ı yeniden denemesi → Devre açıkken boşuna bekleme →ignoreExceptions'a ekle. - Senkron metoda
@TimeLimiterkoymak →IllegalReturnTypeExceptionveya beklenmeyen davranış → HTTP istemcisinde timeout;@TimeLimitersadeceCompletionStageiçin. - RateLimiter'ı varsayılan 5 sn beklemeyle bırakmak → İzin bekleyen thread'ler bloklanır →
timeoutDuration: 0. - Tüm dış çağrılar için tek bir paylaşılan connection pool → Bir bağımlılığın yavaşlığı diğerlerini kilitler → Bağımlılık başına pool ve bulkhead.
- Ödeme veya yetkilendirmede "onayla" fallback'i → Güvenlik ve finansal risk → Fail-closed.
- Liveness probe'unda DB veya downstream kontrolü → Bağımlılık arızası tüm pod'ları restart ettirir → Liveness sadece process'i kontrol eder.
Hızlı tekrar
S: Little's law nedir, thread havuzu tükenmesini nasıl açıklar?
C: L = λ × W. Saniyede 400 istek ve 30 sn bekleme, 12.000 eşzamanlı istek ister; 200'lük havuz yarım saniyede dolar.
S: Neden yavaşlık hatadan daha tehlikelidir?
C: Hızlı hata kaynağı hemen bırakır; yavaş cevap thread ve connection'ı tutar ve havuzları tüketerek ilgisiz işlevlere yayılır.
S: Read timeout toplam süreyi sınırlar mı?
C: Çoğu istemcide hayır; iki veri paketi arasındaki bekleme süresidir. Toplam süre için ayrı bir deadline gerekir.
S: Hangi hatalar retry edilir?
C: Bağlantı kurulamaması, 502/503/504, 429 (Retry-After ile) ve idempotent işlemde timeout. 4xx ve iş kuralı hataları edilmez.
S: Full jitter formülü nedir?
C: sleep = random(0, min(cap, base * 2^attempt)).
S: Üç katmanın her biri toplam 3 deneme yaparsa en alttaki servis kaç çağrı görür?
C: 3³ = 27. "3 tekrar" (4 deneme) olursa 4³ = 64.
S: Circuit breaker OPEN'da ne yapar?
C: Çağrıyı downstream'e hiç göndermez; anında CallNotPermittedException veya fallback döner.
S: Resilience4j'de yavaşlık neden varsayılan ayarlarla devreyi açmaz?
C: slowCallDurationThreshold varsayılanı 60 sn, slowCallRateThreshold %100'dür; 30 sn'lik çağrılar yavaş sayılmaz.
S: Resilience4j aspect'lerinin varsayılan sırası nedir?
C: Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( Function ) ) ) ) ); Retry en dışta.
S: Resilience4j Retry circuit breaker açıkken neden denemeye devam eder?
C: Varsayılan olarak tüm exception'ları retry eder; CallNotPermittedException ignoreExceptions'a eklenmelidir.
S: Semaphore bulkhead ile thread pool bulkhead farkı nedir?
C: Semaphore çağıran thread'de eşzamanlı sayıyı sınırlar; thread pool çağrıyı ayrı, sınırlı bir havuz ve kuyruğa verir ve asenkron döner.
S: Load shedding'in rate limiting'den farkı nedir?
C: Rate limiting sabit bir politika uygular; load shedding servisin o anki kapasitesine göre fazla trafiği erken ve ucuza reddeder.
S: Liveness probe'u neden bağımlılıkları kontrol etmemeli?
C: Ortak bağımlılık çöktüğünde tüm pod'lar aynı anda restart olur; restart dışarıdaki sorunu düzeltmez, toparlanan bağımlılığa ikinci bir yük dalgası vurur.
S: Virtual thread'ler bulkhead ihtiyacını ortadan kaldırır mı?
C: Hayır; thread tükenmesini kaldırır ama eşzamanlılığı sınırsız yapar. Downstream ve bağlantı havuzlarını korumak için sınırı bulkhead koyar.
Kaynaklar
- Timeouts, retries, and backoff with jitter (Marc Brooker, Amazon Builders' Library): Retry'ların katmanlarda çarpılması, token bucket ile sınırlama ve jitter; konunun bir numaralı kaynağı.
- Exponential Backoff And Jitter (AWS Architecture Blog): Full, equal ve decorrelated jitter'ın simülasyonla karşılaştırması.
- Using load shedding to avoid overload (Amazon Builders' Library): Goodput kavramı ve aşırı yükte neden erken reddetmek gerektiği.
- Implementing health checks (Amazon Builders' Library): Yüzeysel ve derin kontroller, bağımlılık kontrollerinin riski ve fail-open.
- Addressing Cascading Failures (Google SRE Book): Kaynak tükenmesi, retry, deadline propagation ve load shedding'in sistematik anlatımı.
- Resilience4j: CircuitBreaker: Durumlar, sliding window ve tüm ayarların varsayılanları.
- Resilience4j: Getting Started with Spring Boot 3: Aspect sırası ve değiştirme yolları.
- Resilience4j: Retry ve Bulkhead:
maxAttempts'ın ilk çağrıyı sayması, varsayılan retry predicate'i ve iki bulkhead türü. - Spring Framework: Resilience Features: Spring 7'deki
@Retryable,@ConcurrencyLimitveRetryTemplate. - Release It!, 2. baskı (Michael Nygard, Pragmatic Bookshelf): Circuit breaker ve bulkhead desenlerini popülerleştiren, gerçek kesinti hikâyeleriyle dolu kitap.