Failure Modes: Çökme Modları
09:00:00 — piyasa açıldı. 09:00:02 — Redis CPU %100. 09:03 — gateway’lerin üçü ölü. Kimse deploy yapmadı, kod değişmedi, trafik beklenen aralıkta. Sistem kendi kendini öldürdü.
- Yavaşlama, çökmekten daha tehlikelidir. Çöken düğüm listeden çıkar; yavaşlayan düğüm herkesi yavaşlatır.
- Aynı anda başlayan her şey thundering herd üretir. Cron da, TTL de, yeniden başlatma da. Cevap yine jitter.
- Tetikleyici geçse de sistem kötü durumda kalabilir. Buna metastable failure denir; retry ve stampede döngüleri kendini besler. Tetikleyiciyi kaldırmak yetmez, döngüyü kıran bir sınır gerekir.
- Timeout olmadan circuit breaker çalışmaz. Beş savunma, sırayla: timeout → sınırlı kuyruk → load shedding → bulkhead → breaker; retry en dışta ve yalnızca bir katmanda. Her biri bir öncekine yaslanır.
- Sağlıklı görünen düğüm en pahalı arızadır. Gray failure’da süreç ayakta, health check yeşil, iş yapılmıyor. Sağlığı mutlak değil göreli ölç: yavaş çağrıyı hata say, düğümü komşularıyla kıyasla.
- Failover veri kaybeder. Asenkron çoğaltmada son saniyelerin write’ları gider; kritik durumun tek kopyası önbellekte olmamalı, her emrin istemci tarafında üretilmiş bir kimliği olmalı.
1. Thundering herd (çığ etkisi): herkes aynı anda
Piyasa 09:00’da açılıyor. O saniyede olan şeyler:
- Bütün gateway’ler sembol listesini Redis’ten çekiyor.
- Bütün köprüler sağlayıcılara bağlanıyor.
- Bütün zamanlanmış işler
0 0 9 * * *ifadesiyle tetikleniyor. (Altı alanlı Spring biçimi; Quartz’ta aynı iş0 0 9 * * ?olur, yıldızlı hâli orada geçersiz.) - Gece boyunca duran istemciler yeniden bağlanıyor.
Hiçbiri tek başına ağır değil. Ama hepsi aynı saniyede. Sistem günün geri kalanında rahat rahat kaldırdığı yükü, o bir saniyede kaldıramıyor.
- Cron ifadeleri. Herkes
:00’ı seviyor. - Aynı anda konan TTL. Açılışta 400 anahtarı 300 saniyelik süreyle yazdıysan, 400’ü de aynı saniyede ölür.
- Toplu yeniden başlatma. Deploy sonrası bütün pod’lar aynı anda ayağa kalkıp aynı anda önbelleklerini doldurur.
- Jitter’sız tekrar denemeler. Önceki yazıda anlatılan dalga etkisi. Jitter tek başına yetmez: 200 ms timeout + 3 deneme, zorlanan bağımlılığa gelen yükü üçe katlar. Modern cevap jitter + retry budget: toplam isteğin %10’undan fazlası retry olamaz; bütçe dolunca retry yapılmaz, hata döner. Bu olmadan timeout’lar cascade’i yavaşlatmaz, hızlandırır.
// 1) Zamanlanmis is: sabit saniye yerine 0-30 sn kaydir
long kaydir = ThreadLocalRandom.current().nextLong(30_000);
long gecikme = acilisZamaniMs - System.currentTimeMillis() + kaydir;
zamanlayici.schedule(is, gecikme, TimeUnit.MILLISECONDS); // ScheduledExecutorService: delay + birim
// 2) TTL: sabit degil, +-%20 sapma ile
int ttl = 300;
int sapma = ThreadLocalRandom.current().nextInt(-60, 61);
redis.setex(anahtar, ttl + sapma, deger);
// 3) Acilista kademeli baslatma: pod index'ine gore bekle, ama ust sinirli
int sira = podIndexiniOku(); // StatefulSet ordinal; Deployment'ta ordinal YOK
long bekle = Math.min(sira * 500L, 15_000L) // 120 pod'da son pod 60 sn beklemesin
+ ThreadLocalRandom.current().nextLong(500); // + jitter
Thread.sleep(bekle);
Üçüncüsünde iki tuzak var. sira * 500’ü sınırsız bırakırsan 120 gateway’de son
pod 60 saniye bekler; üst sınır + jitter gerekir. Ordinal yalnızca StatefulSet’te var;
Deployment’ta pod adının hash’ini kullanırsın ve hash düzgün dağılmayabilir — 20 pod’un
8’i aynı dilime düşebilir. Bu yüzden jitter hash’li sürümde süs değil, zorunluluk. Bir de bu
bekleme açılışı 15 saniyeye kadar geciktiriyor: 5. bölümdeki startupProbe
(failureThreshold × periodSeconds) bu 15 saniyeyi de kapsamalı, yoksa liveness kendi
koyduğun beklemeyi “açılamadı” sayıp pod’u öldürür.
Üçünün toplam maliyeti on satır. Bizde açılış anındaki Redis tepe kullanımı %100’den %40’a düştü; hiçbir şeyin kapasitesini artırmadan.
Yukarıdaki üç ilaç listedeki dört kaynağın üçünü çözüyor: cron, TTL, toplu restart. Köprülerin sağlayıcılara bağlanması da senin kodun, aynı kademeli açılışa girer. Ama gece duran istemcilerin yeniden bağlanması senin kodun değil; 09:00:00’da bütün terminallerin aynı saniyede TLS handshake + login + abonelik göndermesini istemci tarafından düzeltemezsin. Sunucu tarafı cevabı var ve dört parça:
- Accept rate limit. Yeni bağlantıyı saniyede N ile kabul et (yük dengeleyicide ya da uygulamada); fazlası kuyrukta değil, reddedilmiş olsun. Bağlantıyı kabul edip 20 saniye bekletmek istemciye timeout, sana yarım kalmış handshake demek.
- Jitter’lı
Retry-Afterile reddet. 3. bölümdeki load shedding’in bağlantı düzeyindeki hâli: “3–8 saniye sonra gel” diyen bir red, tek saniyeye yığılan bağlantıyı 5 saniyeye yayar. - Bağlantı başına kademeli iş. Handshake’i kabul et, login’i ve ilk aboneliği ayrı ayrı sınırla; en pahalı adım (sembol listesini yollamak) en son ve en sıkı sınırlı olsun.
- IP / hesap başına limit. Reconnect döngüsüne girmiş tek bir istemcinin bütün accept bütçesini yemesini önler.
İstemci SDK’sını sen dağıtıyorsan bir beşincisi: jitter’lı exponential backoff’u SDK’ya göm, “bağlantı kopunca hemen tekrar dene” davranışını istemciye bırakma. Sabit 1 saniye bekleyen bir SDK, her kopuşta bütün terminalleri aynı saniyede kapıya getirir.
Toplu yeniden başlatmanın öbür yarısı pod’ların kapanışı. Deploy’da pod SIGTERM alıp hemen çıkarsa iki şey olur: elindeki in-flight istekler yarım kalır ve yük dengeleyici birkaç saniye daha ona istek yollamaya devam eder, çünkü endpoint listesinden düşmesi anlık değil. O istekler hata alır, istemci retry yapar, retry kalan pod’lara biner. Aynı çığ, bu kez kapanış tarafında; ve her deploy’da.
# Deployment
lifecycle:
preStop:
exec: { command: ["sh", "-c", "sleep 5"] } # endpoint listesinden dusmeyi bekle, SIGTERM ondan sonra gelir
terminationGracePeriodSeconds: 30 # preStop (5) + in-flight islerin bitmesi + kapanis; kucukse SIGKILL
# Uygulama (Spring): SIGTERM'de yeni istek alma, eldekini bitir, sonra cik
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=20s
Sıra: önce listeden düş (preStop beklemesi ya da readiness’i 503’e çekmek), sonra yeni bağlantı
alma, sonra eldekini bitir, en son kapan. terminationGracePeriodSeconds bu toplamdan
küçükse kubelet süreci SIGKILL ile keser ve bütün özen boşa gider.
2. Cache stampede: aynı anda 120 kez hesaplanan spread
Çığ etkisinin (thundering herd) özel ve çok yaygın bir hali. symbol:EURUSD:spread
anahtarının süresi doluyor. Tam o anda 120 gateway aynı anda okuyor, 120’si de
“yok” cevabı alıyor ve 120’si birden hesaplamaya
başlıyor.
Dikkat: 1. bölümdeki TTL sapması burada işe yaramaz. Sapma, süre dolumunu anahtarlar arasında dağıtır; aynı anahtarın 120 okuyucusu arasında değil. 400 anahtarın aynı saniyede ölmesini önler, tek bir sıcak anahtarın 120 kez hesaplanmasını önlemez. Onun için aşağıdaki üç çözümden biri gerekir.
Hesaplama pahalı değil — ama 120 katı pahalı. Ve hesap bitene kadar süre yine dolmuş oluyor, döngü kendini besliyor.
Dağıtık kilide atlamadan önce daha ucuz bir katman var: süreç içi tekilleştirme (single-flight).
120 gateway’in her birinde onlarca thread aynı anahtarı istiyor; önce süreç içinde “bu anahtar için
zaten bir hesap yolda, ona katıl” deyince Redis’e giden istek 120 × N’den 120’ye iner.
Java’da Caffeine / Guava LoadingCache bunu kendiliğinden yapar; Go’da
singleflight. Caffeine’in bir adım ötesi var: refreshAfterWrite. Süre dolunca
okuyanı bekletmek yerine eski değeri hemen döner ve arkada tek bir thread ile yeniler; aşağıdaki
b ve c’nin süreç içi birleşimi, sıfır koordinasyonla. Kalan 120 için de aşağıdaki üç çözümden biri.
Üç çözüm
deger = redis.get(anahtar);
if (deger != null) return deger;
String ben = UUID.randomUUID().toString(); // kilidin sahibi kim?
// Sadece bir tanesi kilidi alsin (Jedis 4+: SetParams; donus "OK" ya da null)
String sonuc = redis.set(anahtar + ":kilit", ben, SetParams.setParams().nx().px(5000));
if ("OK".equals(sonuc)) {
try {
deger = pahaliHesapla();
redis.setex(anahtar, ttlSapmali(), deger);
redis.set(anahtar + ":eski", deger); // TTL'siz: digerlerinin okuyacagi eski deger
return deger;
} finally {
// Kilidi SADECE sahibi silsin: hesap 5 sn'yi astiysa kilit dusmus, baskasi almis olabilir
redis.eval(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) end return 0",
List.of(anahtar + ":kilit"), List.of(ben));
}
} else {
// Digerleri: eski degeri kullan
deger = redis.get(anahtar + ":eski");
if (deger != null) return deger;
// Soguk baslangic: eski deger de yok. Kisa bekle, ana anahtari tekrar dene.
Thread.sleep(50);
return redis.get(anahtar); // hala null ise cagiran karar verir: bekle ya da hata
}
Çalışıyor ama dikkat isteyen üç yer var. Eski değer anahtarını yazmayı unutma: bu yazının
ilk hâlinde :eski yalnızca okunuyordu, hiçbir yerde yazılmıyordu; o kodla soğuk başlangıçta
119 istemcinin hepsi null alırdı. Kilidi sahibi silsin: hesap 5 saniyeyi aşarsa kilit
kendiliğinden düşer, başka biri alır ve sen düz del ile onun kilidini silersin;
get == ben ise del Lua script’i bu yüzden var, atomik olması şart. finally
şart: hesap exception atarsa kilit 5 saniye boyunca herkesi eski değere mahkûm eder. Bir de
“eski değer ne kadar eski olabilir” sorusunu cevaplaman gerekiyor; spread için 5 saniye
kabul edilebilir, bakiye için değil.
:eski’nin TTL’siz olması kasıtlı ama bedava değil: 400 sembolde 400 kalıcı anahtar.
allkeys-lru’da bellek sıkışınca atılır, volatile-*’ta hiç atılmaz ve
sembol listesi büyüdükçe sonsuza kadar birikir. Sembol seti sabitse sorun yok; anahtar uzayı
büyüyorsa :eski’ye ana TTL’nin 10 katı gibi uzun ama sonlu bir TTL ver.
“Hesap 5 saniyeyi aşarsa” sorununun standart cevabı yukarıda yok: kilit uzatma.
Sahip hesap sürerken her 1–2 saniyede bir kilidin süresini tazeler (get == ben ise PEXPIRE,
yine Lua, yine atomik); süreç ölürse tazeleme durur ve kilit kendiliğinden düşer. Redisson bunu
watchdog adıyla kendiliğinden yapar: süre vermediğin kilidi 30 saniyeyle açar ve sahibi
yaşadığı sürece her 10 saniyede süreyi yeniden 30’a çeker. Kendin yazıyorsan aynı oran: uzatma
aralığı TTL’nin üçte biri. Bir GC duraklaması tazelemeyi kaçırırsa kilit düşer; sahibi geç uyanıp
başkasının kilidini silmesin diye Lua zaten var.
Bir de bu kilidin nerede durduğuna bak: Redis’in kendisinde. 6. bölüm o Redis’in failover’da son 1–2 saniyenin write’larını kaybettiğini anlatıyor; kilit de bir write. Yani failover anında kilit yeni master’da yoktur, ikinci bir düğüm de alır ve iki hesap aynı anda koşar. Cache stampede için sonuç zararsız: en kötü ihtimalle spread iki kez hesaplanır, ikisi de doğrudur. Ama bu deseni olduğu gibi bakiye düşmeye ya da emir göndermeye taşırsan aynı pencere iki kez para çekmek demektir. Oraya bu kilit değil, fencing token’lı kilit gider.
// Deger ile birlikte "hesaplamasi ne kadar surdu" ve "ne zaman doluyor" saklanir.
// Sure dolmaya yaklastikca YENILEME OLASILIGI artar.
Kayit k = oku(anahtar); // deger + hesapSuresi + bitis birlikte saklanir
// DIKKAT: kalan, hesapSuresi ve esik AYNI birimde (ms) olmali; biri saniye olursa formul sessizce bozulur
if (k == null) return hesaplaVeKaydet(anahtar).deger; // ilk okuma: henuz deger yok (Kayit degil, deger doner)
long kalan = k.bitis - simdi();
double u = 1.0 - ThreadLocalRandom.current().nextDouble(); // (0, 1]: log(0) = -sonsuz tuzagi yok
double esik = -k.hesapSuresi * BETA * Math.log(u); // BETA ~ 1.0
if (kalan < esik) {
k = hesaplaVeKaydet(anahtar); // erken, KENDILIGINDEN, tek basina
}
return k.deger; // yenilemeyen istemci de gecerli degeri doner
Güzelliği şurada: kilit yok, koordinasyon yok. İstemcilerin yalnızca birkaçı süre dolmadan önce yeniliyor, geri kalanı geçerli değeri okuyor. Süre hiçbir zaman “herkes için aynı anda” dolmuyor. Ve hesaplama ne kadar pahalıysa yenileme o kadar erken başlıyor — formül bunu kendiliğinden ayarlıyor.
Anahtar hiç ölmez; ayrı bir iş her 30 saniyede bir değeri yeniden hesaplayıp üzerine yazar. Okuyanlar hep bir değer bulur. Bedeli: değer, tazeleyici çökerse sessizce eskir. O yüzden değerin yanına hesaplanma zamanını da yaz ve okurken yaşına bak.
Üç çözümün ortak varsayımı: anahtar önbellekte duruyor. Redis bellek baskısına girip
maxmemory eviction ile anahtarı attığında TTL sapması da, olasılıksal yenileme de, arka plan
tazeleme de boşa düşer; anahtar süre dolmadan yok olur ve doğrudan stampede’e düşersin. Eviction
politikası burada ters köşe yapar: allkeys-lru sıcak anahtarı da atabilir;
volatile-lru ise yalnızca TTL’i olan anahtarları aday sayar — yani 2a ve 2b’nin
anahtarlarını, tam olarak onları. Bu politikada korunan tek şey 2c’nin TTL’siz anahtarı.
noeviction üçüncü yol: anahtar atılmaz ama bellek dolunca her write OOM hatası döner;
stampede yerine yazma kesintisi alırsın, o da :eski’yi bile yazamamak demek. Pratik cevap
kritik anahtarlara ayrı bir instance, ya da 2c + volatile-*. Her durumda
used_memory’yi maxmemory’nin %80’inde alarma bağla;
evicted_keys sayacı sıfırdan ayrıldığı an stampede kapıda.
Ve tazeleyicinin tek sahibi olsun. 120 gateway’in her biri kendi 30 saniyelik zamanlayıcısıyla tazelerse stampede’i başka bir isimle yeniden icat etmiş olursun: aynı hesap 120 kez, üstelik artık süre dolmasını bile beklemeden. Tazeleyici ya tek replikalı ayrı bir iş olmalı ya da lider seçimiyle o an tek bir instance’a verilmeli.
Stampede’in bir de “yok” cevabı üzerinden yaşanan hâli var. Olmayan bir sembol (yanlış yazılmış, listeden kaldırılmış, bir istemcinin uydurduğu) sorgulanınca önbellekte hiçbir şey yok, istek veritabanına iner; “yok” cevabı önbelleğe yazılmadığı için bir sonraki istek de iner. Sıcak anahtarın stampede’i neyse, hiç var olmayan anahtarınki aynı; üstelik bunu tetiklemek için yük gerekmez, yanlış sembolü döngüde soran tek bir istemci yeter. İki ucuz çare: negatif önbellekleme — “yok” cevabını da kısa TTL’yle (5–30 sn) yaz; anahtar uzayı biliniyorsa (sembol listesi) önüne bir bloom filter koy, hiç var olmamış anahtar Redis’e bile gitmesin.
3. Cascade failure: yavaşlığın bulaşması
Şimdi asıl olay. Baştaki hikâyenin devamı şöyle işledi:
| Adım | Ne oldu |
|---|---|
| 1 | Redis, açılış çığı yüzünden yavaşladı. Sorgular 2 ms yerine 400 ms. |
| 2 | Gateway’ler her istekte Redis bekliyor. İş parçacıkları meşgul. |
| 3 | Gelen istekler işlenemiyor, iç kuyruk büyüyor. |
| 4 | Kuyruk sınırsız olduğu için bellek doluyor. |
| 5 | Bir gateway OOM ile ölüyor. |
| 6 | Yük dengeleyici (load balancer) onun trafiğini kalanlara dağıtıyor. |
| 7 | Kalanlar zaten zordaydı; şimdi daha fazla yükle onlar da ölüyor. |
2. adımdan 3. adıma geçiş sezgi değil, aritmetik (Little yasası): 200 thread, her biri 400 ms meşgul → 200 / 0,4 = saniyede en fazla 500 istek. Açılışta gelen 800 istek/sn ise her saniye 300 istek kuyruğa birikir. Kuyruk sınırsızsa büyümesi bir ihtimal değil, sonuç. Redis 2 ms’deyken aynı 200 thread için teorik tavan saniyede 100.000 istekti (pratikte CPU ve soket çok daha önce biter, ama tavan 500’ün çok üstündeydi); kapasiteyi 200 kat düşüren şey Redis’in yavaşlaması.
Dikkat: 1. adımda Redis çökmedi, sadece yavaşladı. Çökseydi gateway’ler anında hata alıp hızlıca cevap dönecekti. Yavaşlama daha kötü, çünkü herkesi bekletiyor.
Bu hikâyenin bir adı var: metastable failure. Tetikleyici (açılış çığı) 09:01’de bitti; Redis 09:02’de normale döndü. Ama gateway’ler toparlanmadı, çünkü kötü durum artık kendini besliyordu: dolu kuyruk isteği yavaşlatıyor, yavaş istek istemcide retry üretiyor, retry kuyruğu daha da dolduruyor. Cache stampede döngüsü de aynı sınıftan: hesap bitene kadar süre yine dolmuş oluyor. Girişteki “kimse deploy yapmadı, trafik normal, sistem kendi kendini öldürdü” cümlesinin teknik karşılığı bu. Sonucu şu: tetikleyiciyi kaldırmak yetmez. Döngüyü kıran bir sınır gerekir — kuyruğu boşaltan yük atma, retry’ı durduran devre kesici. Aşağıdaki beş savunma bunun için; ilk üçü bu bölümde. Ve olay anındaki pratik sonucu daha da net: beklemek işe yaramaz. Sistem bu durumdayken “biraz sonra düzelir” demek, döngüye zaman vermektir; yükü tamamen kesip kontrollü soğuk başlatmak gerekir. Sırası yazının sonunda, kurtarma bölümünde.
Beş savunma, sırayla: ilk üçü burada
Yazının omurgası bu sıra: timeout → sınırlı kuyruk → load shedding → bulkhead → circuit breaker, retry en dışta. Sıra keyfi değil; her biri bir öncekine yaslanır. Timeout yoksa kuyruktaki iş hiç bitmez, kuyruk sınırı yoksa shedding’in ölçeceği yaş anlamsızdır, bulkhead yoksa breaker açılana kadar havuz zaten tükenir. İlk üçü bu bölümde, dört ve beş bir sonraki bölümde.
// Varsayilan zaman asimi genelde YOK ya da 30+ saniye. Trade tarafinda 30 sn = sonsuz.
// Baglanti (connect) ve okuma (read) zaman asimi AYRI seylerdir:
// TCP/TLS handshake asili kalirsa read timeout hic devreye girmez.
// Redis (Jedis 4+): timeout constructor'a config ile verilir, setTimeout diye bir metot yok
JedisClientConfig cfg = DefaultJedisClientConfig.builder()
.connectionTimeoutMillis(100) // baglanti
.socketTimeoutMillis(200) // okuma: olculen p99 ~120 ms x 1,5
.build();
JedisPoolConfig havuz = new JedisPoolConfig();
havuz.setMaxWait(Duration.ofMillis(100)); // havuzdan baglanti alma (checkout): varsayilan -1 = SINIRSIZ
JedisPool pool = new JedisPool(havuz, new HostAndPort(host, 6379), cfg);
// MT5 koprusu (HTTP)
HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(200)) // baglanti
.build();
HttpRequest istek = HttpRequest.newBuilder(uri)
.timeout(Duration.ofMillis(800)) // alisverisin TAMAMI, cevap bitene kadar: p99 ~500 ms x 1,5
.build();
// Veritabani: sorgu suresi, havuzdan baglanti ALMA suresini kapsamaz
hikari.setConnectionTimeout(500); // checkout: Hikari'de varsayilan 30 sn; DBCP2'de (maxWaitMillis=-1) SINIRSIZ
stmt.setQueryTimeout(3); // sorgunun kendisi (saniye); surucuye bagli, PostgreSQL'de statement_timeout daha guvenilir
Üç ayrı timeout var ve görünmez olanı üçüncüsü: havuzdan bağlantı bekleme. Connect
ve read ayarlanır, checkout unutulur. Varsayılanlar kütüphaneye göre değişir: HikariCP’de 30 saniye
(trade tarafında sonsuzla aynı şey), DBCP2’de ve JedisPool’un altındaki
GenericObjectPool’da maxWaitMillis = -1, yani gerçekten sınırsız. Dikkat: checkout’u
sınırsız olan havuz bu yazının kendi Redis yolunda duruyor; yukarıdaki socketTimeoutMillis(200)
havuzdan bağlantı aldıktan sonra başlar. Havuzdaki 20 bağlantının hepsi yavaş sorgularda takılınca
21. istek sessizce beklemeye başlar — timeout’u yoktur, çünkü henüz bir bağlantısı bile yoktur.
Kaskadın gerçek başlangıç noktası çoğu zaman burası: 3. bölümdeki tablonun 2. adımı, thread’lerin
Redis’i değil, havuzu beklemesiyle de başlayabilir.
Bu adım atlanırsa geri kalan hiçbir şey çalışmaz. Devre kesici (circuit breaker) de, kuyruk sınırı da, zaman aşımı olmadan devreye giremez.
Değeri nasıl seçersin? Havadan değil, ölçümden: o çağrının gerçek p99’unu al,
~1,5 ile çarp. Redis 120 ms’de p99 yapıyorsa 200 ms; köprü 500 ms’de yapıyorsa 800 ms. İkinci
kural: çağıranın timeout’undan küçük olmalı. İstemci gateway’den 1 saniyede
vazgeçiyorsa gateway’in Redis + köprü + DB bütçesi toplamda 1 saniyenin altında kalmalı; yoksa
gateway, artık kimsenin beklemediği bir cevap için çalışır. Bunun devamı deadline
propagation: istemci 800 ms’de vazgeçtiyse alt katman devam etmesin. Kalan süreyi
header’la aşağı geçir (X-Remaining-Ms: 450; gRPC’nin grpc-timeout’u da
tam bunu taşır); alt katman süre dolmuşsa hiç başlamasın. Header’a mutlak zaman damgası
koyma: “deadline 09:00:01.800” demek, iki makinenin saatinin aynı olduğunu varsaymaktır;
makineler arası 200 ms’lik bir saat kayması 800 ms’lik bütçenin dörtte biridir ve yönüne göre ya
canlı isteği öldürür ya da ölü isteği yaşatır. Kalan süre gönder, her katman kendi saatiyle geri saysın.
Aksi hâlde yük altında en pahalı işi, cevabı çöpe gidecek isteklere yaparsın.
4. adımı kesen şey bu. Sınırlı kuyruk (bounded queue) dolduğunda istek reddedilir — kötü, ama süreç ayakta kalır. Sınırsız kuyruk, bellek bitene kadar kabul eder ve hepsini birden kaybeder.
“Kuyruk” tek bir şey değil; bir gateway’de en az beş tane var ve önce hangisinin sınırsız olduğunu bulman gerekiyor:
- Accept kuyruğu. Tomcat’te
acceptCount(varsayılan 100) vemaxConnections(NIO’da 8.192). Zaten sınırlı; dolunca yeni bağlantı reddedilir. - Thread pool’un iş kuyruğu. Tomcat’in kendi havuzu sınırlı. Ama senin
Executors.newFixedThreadPool(n)ile açtığın her havuzun arkasında sınırsız birLinkedBlockingQueuevar;newCachedThreadPool()ise thread sayısını sınırlamaz. Java’nın en klasik sınırsız kuyruğu bu. - Uygulama içi async kuyruk. Emirleri köprüye taşıyan, tick’leri işleyen kendi
BlockingQueue’n. Kapasite verilmemişse sınırsız. - Netty / soket çıkış tamponu. Yavaş okuyan istemciye yazmaya devam edersen
outbound buffer büyür;
WRITE_BUFFER_WATER_MARKveisWritable()bunun için. - İstemci tarafı tampon. Terminalde biriken abonelik verisi; senin sunucunu değil istemciyi öldürür, ama kopunca 1. bölümün reconnect çığına döner.
Tablodaki 4. adımın (bellek doluyor) kaynağı, accept kuyruğu zaten sınırlı olduğu için, büyük ihtimalle ikinci ya da üçüncü madde: senin kendi havuzun ya da kendi kuyruğun. Düzeltmesi üç satır:
// YANLIS: Executors.newFixedThreadPool(50) -> arkasinda SINIRSIZ LinkedBlockingQueue
ExecutorService havuz = new ThreadPoolExecutor(
50, 50, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(1_000), // sinir: 1000 bekleyen is
new ThreadPoolExecutor.AbortPolicy()); // dolunca RejectedExecutionException -> 503
// CallerRunsPolicy DEGIL: isi kabul eden thread'de (Tomcat worker, Netty event loop) calistirir,
// o thread bloke olur, kabul de durur; "zarif backpressure" gibi gorunur, kaskadi hizlandirir.
// Olcut: kuyrukta bekleme suresi, doluluk degil. Doluluk gecikmeli bir sinyal;
// 5 sn beklemis fiyat istegi zaten degersiz, isleyip cevap donmenin anlami yok.
// CoDel'den esinlenmis basitlestirme: gercek CoDel ANLIK degere degil, bir interval (100 ms)
// boyunca bekleme suresinin MINIMUMUNUN hedefi asip asmadigina bakar. Kisa bir patlamada minimum
// dusuk kalir, atma olmaz; surekli birikimde minimum da yukselir, o zaman atar.
if (kuyruk.sonPencereMinBeklemeMs(100) > 500 && !istek.kritikMi()) {
// Retry-After SABIT olursa reddedilen herkes t+2'de ayni anda doner: 1. bolumdeki cig.
int retryAfter = 2 + ThreadLocalRandom.current().nextInt(4); // 2-5 sn, jitter'li
return Response.status(503)
.header("Retry-After", String.valueOf(retryAfter)) // HEADER, govde degil
.build();
}
Fiyat sorgusunu reddedip emri kabul etmek, ikisini birden yavaşça kaybetmekten
iyidir. Yükü atmak bir başarısızlık değil, bir öncelik kararı. İki ayrıntı: bu
yazının ilk hâlinde Retry-After: 2 sabitti — yani reddedilen bütün istemcileri iki
saniye sonra aynı saniyede geri çağırıyordu; 1. bölümde anlattığım çığı 3. bölümde kendim üretiyordum.
Ve kuyruk doluluğu geç konuşan bir ölçüt: %80 dolduğunda kuyruğun başındaki istek çoktan saniyelerdir
bekliyor. Kuyrukta bekleme süresi hem daha erken hem daha dürüst bir sinyal — ama anlık değeri
değil, pencere içi minimumu: bu yazının ilk hâlinde kural “baştaki istek 500 ms’den
yaşlıysa” diyordu ve o hâliyle her 600 ms’lik tek patlamada 503 basıyordu. CoDel’in bütün
numarası tam olarak bunu yapmamak: 100 ms boyunca en iyi istek bile 500 ms beklemişse
birikim gerçektir, o zaman at.
Bunun doğal devamı iki kural daha. Aşırı yükte LIFO. Kuyruk birikmişken FIFO, en eski isteği önce işler — yani istemcinin çoktan vazgeçtiği isteği. %100 CPU ile %0 faydalı iş. Yük altında sondan almak (LIFO) en azından hâlâ bekleyen birine cevap verir. Ölü isteği kuyruktan almadan at. İstek deadline’ını taşıyorsa (yukarıdaki deadline propagation) ve süresi dolmuşsa, işlemeye başlamadan düşür; cevabı kimse okumayacak. İkisi de kapasite eklemeden kurtarır; çünkü kapasite hep vardı, sadece çöpe gidiyordu.
Bir eksik daha: yukarıdaki kodda tek ayrım kritikMi(). Yük atma kime ait olduğuna
bakmıyor; tek bir istemcinin retry fırtınası (bozuk bir bot, döngüye girmiş bir entegrasyon) bütün
kuyruğu yer ve reddedilen, diğer 5.000 müşteri olur. Load shedding’in gerçek hayattaki eşlikçisi
müşteri başına adalet: per-tenant rate limit (kimse saniyede N isteğin üstüne çıkamaz)
ya da weighted fair queuing (kuyruk dolunca en çok tüketen önce atılır). Kuyruk ne kadar dolu sorusunun
yanına “kim doldurdu” sorusu gelmeli.
4. Circuit breaker: yanıt vermeyene istek göndermeyi bırak
Somut olay: MT5 köprüsü yanıt vermiyor. Her emir 30 saniye zaman aşımına kadar bir iş parçacığını tutuyor. Havuzda 200 iş parçacığı var (Tomcat varsayılanı). Açılışta o köprüye saniyede ~29 emir gidiyor; 29 × 7 ≈ 200, yani yedi saniyede havuz tükeniyor ve gateway artık sağlıklı köprülere de emir gönderemiyor.
Yani bir köprünün arızası, bütün emir akışını durdurdu.
Bir yanılgıyı baştan düzelteyim: devre kesici toparlanmayı anlık yapmaz. Yalnızca yeni çağrıları keser; 30 saniyelik timeout’ta asılı duran 200 thread yerinde kalır ve ancak timeout dolunca serbest kalır. Yani toparlanma süresi devre kesicinin değil, timeout’un değeri kadardır: 30 sn timeout’la havuz 30 saniyede, 800 ms timeout’la 1 saniyede boşalır. Timeout’un neden küçük olması gerektiğinin asıl gerekçesi bu; devre kesici onun üstüne gelir.
Devre kesiciden önce gelen savunma bu; 3. bölümdeki sıranın dördüncüsü. Devre açılana kadar
(minimumNumberOfCalls=20 + pencere) 200 thread’lik ortak havuz zaten tükenmiş olur;
yedi saniyelik hikâye tam olarak o pencerede geçti. Gemideki su geçirmez bölmeler gibi: her köprüye
kendi havuzu ya da semaforu, biri dolunca diğerleri çalışmaya devam eder.
// Kopru basina ayri sinir: tek bir kopru 200 thread'in tamamini alamaz
BulkheadConfig bh = BulkheadConfig.custom()
.maxConcurrentCalls(25) // bu kopruye ayni anda en fazla 25 cagri
.maxWaitDuration(Duration.ofMillis(50)) // yer yoksa 50 ms icinde reddet, bekletme
.build();
Bulkhead kopruA = Bulkhead.of("mt5-kopru-A", bh);
Supplier<Emir> korunan = Bulkhead.decorateSupplier(kopruA, () -> kopruAGonder(emir));
Köprü A asılı kalınca en fazla 25 thread orada takılır; 175’i B, C ve D için serbest kalır. Devre kesici sonra gelir ve o 25’i de kurtarır. Aynı kural devre kesici için de geçerli: bağımlılık başına bir breaker. Bütün köprüleri tek bir global breaker’ın arkasına koyarsan köprü A’nın hataları B, C ve D’ye giden yolu da kapatır; bu hikâyede global breaker hiçbir işe yaramaz.
maxConcurrentCalls(25) bir tahmin; köprünün bugünkü kapasitesine göre doğru, yarın yavaşladığında
yanlış. Bunun bir sonraki adımı adaptif eşzamanlılık sınırı: sınırı elle koymak yerine
gecikmeden türetmek. Netflix’in concurrency-limits kütüphanesi (Gradient, Vegas
algoritmaları; TCP’nin tıkanıklık kontrolüyle aynı fikir) p50 gecikme yükselmeye başlayınca sınırı
düşürür, düşünce yükseltir; Envoy’da aynı şey adaptive_concurrency filtresi. Sabit 25 ile
başla, ama sınırın yanına gecikme grafiğini koy: 25’e hiç ulaşmadan p99 ikiye katlanıyorsa sınır
çok yüksek demektir.
- Kapalı (normal): istekler geçer, hatalar sayılır.
- Açık: hata oranı eşiği aştı; istekler hiç denenmeden anında reddedilir.
- Yarı açık: bir süre sonra birkaç deneme istek geçirilir. Başarılıysa kapanır, değilse yine açılır.
// MT5 koprusu (senkron HttpClient.send) icin breaker
CircuitBreakerConfig.custom()
.slidingWindowType(SlidingWindowType.TIME_BASED) // ← COUNT_BASED dusuk trafikte saatler onceki cagrilari tutar
.slidingWindowSize(60) // son 60 saniye
.minimumNumberOfCalls(20) // ← 20 cagri olmadan karar verme
.failureRateThreshold(50) // %50 hata -> ac
.slowCallDurationThreshold(Duration.ofMillis(400)) // ← timeout'un (800 ms) ALTINDA olmali, ~%50'si
.slowCallRateThreshold(50) // ← YAVAS cagri da hata sayilir
.waitDurationInOpenState(Duration.ofSeconds(10))
.automaticTransitionFromOpenToHalfOpenEnabled(true) // ← varsayilan false: gecis ancak bir CAGRI gelince olur
.permittedNumberOfCallsInHalfOpenState(5)
.recordExceptions(IOException.class) // ← sadece altyapi hatasi sayilir; HttpTimeoutException IOException'dan turer
.ignoreExceptions(YetersizTeminatException.class, // ← is hatasi devreyi ACMAZ
GecersizSembolException.class) // (ikisinden biri yeter, asagida)
.build();
// AYNI config'i Redis yoluna KOPYALAMA: Jedis IOException atmaz.
// JedisConnectionException -> JedisException -> RuntimeException. Lettuce'ta da RedisConnectionException
// ve RedisCommandTimeoutException RuntimeException'dir. Redis breaker'inda:
// .recordExceptions(JedisConnectionException.class) // baglanti + socket timeout ikisi de buradan gelir
-
minimumNumberOfCallsolmazsa sabah ilk iki çağrı hata alınca devre açılır ve servis hiç denenmemiş olur. -
slowCallRateThresholdçok önemli. Gri arıza (gray failure) tam burada yakalanıyor: köprü hata dönmüyor, sadece 5 saniyede dönüyor. Yavaş çağrıyı hata saymazsan devre hiç açılmaz. Eşik timeout’un altında olmalı: bu yazının ilk hâlinde ikisi de 800 ms’ydi ve o hâliyle “yavaş ama başarılı” diye bir kategori kalmıyordu — 800 ms’ye ulaşan her çağrı zaten timeout olarak failure sayılıyor, mekanizma ölü doğuyordu. Pratik oran timeout’un %50–60’ı: 800 ms timeout için 400–500 ms. -
recordExceptions/ignoreExceptionspratikte en çok yakan ayar. Varsayılan: her exception hata. Sabah bir müşteri grubu “yetersiz teminat” alıyorsa — ki bu köprünün doğru cevabı — devre sapasağlam köprüye karşı açılır ve herkesin emri reddedilir. İş hatası ile altyapı hatasını ayır; devre yalnızca ikincisine baksın. Yukarıdaki örnekte ikisinin birden olması şart değil:recordExceptionsverdiğin anda listede olmayan hiçbir exception failure sayılmaz,ignoreExceptionsonun da üstünde önceliklidir. PratikterecordExceptions’ı daraltmak daha güvenli, çünkü yarın eklenen yeni bir iş exception’ı varsayılan olarak devreyi açmaz;ignoreExceptionsile gidersen her yeni iş hatasını listeye eklemeyi hatırlaman gerekir. Daraltmanın kendi tuzağı da var, bu maddenin en sinsi hâli: istemci kütüphanesinin exception hiyerarşisini kontrol et. Bu yazının ilk hâlinde listedeIOExceptionvejava.util.concurrent.TimeoutExceptionvardı. İkincisi senkronHttpClient.send()’den hiç gelmez; oHttpTimeoutExceptionatar, o daIOException’dır.TimeoutExceptionyalnızcasendAsync/CompletableFutureyolunda görünür; senkron kod gösterip async exception’ı kaydediyordum, zararsız ama yanlış. Zararlısı şu: aynı listeyi Redis breaker’ına kopyalarsan devre hiç açılmaz, çünkü JedisIOExceptionatmaz; bağlantı kopması da, socket timeout daJedisConnectionException’dır ve o birRuntimeException. Listede olmadığı için failure sayılmaz, Redis günlerce yanıt vermese bile breaker kapalı kalır. Her bağımlılık için bir kez yap: istemcinin gerçekte hangi sınıfı attığını testte tetikle, o sınıfı yaz. -
slidingWindowTypeunutulur. Sayı tabanlı pencere (son 100 çağrı) düşük trafikte saatler önceki çağrıları hesapta tutar; gece 02:00’deki 3 hata sabah 09:00’da hâlâ oranın içindedir. Zaman tabanlı pencere (son 60 sn) “şu an” sorusuna cevap verir. -
automaticTransitionFromOpenToHalfOpenEnabledvarsayılan olarak kapalı. Kapalıyken OPEN→HALF_OPEN geçişiwaitDurationInOpenStatedolunca değil, dolduktan sonra ilk çağrı geldiğinde olur. Trafiği kesip beklediğin kurtarma senaryosunda (aşağıda) devre panoda sonsuza kadar OPEN görünür; “devreler toparlandı mı” sorusuna trafik açmadan cevap alamazsın.trueyapınca geçiş zamanlayıcıyla kendiliğinden olur, panoda HALF_OPEN’ı görürsün ve açtığın ilk %10 trafiğin ilk 5 çağrısı deneme olarak geçer. Bedeli breaker başına bir zamanlayıcı; yüzlerce breaker’ın yoksa önemsiz.
Bu yazının ilk hâlinde retry’ın yeri hiç geçmiyordu; oysa sarma sırası yanlışsa devre kesici de,
bulkhead da işe yaramaz. Resilience4j’nin önerdiği sıra dıştan içe
Retry(CircuitBreaker(RateLimiter(TimeLimiter(Bulkhead(çağrı))))):
// Decorators'ta ilk yazilan EN ICTE kalir; retry en son yazilir, en DISTA durur
Supplier<Emir> s = Decorators.ofSupplier(() -> kopruAGonder(emir)) // timeout HttpRequest'in kendisinde (800 ms)
.withBulkhead(kopruA) // 1) yer var mi? yoksa 50 ms icinde reddet
.withCircuitBreaker(cbA) // 2) devre acik mi? aciksa CallNotPermittedException, kopru hic aranmaz
.withRetry(retryA) // 3) EN DISTA: breaker gecirdi ve altyapi hatasi geldiyse bir daha dene
.decorate();
RetryConfig retryCfg = RetryConfig.custom()
.maxAttempts(2) // toplam 2 deneme, 3 degil
.intervalFunction(IntervalFunction.ofExponentialRandomBackoff(100, 2.0)) // jitter'li
.retryExceptions(IOException.class) // senkron HttpClient: timeout da IOException (HttpTimeoutException)
// Breaker'daki kural burada da gecerli: liste verildigi an CallNotPermittedException, BulkheadFullException
// ve is hatalari ZATEN denenmez; ayrica ignoreExceptions yazmaya gerek yok. Varsayilan (liste yok) ise
// HER exception'i tekrar dener -- devre acikken bile.
.build();
Bu inşa sırası; çalışma anında zincir dıştan içe, yani 3. bölümdeki omurganın tersinden işler: retry → breaker → bulkhead → çağrı. İkisi de doğru, ikisini yan yana okurken karıştırma.
Sıra neden bu? Retry’ı breaker’ın içine koyarsan her deneme breaker’a ayrı bir failure
olarak yazılır: 3 deneme = 3 hata, devre üç kat hızlı açılır. Retry’ı breaker’ın dışına koyup
retryExceptions listesini boş bırakırsan varsayılan her exception’ı dener,
CallNotPermittedException dahil: devre açıkken bile retry backoff’u bekler; çağrı hiç
yapılmıyor ama thread bekliyor. İkinci kural daha az bilinir ve daha pahalıdır: retry
katmanlar arasında çarpımsaldır. İstemci 3 deneme, gateway 3 deneme, köprü istemcisi 3 deneme:
kaynağa giden yük 3 değil, 3 × 3 × 3 = 27 kat. 1. bölümdeki retry budget bunu tek katmanda
%10’a bağlar, ama üç katmanın her biri kendi bütçesiyle retry yapıyorsa çarpım yine oradadır. Kural:
yalnızca en dış katman retry yapar; ara katmanlar hatayı olduğu gibi yukarı geçirir.
Bir de sıradaki TimeLimiter’ın ne işe yaradığı: timeout kabul etmeyen
kütüphane için var. “Timeout olmadan hiçbir şey çalışmaz” tezinin kaçış kapısı bu.
Sağlayıcının verdiği eski SDK gonder()’e süre parametresi almıyorsa, çağrıyı ayrı bir
havuza atıp dışarıdan kesersin:
// SDK timeout bilmiyor: cagri ayri thread havuzunda, sure disaridan
ThreadPoolBulkhead tpb = ThreadPoolBulkhead.of("kopru-B", ThreadPoolBulkheadConfig.custom()
.maxThreadPoolSize(25).queueCapacity(1).build()); // 25 thread, kuyruk fiilen yok (en az 1 olmak zorunda): yer yoksa reddet
TimeLimiter tl = TimeLimiter.of(Duration.ofMillis(800));
CompletableFuture<Emir> f = Decorators.ofSupplier(() -> eskiSdk.gonder(emir))
.withThreadPoolBulkhead(tpb) // 1) ayri havuz
.withTimeLimiter(tl, zamanlayici) // 2) 800 ms'de future'i kes -> java.util.concurrent.TimeoutException
.withCircuitBreaker(cbB) // 3) bu yolda breaker'a TimeoutException.class KAYDEDILIR
.withRetry(retryB, zamanlayici) // 4) en dista
.get().toCompletableFuture();
İki dürüst not. TimeLimiter future’ı keser, thread’i değil: SDK
interrupt’a bakmıyorsa kesilen çağrı o thread’i cevap gelene kadar tutmaya devam eder. Havuzun
25 ile sınırlı olmasının sebebi tam bu; en fazla 25 zombi, sonra red. Ve yukarıda “senkron koddan
TimeoutException gelmez” dedim; bu yol asenkron, burada gelir, breaker’ın record
listesi de ona göre yazılır. Hangi yoldaysan o yolun exception’ı.
Devre kesici koymak yetmiyor; alternatif davranışı tasarlamak gerekiyor. Trade tarafında cevap veriye göre değişiyor:
- Fiyat sorgusu, görüntüleme için: son bilinen fiyatı yaşıyla birlikte dön. Ekranda “3 sn önceki fiyat” hiç fiyat olmamasından iyi. Emir girişi için hayır: müşteri o bayat fiyattan emir girebiliyorsa icra ihtilafı ve arbitraj açığı üretirsin. Emir tarafında güncel fiyat yoksa emir de yok.
- Emir gönderme: hızlıca reddet. Sessizce kuyruğa alıp “sonra göndeririz” deme — müşteri emrin geçtiğini sanır.
- Pozisyon listesi: veritabanından oku, önbelleği atla. Yavaş ama doğru.
Bunu düşünmeden konan devre kesici, hata mesajını hızlandırmaktan başka bir işe yaramıyor.
5. Gray failure: ayakta ama ölü
En zor teşhis edilen arıza sınıfı. Fiyat akış motoru (streaming engine) ayakta,
/health uç noktası 200 OK dönüyor, süreç çalışıyor.
Ama uzun bir çöp toplama duraklaması yüzünden gerçek işi yapamıyor:
fiyat işlemiyor, elindeki 4 saniyelik eski kotasyonu yayınlıyor.
Yük dengeleyici sağlıklı görüyor ve trafik göndermeye devam ediyor. Sonuç: emirlerin dörtte biri eski fiyattan açılıyor.
GET /health
→ 200 {"status":"UP"}
// Kodu:
return ok(); // ← surec ayakta, o kadar
Bu sadece “süreç çalışıyor” diyor. Faydası çok sınırlı.
GET /ready
HTTP 503 // ← kubelet ve LB SADECE HTTP koduna bakar, JSON'a degil
{
"son_tick_yasi_ms": 4200, // ← 4 saniyedir tick islemedi
"beklenen_tick_araligi_ms": 200, // bu sembol setinde, bu seansta
"kuyruk_derinligi": 48000,
"gc_duraklama_p99_ms": 1800,
"status": "DEGRADED" // insan ve dashboard icin; makine 503'u okur
}
// Kurallar (herhangi biri tutarsa 503):
// seans acikken son_tick_yasi > beklenen_tick_araligi x 5 (seans kapaliyken atlanir)
// kuyruk_derinligi > 10.000 (normalde < 500; 10k = 20 sn'lik birikim)
// gc_duraklama_p99_ms > 500 (1800 ms = tick araliginin 9 kati, fiyat islenmiyor demek)
Bu, gerçekten iş yapıp yapmadığını söylüyor.
Birincisi eşiğin kendisi. Bu yazının ilk hâlinde kural sabitti: “son tick 1 saniyeden eskiyse
hazır değil.” O kural olduğu gibi konursa yeni bir kesinti kaynağıdır: likiditesi düşük bir sembol
setinde ya da seans dışında tick zaten gelmez; o anda bütün düğümler aynı saniyede
kendini hazır-değil ilan eder ve endpoint listesi boşalır.
Eşik, beklenen tick hızına ve seans takvimine bağlı olmalı: EURUSD’de 200 ms normalken egzotik bir
paritede 5 saniye normaldir; seans kapalıyken kontrolün kendisi kapalı olmalı. İkincisi HTTP kodu:
"status": "DEGRADED" gövdesini kubelet de, yük dengeleyicilerin çoğu da okumaz; yalnızca
duruma bakarlar. DEGRADED’in 503 döndüğünü yazmadıysan örnek uygulandığında hiçbir
şey yapmaz, JSON güzel görünür.
Üçüncüsü ve en büyüğü: sağlık kontrolünü bağımlılıklara bağlarsan yeni bir zincirleme çöküş (cascade failure) icat edersin: Redis yavaşladığında bütün gateway’ler kendini sağlıksız ilan eder, yük dengeleyici hepsini listeden çıkarır ve yavaşlamış bir sistem yerine tamamen ölü bir sistemin olur. Sabit tick eşiği de tam bu tuzağın başka bir yüzü: hepsi birden düşer.
Ayrımı net tutmak gerekiyor:
- Liveness (canlılık): sadece süreç sağlığı. Başarısızsa pod yeniden başlatılır. Bağımlılık kontrol edilmez.
- Readiness (hazırlık): trafik alabilir miyim? Kendi iç durumuna bakar (kuyruk, tick yaşı). Bağımlılığa bakarsa hepsi birden düşmemeli.
- Startup (açılış): yavaş açılan JVM için.
startupProbeyoksa liveness, ısınma bitmeden pod’u öldürür; pod yeniden başlar, yine ısınamaz, yine ölür. Bütün pod’lar bu döngüye girdiğinde elindeki şey 1. bölümdeki toplu yeniden başlatma çığının ta kendisidir.failureThreshold × periodSecondsen yavaş açılışı (1. bölümdeki 15 saniyelik kademeli bekleme dahil) kapsamalı.
Yukarıdaki kurallar olduğu gibi konursa düğüm saniyede bir listeye girip çıkar: tick geldi → hazır, GC duraklaması → hazır değil, tick geldi → hazır. Her geçiş yük dengeleyicide bir endpoint güncellemesi, bağlı istemcilerde bir kopuş demek; salınan düğüm, yavaş düğümden daha pahalıdır. İki yerde histerezis gerekiyor:
# kubelet tarafi: art arda 3 hata -> hazir degil (15 sn), art arda 2 basari -> hazir (10 sn; uygulama katmaniyla toplam ~13 sn, asagida)
readinessProbe:
httpGet: { path: /ready, port: 8080 }
periodSeconds: 5
failureThreshold: 3 # tek bir kotu olcumle listeden dusme
successThreshold: 2 # tek bir iyi olcumle listeye donme (liveness'ta 1 olmak ZORUNDA, readiness'ta degil)
# uygulama tarafi: giris ve cikis esigi ayri
# DEGRADED'e gir : son_tick_yasi > beklenen_araligin 5 kati
# DEGRADED'den cik: son_tick_yasi < beklenen_araligin 2 kati, ust uste 3 olcum (saniyede bir olcum -> 3 sn)
Giriş eşiği ile çıkış eşiği aynıysa düğüm eşiğin üstünde titreşir. Aradaki boşluk (5× / 2×) ve “üst üste N ölçüm” şartı, tek bir GC duraklamasının düğümü listeden atmasını önler; gerçekten bozulmuş düğüm ise 15 saniyede düşer, bu da 4 saniyelik bayat fiyat hikâyesi için yeterince hızlı. İki katman üst üste biner, rakamı toplamdan ver: uygulama önce 3 saniye iyi görmeli, ancak ondan sonra kubelet 2 × 5 saniyelik iki iyi cevap alır; toparlanma 10 değil ~13 saniye. Düşme tarafında da aynı: uygulamanın DEGRADED’e girmesi + 3 × 5 saniye. Eşikleri ayarlarken iki katmanı ayrı ayrı değil, toplamı düşün.
Bu bölümün en büyük pratik boşluğu buydu. Senaryo bir fiyat akış motoru; istemciler ona HTTP isteği değil, uzun ömürlü WebSocket / TCP stream açıyor. Readiness 503 dönünce pod endpoint listesinden çıkar, yeni bağlantı almaz. Ama zaten bağlı olan istemciler yerinde durur ve 4 saniyelik bayat kotasyonu almaya devam eder. Gri arıza hikâyesi tam olarak o istemcilerde yaşandı; readiness onlara hiçbir şey yapmadı.
Streaming tarafında gereken şey aktif kapatma: düğüm DEGRADED’e girince açık
stream’leri kendisi kapatır ve istemciyi başka düğüme iter. WebSocket’te close frame
1012 Service Restart ya da 1013 Try Again Later; HTTP/2 ve gRPC’de
GOAWAY; ham TCP’de protokolün kendi “yeniden bağlan” mesajı. İstemci
jitter’lı backoff’la geri gelir, yük dengeleyici onu artık listede olmayan bu düğüme değil sağlıklı
olana verir. İki kural: kapatmayı da kademeli yap (bütün stream’leri aynı saniyede kesmek 1.
bölümdeki reconnect çığıdır; saniyede N bağlantı, jitter’lı) ve her kotasyona sunucu zaman damgası
koy, istemci yaşı kendi görsün. Sunucu kapatmayı unutsa bile “bu fiyat 4 saniyelik” diyebilen
istemci, o fiyattan emir girmez.
1,8 saniyelik GC duraklamasını health check’te yakalamak teşhis; fiyat motorunda asıl iş o duraklamanın olmaması. Sıra önemli, ucuzdan pahalıya:
- Ölç.
-Xlog:gc*ile duraklama nedenini oku: humongous allocation, full GC, promotion failure? Tedavi nedene bağlı; ölçmeden collector değiştirmek tahmin. - Allocation rate’i düşür. Tick başına yeni nesne üretme: tick objelerini yeniden kullan, kutulamayı (boxing) kes, primitive koleksiyon kullan, string birleştirmeyi sıcak yoldan çıkar. En etkili adım genelde bu ve collector’dan bağımsız.
- Collector’ı seç. G1’in duraklaması heap’le büyür; ZGC (generational sürümü JDK 21’de
-XX:+ZGenerationalile, JDK 23’ten itibaren varsayılan) ve Shenandoah duraklamayı heap boyutundan bağımsız olarak milisaniye mertebesinde tutar, bedeli biraz CPU ve throughput. Fiyat motoru için doğru takas bu; batch işi için değil. - Büyük veriyi heap dışına al. Sembol tarihçesi, order book snapshot’ı gibi büyük ve
uzun ömürlü yapılar off-heap (Chronicle, Agrona, doğrudan
ByteBuffer) ya da ayrı bir süreçte dursun; collector’ın tarayacağı canlı nesne azalsın.
Buna ek olarak yük dengeleyici tarafında aykırı değer tespiti (outlier detection) çok
işe yarıyor: düğümü kendi sağlık kontrolüne değil, komşularına göre değerlendirir ve sağlık
kontrolü OK dese bile geçici olarak devre dışı bırakır. Gri arızaya karşı en pratik savunma bu —
çünkü sağlığı mutlak değil göreli ölçüyor. Bir düzeltme: bu yazının ilk hâlinde
kural “bu düğüm diğerlerinden 5 kat yavaş” diye yazıyordu; Envoy’da böyle bir gecikme
kuralı yok. Ejection tipleri ardışık 5xx, ardışık gateway hatası, success_rate
(düğümün başarı oranı kümenin ortalamasından belirgin düşükse) ve failure_percentage. Yavaş
düğüm Envoy’da ancak timeout’a düşüp local-origin failure sayıldığında yakalanır — yani 3.
bölümün tezi burada da geçerli: timeout yoksa outlier detection da gri arızayı görmez. Gecikmeyi doğrudan
ölçen mekanizmalar yük dengeleyicinin seçim tarafında: Finagle ve Linkerd’in peak-EWMA
seçicisi yavaş düğüme daha az istek yollar; düğümü atmaz, sessizce aç bırakır. Yukarıdaki “hepsi
birden düşer” tuzağının yük dengeleyici tarafındaki çözümü de burada: Envoy’da
max_ejection_percent (varsayılan %10) aynı anda havuzun en fazla %10’unun dışarı
atılmasına izin verir. Hepsi yavaşladıysa hiçbiri atılmaz; yavaş bir sistem, ölü bir sistemden iyidir.
Gri arızanın bir de ölçüm tarafı var: coordinated omission. p99’un temiz görünmesinin
sebebi sistemin iyi olması değil, yavaş isteklerin hiç ölçülememesi olabilir. 1,8 saniyelik GC
duraklaması sırasında gelen istekler ölçüm aracının kuyruğunda bekler; araç onları duraklama bittikten
sonra atar ve süreyi o andan itibaren sayar. Duraklama grafiğe hiç girmez. Yük aracı sabit hızda istek
üretmeli (bekleyen isteğin gecikmesi hesaba katılmalı; wrk2, Gatling’in constantUsersPerSec
modu, HdrHistogram’ın düzeltmesi) ve server tarafı gecikmeyle istemci tarafı gecikme yan yana
izlenmeli. Aradaki fark, ölçüm aracının sakladığı gri arızadır.
İki sınırını da yazmak lazım. Birincisi outlier detection’ın kendisi: göreli ölçtüğü için, bütün
düğümler birlikte bozulduğunda hiçbir şey bulamaz — hepsinin başarı oranı %60’a düştüyse
hiçbiri “ortalamanın altında” değildir. Tek düğümün gri arızasında en pratik savunma; küme geneli
yavaşlamada kör. İkincisi, readiness’in hepsi birden düşmesine karşı yük dengeleyicinin son
sigortası: panic threshold / fail-open. Envoy’da healthy_panic_threshold
(varsayılan %50): sağlıklı düğüm oranı bu eşiğin altına inerse yük dengeleyici sağlık bilgisini yok sayar ve
trafiği hepsine dağıtır. “Yavaş sistem yerine ölü sistem” senaryosunun standart
panzehiri bu; eşik %0 yapılırsa kapanır, o yüzden kimin kapattığına bak.
6. Failover: devraldı ama neyi kaybetti?
Redis master çöktü, Sentinel bir replikayı terfi ettirdi, sistem 8 saniyede geri geldi. Herkes rahat. Ama:
Çoğaltma asenkron. Master, write’ı kabul edip istemciye “tamam” dedikten sonra replikaya gönderiyor. Çöktüğü anda son 1–2 saniyenin write’ları henüz gitmemişti.
O saniyelerde ne vardı? Belki 60 emrin durumu. Yeni master bu emirleri hiç görmemiş durumda. Uygulama “emir yok” diyor, köprü ise o emirleri işlemiş olabilir. Şimdi elimizde durumu bilinmeyen 60 emir var.
Bir de kaybolmayan ama kör kalan 8 saniye var. Sentinel’in master’ı düşmüş
sayması (down-after-milliseconds) + oylama + terfi: bu sürede her write ya hata alır ya da
asılı kalır. 8 saniye Sentinel varsayılanıyla mümkün değil; varsayılan down-after-milliseconds
30.000’dir ve failover 30 saniyeden önce başlamaz. Bu rakam ayarın 5.000’e çekilmiş hâliyle
geliyor — daha kısa tespit, daha çok yanlış alarm; ağdaki 3 saniyelik bir titreme gereksiz failover
tetikler. Trade tarafında bu takas 5 saniyeden yana verildi. O sırada gelen emirlere ne olacak, önceden kararlaştırılmış olmalı: ya
istemciye açıkça reddedilir (“tekrar dene”) ya da doğruluk kaynağı olan veritabanına yazılıp
Redis’e sonra taşınır. “Bellekte bekletip sessizce sonra yazarız” seçeneği yok; o
bellek de failover’la gider. Pencereyi uzatan iki şey daha var ve ikisi de istemci tarafında.
Birincisi havuzdaki eski bağlantılar. Yeni master’ı öğrenmek sorun değil: Jedis’in
JedisSentinelPool’u +switch-master olayına zaten abone, Lettuce da Sentinel
bağlantısında aynı olayları dinler. Sorun, havuzda eski master’a açık duran boşta bağlantılar ve
master’ın sessizce gittiği durumdaki half-open TCP: bağlantı açık görünür, yazdığın gider, cevap
hiç gelmez, ancak socketTimeoutMillis dolunca hata alırsın. Bu yüzden Sentinel’li
havuzda testOnBorrow (ödünç alırken PING) ve kısa socket timeout süs değil; ikisi olmadan
8 saniyelik terfiye bağlantı başına bir timeout süresi eklenir. İkincisi JVM’in DNS
önbelleği. Master’a hostname üzerinden gidiyorsan (ElastiCache primary endpoint, bir DNS
alias, Sentinel’de announce-hostnames) networkaddress.cache.ttl araya girer:
Java’nın kendi varsayılanı 30 saniye, ama SecurityManager açık kurulumlarda ve bazı eski imajlarda
-1 = sonsuz. O JVM, failover’dan sonra eski IP’yi hiç unutmaz; yeniden
başlatılana kadar ölü adrese bağlanmaya çalışır. -Dnetworkaddress.cache.ttl=5 ile açıkça yaz,
varsayılana güvenme.
Kaybın ikinci perdesi eski master geri döndüğünde oynanır: Sentinel onu replika yapar, yeni master’dan tam senkronizasyon alır ve kendi diskindeki o 60 emri atar. Kayıp artık geri döndürülemez; sessiz kaybın kesinleştiği an bu.
Üçüncü perde: soğuk önbellek. Terfi eden replika veriyi taşır ama arkasındaki uygulama düğümleri yeniden bağlanırken yerel önbellekleri boşalmışsa — ya da failover’la birlikte pod’lar yeniden başladıysa — ilk saniyede bütün istekler Redis’i atlayıp veritabanına gider. 1. bölümün çığı, 2. bölümün stampede’i ve 3. bölümün kaskadı, 6. bölümün failover’ından doğar. Altı modun birbirini tetiklediğinin en net örneği bu; failover planı “veri kaybı” ile bitmez, “önbellek ısındı mı” ile biter.
# redis.conf (master)
min-replicas-to-write 1 # en az 1 replika bagli ve ...
min-replicas-max-lag 2 # ... gecikmesi 2 sn'nin altinda degilse WRITE REDDEDILIR
appendfsync everysec # tek dugumde bile ~1 sn kayip demek; always = her write'ta fsync, pahali
# sentinel.conf (her sentinel'de)
sentinel monitor mymaster 10.0.0.5 6379 2 # son sayi QUORUM: kac sentinel "dustu" derse ODOWN
sentinel down-after-milliseconds mymaster 5000
min-replicas-to-write 1’in yazıda hiç geçmeyen bedeli: tek replikan varsa kendi
kesintini üretirsin. O replikayı bakım için yeniden başlattığın an master write kabul etmeyi
bırakır; failover yok, çökme yok, sadece “NOREPLICAS” hatası. Bu ayar en az iki replikayla
anlamlı; tek replikayla ya kullanma ya da bakım öncesi CONFIG SET min-replicas-to-write 0
ile geçici kapat, sonra geri aç. Ayarın en sık ısıran yanı bu.
down-after-milliseconds’e bu kadar girip quorum’u atlamak da boşluktu.
Failover için iki ayrı şart var: quorum kadar sentinel master’ı düşmüş saymalı (ODOWN), ve
bütün sentinel’lerin çoğunluğu terfiyi yetkilendirmeli. İkincisi quorum’dan
bağımsız: iki sentinel + quorum 1 yazan çok insan var; ikisinden biri master’la aynı
makinede ölünce çoğunluk (2) sağlanamaz ve failover hiç olmaz. En az üç sentinel, üç ayrı
makinede; quorum 2. Sentinel’in yerine Redis Cluster kullanıyorsan aynı hikâye shard başına
oynanır (cluster-node-timeout, master çoğunluğunun oyu), WAIT orada da var ve
asenkron çoğaltmanın kaybı da aynen orada.
// Uygulama: kritik write'tan sonra en az 1 replikanin ack'ini bekle (en fazla 200 ms)
redis.set("emir:" + emirId, durum);
long onaylayan = redis.waitReplicas(1, 200); // WAIT 1 200: replikanin BELLEGINE ulasti
if (onaylayan < 1) {
// replika yetismedi: emri "onaylanmadi" say, istemciye "tamam" deme
}
// Redis 7.2+ ve Jedis 5+: diske de yazildigini iste (yerelde 1 fsync + 1 replikada fsync, en fazla 200 ms)
KeyValue<Long, Long> sonuc = redis.waitAOF(1, 1, 200); // WAITAOF 1 1 200 -> (yerel, replika)
if (sonuc.getValue() < 1) { /* replika diske yazmadi */ }
// master'da appendonly no ise komut HATA doner; replikada kapaliysa o replika hic saymaz, sonuc hep 0
WAIT senkron çoğaltma değil; “bu write en az bir replikaya ulaştı” der,
failover’da o replikanın seçileceğini garanti etmez. Ama 1–2 saniyelik pencereyi
milisaniyelere indirir ve en önemlisi: yetişmediğinde sana haber verir. Sessiz kayıp, sesli
kayba döner. Bir boşluğu kalır: WAIT replikanın belleğine ulaştığını söyler, diskine
değil. appendfsync everysec ile master ve replika birlikte güç kaybederse (aynı rack, aynı
güç hattı) o 1 saniye yine gider. Redis 7.2’nin WAITAOF’u bu ayağı tamamlar: fsync
onayı ister. Her write için değil; emir durumu gibi geri alınamayan write’lar için.
Bunun bir bedeli var ve yazmadan geçmek olmaz: WAIT o bağlantıyı replika ack’i gelene
kadar bloke eder. Aynı veri merkezinde, replika güncelken bu 1–2 ms (bir RTT); replika geride
kaldığında 200 ms’lik tavana kadar. Yani her kritik write’a normalde bir RTT, kötü günde 200 ms
ekliyorsun ve o sırada o bağlantıdan başka komut geçmiyor. Fiyat, spread, oturum gibi write’lara
uygulama; havuzda kritik write için ayrı bağlantı tut ki bekleyen WAIT fiyat yolunu
tıkamasın. Ölç: WAIT süresinin p99’u replika lag’inin en dürüst göstergesidir.
Bir ayar daha, hem 5. hem 6. bölümle ilgili: replica-serve-stale-data yes (varsayılan).
Master’dan kopmuş bir replika, kopuk olduğunu bile bile eski veriyi servis etmeye devam eder;
replikadan okuyorsan bu, sağlık kontrolü yeşil bir gri arıza. no yaparsan kopuk
replika okumaya MASTERDOWN hatası döner; okuyan taraf yanlış veri yerine hata alır, ki
emir tarafı için doğru takas bu. Fiyat görüntüleme için yes kalabilir — yaşını
gösteriyorsan.
Çözüm mimaride; dört şey gerekiyor:
- Kritik durumun tek kopyası önbellekte olmasın. Emir ve pozisyonun doğruluk kaynağı kalıcı veritabanı olmalı; Redis hızlı bir kopya olarak durmalı. “Redis’te tutalım, hızlı” kararı failover gününe kadar doğru görünüyor.
- Her emrin istemci tarafında üretilmiş tekil bir kimliği olsun. Client order ID ya da idempotency key: emir daha gateway’e ulaşmadan üretilir, köprüye o kimlikle gider, veritabanına o kimlikle yazılır. Bu olmadan bir sonraki madde çalışmaz; köprüden çektiğin emirleri yerel kayıtlarla eşleyecek ortak bir anahtarın yoktur ve “durumu bilinmeyen 60 emir” sonsuza kadar bilinmez kalır.
- Failover sonrası mutabakat otomatik olsun. Terfi tespit edilince bir iş tetiklensin: son N dakikanın emirlerini köprüden çekip client order ID üzerinden yerel durumla karşılaştırsın, farkları rapor etsin. Elle yapılmaya çalışılırsa yapılmıyor.
- Terfi anı olay olarak yazılsın. “Şu saatte failover oldu” kaydı, üç gün sonra “bu emir neden kayıp” sorusunun tek cevabı oluyor.
Ağ bölünmesinde eski master hâlâ kendini master sanabilir ve write kabul etmeye
devam edebilir. Bu, leader election'daki split-brain
sorununun aynısı ve çaresi de aynı: kritik write’lara bir
dönem numarası (fencing token) koy ve eskisini reddet. Bir uyarı: Sentinel’in kendi
config epoch’u vardır ama uygulama katmanına sızmaz; Redis istemcisi senin yerine hiçbir write’ı
reddetmez. Token’ı üretmek, write’ın yanına koymak ve alıcı tarafta karşılaştırmak uygulamanın
işidir. Redis tarafındaki ilk savunma ise yukarıdaki min-replicas-to-write: bölünmede eski
master replikalarını kaybettiği için write kabul edemez hâle gelir. Tam garanti değil (lag penceresi
kadar açık kalır), ama “ya Redis ya DB” ikiliğinden daha zengin bir tablo: min-replicas
pencereyi daraltır, WAIT yarı-senkron onay verir, fencing token kalanı reddeder.
Kötü duruma girdiysen: kurtarma sırası
Metastable durumun tanımı gereği sistem kendi kendine çıkmaz; birinin döngüyü kırması gerekir. Olay anında herkesin ilk refleksi “hepsini yeniden başlat” — ve bu, 1. bölümdeki toplu yeniden başlatma çığını en kötü anda tetikler: soğuk önbellek, aynı anda ısınan 120 pod, Redis yine %100.
- Trafiği kes. Yük dengeleyicide gelen isteği düşür ya da kritik olmayan yolları kapat; kuyrukların boşalmasına izin ver. Döngüyü besleyen şey gelen istek; o kesilmeden yapılan hiçbir şey tutmaz. “Kritik olmayan yolları kapat” için o kill switch’in önceden kodlanmış olması gerekiyor: fiyat sorgusunu, geçmiş listesini, raporu tek bir bayrakla 503’e çeviren bir feature flag. Yazılı olması yetmez; 09:03’te kod yazılmaz, sadece bayrak çevrilir.
-
Retry’ları durdur. Kendi istemcilerinde retry’ı kapat ya da devre kesicileri
elle açık tut. Dış müşteri terminallerinde bu kol yok; onlar için sunucu tarafı karşılığı 3. bölümün
araçları: hızlı reddet, jitter’lı
Retry-After, istemci başına rate limit. Amaç aynı: toparlanan sistemi ilk vuracak şey birikmiş retry dalgasıdır. Trafiği açarken devrelerin durumuna bakacaksanautomaticTransitionFromOpenToHalfOpenEnabledaçık olmalı; kapalıysa trafik gelmeden devre OPEN’dan çıkmaz ve pano sana yalan söyler. - Önbelleği ısıt. Sıcak anahtarları (sembol listesi, spread’ler) trafik gelmeden önce tek bir iş ile doldur. 2c’deki tazeleyici tam burada işe yarar; 120 pod’un her birinin kendi ısınması değil.
-
Dalga dalga kaldır. Pod’ları hepsini birden değil, %10–25’lik gruplarla;
her grup readiness’i geçtikten sonra bir sonraki.
maxSurge/maxUnavailablebunun için var. - Trafiği kademeli aç. %10, %25, %50, %100; her adımda p99’a ve kuyruk başındaki işin yaşına bak. Bir adımda bozuluyorsa geri al, ileri gitme.
Sıra önemli: trafiği kesmeden restart, aynı çığın tekrarı. Ve bu adımlar olaydan önce yazılı olmalı; 09:03’te, üç gateway ölmüşken uydurulmaz.
Hepsi bir arada: neyin ne zaman devreye girdiği
| Belirti | Muhtemel mod | İlk bakılacak |
|---|---|---|
| Belirli saatlerde tepe yükler | Thundering herd | Cron saatleri, TTL dağılımı |
| Düzenli aralıklarla CPU zıplaması | Cache stampede | TTL değerleri, aynı anda ölen anahtarlar |
| Bir servis yavaşladı, hepsi çöktü | Cascade failure | Zaman aşımı var mı, kuyruk sınırlı mı |
| Thread pool (iş parçacığı havuzu) tükeniyor | Timeout ya da bulkhead yok | Dış çağrıların zaman aşımı, bağımlılık başına havuz; devre kesici beşinci savunma |
| Health check yeşil ama müşteri şikayetçi | Gray failure | Hazırlık kontrolü (readiness probe) gerçek işe bakıyor mu |
| Kısa kesintiden sonra tutarsız veri | Failover data loss | Çoğaltma senkron mu, mutabakat var mı |
Kontrol listesi
- Bütün dış çağrıların zaman aşımı var mı? Connect ve read ayrı ayrı mı?
- Timeout değerleri p99’dan mı türedi, çağıranın timeout’undan küçük mü?
- Veritabanı ve HTTP havuzlarında bağlantı alma (checkout) timeout’u var mı, yoksa sadece sorgu timeout’u mu?
- Havuz checkout timeout’u Redis tarafında da var mı (JedisPool
maxWait, varsayılan sınırsız)? - Load shedding’de
Retry-Afterjitter’lı mı, yoksa herkesi aynı saniyeye mi çağırıyor? Kuyruğu tek bir müşteri doldurabiliyor mu; müşteri başına limit var mı? - Deploy’da pod listeden düşmeden mi kapanıyor?
preStop+terminationGracePeriodSecondsin-flight işi kapsıyor mu? - “Yok” cevabı önbelleğe yazılıyor mu (negatif önbellekleme), yoksa olmayan anahtar her seferinde veritabanına mı iniyor?
- Bütün kuyrukların üst sınırı var mı?
Executors.newFixedThreadPoolya da kapasitesizBlockingQueuekaldı mı? - Breaker’ın record listesi istemcinin gerçekten attığı sınıfı içeriyor mu (Jedis:
RuntimeException,IOExceptiondeğil)? - Readiness’te histerezis var mı? DEGRADED’e girince açık stream’ler kapatılıyor mu?
- Sentinel en az 3, ayrı makinelerde, quorum 2 mi?
min-replicas-to-writetek replikayla mı çalışıyor? - Bağımlılık başına ayrı havuz (bulkhead) var mı, yoksa tek ortak havuz mu?
- Zamanlanmış işler aynı saniyede mi başlıyor?
- TTL değerlerinde sapma var mı, hepsi sabit mi? Arka plan tazeleyicinin tek sahibi var mı?
- Devre kesici yavaş çağrıyı da hata sayıyor mu? İş hatasını hata saymıyor mu? Yavaş eşiği timeout’un altında mı? Bağımlılık başına mı?
- Retry’larda jitter’ın yanında retry budget var mı?
- Retry zincirin neresinde, breaker’ın dışında mı? Kaç katman retry yapıyor, çarpım kaç?
- Devre açıkken OPEN→HALF_OPEN geçişi kendiliğinden mi oluyor, yoksa ilk çağrıyı mı bekliyor?
- Devre açıkken ne dönüyorsun? Yazılı mı?
- Hazırlık kontrolü gerçek işe mi bakıyor, sadece sürece mi? DEGRADED 503 mü dönüyor?
- GC duraklaması yalnızca ölçülüyor mu, yoksa allocation rate / collector / off-heap ile düşürülüyor mu?
- Hazırlık kontrolü bağımlılığa ya da sabit bir eşiğe bağlıysa hepsi birden düşer mi?
- Kritik durumun tek kopyası önbellekte mi duruyor?
- Her emrin client order ID’si var mı; failover sonrası otomatik mutabakat var mı?
Nerede görünürler? Birim testte değil, üç yerde: gecikme enjeksiyonlu yük testi (Redis’e yapay 400 ms ekle, açılış trafiğini bas), fault injection (bir köprüyü kapat, bir pod’u öldür) ve takvimli bir oyun günü (game day) — failover’ı prod’da, mesai saatinde, herkes bakarken elle tetikle. Bu üçü yapılmıyorsa liste kâğıt üstünde kalır.
Sonuç
Bu altı modun ortak noktası şu: hiçbiri bir kod hatasından çıkmıyor. Hepsi doğru yazılmış parçaların, yük altında birbirini beslemesinden doğuyor. O yüzden birim testlerde görünmüyorlar; yük testinde, fault injection’da ya da kötü bir sabahta görünüyorlar. Hangisinde göreceğin senin seçimin.
Ve hepsinin ilacı aynı yerde toplanıyor: sınır koy. Zaman aşımına sınır, kuyruğa sınır, tekrar denemeye sınır, aynı anda başlayan işe sınır. Sınırı olmayan her şey, bir gün sınırı senin yerine belirleyecek — ve genelde bunu bellek tükendiğinde yapacak.
- Metastable failure: Bronson, Aghayev, Charapko, Zhu, Metastable Failures in Distributed Systems (HotOS 2021).
- Olasılıksal erken yenileme (XFetch): Vattani, Chierichetti, Lowenstein, Optimal Probabilistic Cache Stampede Prevention (VLDB 2015).
- Gri arıza: Huang vd., Gray Failure: The Achilles’ Heel of Cloud-Scale Systems (HotOS 2017).
- CoDel: Nichols, Jacobson vd., RFC 8289 — Controlled Delay Active Queue Management.
- Retry budget ve load shedding: Google SRE kitabı, Handling Overload.
- Coordinated omission: HdrHistogram (Gil Tene) ve düzeltme notları.
- Sarma sırası: Resilience4j, Getting Started — aspect order.
- Adaptif eşzamanlılık: Netflix concurrency-limits; Envoy adaptive concurrency filter.
- Outlier detection ve panic threshold: Envoy, Outlier detection.
- WAITAOF: Redis, WAITAOF komutu.
- Graceful shutdown: Kubernetes, Pod termination.