Sertaç Yıldırımsaha notları

Ana sayfa → Teknik

Failure Modes

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 beklenenin içinde. Sistem kendi kendini öldürdü.

Özet
  • Yavaşlama, çökmekten 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.
  • Timeout olmadan circuit breaker çalışmaz. Sıralama önemli: önce timeout, sonra breaker.
  • Failover veri kaybeder. Asenkron çoğaltmada son saniyelerin yazımı gider; kritik durumun tek kopyası önbellekte olmamalı.

1. Thundering herd: 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.
  • 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.

Senkronizasyonu ne üretiyor?
  • 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.
İlaç: her şeye biraz rastgelelik
// 1) Zamanlanmis is: sabit saniye yerine 0-30 sn kaydir
long kaydir = ThreadLocalRandom.current().nextLong(30_000);
zamanlayici.schedule(is, acilisZamani + kaydir);

// 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
int sira = podIndexiniOku();            // StatefulSet ordinal ya da hash
Thread.sleep(sira * 500L);              // 0, 500, 1000, 1500 ms...

Üçü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.

2. Cache stampede: aynı anda 200 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 200 gateway aynı anda okuyor, 200’ü de “yok” cevabı alıyor ve 200’ü birden hesaplamaya başlıyor.

Hesaplama pahalı değil — ama 200 katı pahalı. Ve hesap bitene kadar süre yine dolmuş oluyor, döngü kendini besliyor.

Üç çözüm

a) Tek hesaplayan: kilit
deger = redis.get(anahtar);
if (deger != null) return deger;

// Sadece bir tanesi kilidi alsin
if (redis.set(anahtar + ":kilit", ben, "NX", "PX", 5000)) {
    deger = pahaliHesapla();
    redis.setex(anahtar, ttlSapmali(), deger);
    redis.del(anahtar + ":kilit");
    return deger;
} else {
    // Digerleri: eski degeri kullan (ayri, uzun omurlu anahtar)
    return redis.get(anahtar + ":eski");
}

Çalışıyor ama iki anahtar yönetmen gerekiyor ve “eski değer ne kadar eski olabilir” sorusunu cevaplaman gerekiyor.

b) Probabilistic early expiration — en zarifi
// Deger ile birlikte "hesaplamasi ne kadar surdu" ve "ne zaman doluyor" saklanir.
// Sure dolmaya yaklastikca YENILEME OLASILIGI artar.

long kalan = bitis - simdi();
double esik = -hesapSuresi * BETA * Math.log(Math.random());   // BETA ~ 1.0

if (kalan < esik) {
    deger = pahaliHesapla();      // erken, KENDILIGINDEN, tek basina
    kaydet(deger);
}
return deger;

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.

c) Hiç süre koyma, arka planda tazele

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.

3. Cascade failure: yavaşlığın bulaşması

Şimdi asıl olay. Baştaki hikâyenin devamı şöyle işledi:

AdımNe oldu
1Redis, açılış çığı yüzünden yavaşladı. Sorgular 2 ms yerine 400 ms.
2Gateway’ler her istekte Redis bekliyor. İş parçacıkları meşgul.
3Gelen istekler işlenemiyor, iç kuyruk büyüyor.
4Kuyruk sınırsız olduğu için bellek doluyor.
5Bir gateway OOM ile ölüyor.
6Yük dengeleyici (load balancer) onun trafiğini kalanlara dağıtıyor.
7Kalanlar zaten zordaydı; şimdi daha fazla yükle onlar da ölüyor.

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.

Çökme dürüsttür: hemen belli olur. Yavaşlama sinsidir: bütün sistemi kendine benzetir.

Üç savunma, sırayla

a) Her dış çağrının zaman aşımı (timeout) olsun
// Varsayilan zaman asimi genelde YOK ya da 30+ saniye.
// Trade tarafinda 30 saniye = sonsuz demek.
jedis.setTimeout(200);              // Redis: 200 ms
httpClient.timeout(Duration.ofMillis(800));   // MT5 koprusu
db.setQueryTimeout(3);              // saniye

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.

b) Her kuyruğun sınırı olsun

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.

c) Load shedding
// Kuyruk %80 doluysa, kritik olmayan isteklere hemen 503 don
if (kuyruk.doluluk() > 0.8 && !istek.kritikMi()) {
    return hata(503, "Retry-After: 2");
}

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ı.

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 parçacık var. 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.

Üç durum
  • 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.
Ayarlarken dikkat edilecekler
CircuitBreakerConfig.custom()
    .slidingWindowSize(100)              // son 100 cagri
    .minimumNumberOfCalls(20)            // ← 20 cagri olmadan karar verme
    .failureRateThreshold(50)            // %50 hata -> ac
    .slowCallDurationThreshold(Duration.ofMillis(800))
    .slowCallRateThreshold(50)           // ← YAVAS cagri da hata sayilir
    .waitDurationInOpenState(Duration.ofSeconds(10))
    .permittedNumberOfCallsInHalfOpenState(5)
    .build();
  • minimumNumberOfCalls olmazsa 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.
En çok atlanan soru: devre açıkken ne dönüyorsun?

Devre kesici koymak yetmiyor; alternatif davranışı tasarlamak gerekiyor. Trade tarafında cevap veriye göre değişiyor:

  • Fiyat sorgusu: son bilinen fiyatı yaşıyla birlikte dön. “3 sn önceki fiyat” hiç fiyat olmamasından iyi.
  • 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ı. 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.

Yüzeysel sağlık kontrolü (health check)
GET /health
→ 200 {"status":"UP"}

// Kodu:
return ok();      // ← surec ayakta, o kadar

Bu sadece “süreç çalışıyor” diyor. Faydası çok sınırlı.

İşe bakan sağlık kontrolü
GET /ready
{
  "son_tick_yasi_ms": 4200,     // ← 4 saniyedir tick islemedi
  "kuyruk_derinligi": 48000,
  "gc_duraklama_p99_ms": 1800,
  "status": "DEGRADED"
}

// Kural: son_tick_yasi > 1000 ise READY DEGIL

Bu, gerçekten iş yapıp yapmadığını söylüyor.

Ama burada bir tuzak var

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.

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.

Buna ek olarak yük dengeleyici tarafında aykırı değer tespiti (outlier detection) çok işe yarıyor: “bu düğüm diğerlerinden 5 kat yavaş” kuralı, sağlık kontrolü OK dese bile o düğümü geçici olarak devre dışı bırakıyor. Gri arızaya karşı en pratik savunma bu — çünkü sağlığı mutlak değil göreli ölçüyor.

6. Failover: devraldı ama neyi kaybetti?

Redis master çöktü, Sentinel bir replikayı terfi ettirdi, sistem 8 saniyede geri geldi. Herkes rahat. Ama:

Sessiz kayıp

Çoğaltma asenkron. Master, yazımı kabul edip istemciye “tamam” dedikten sonra replikaya gönderiyor. Çöktüğü anda son 1–2 saniyenin yazımları 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.

Bunun tek gerçek çözümü mimari:

  1. 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.
  2. Failover sonrası mutabakat otomatik olsun. Terfi tespit edilince bir iş tetiklensin: son N dakikanın emirlerini köprüden çekip yerel durumla karşılaştırsın, farkları rapor etsin. Elle yapılmaya çalışılırsa yapılmıyor.
  3. Terfi anı olay olarak yazılsın. “Şu saatte failover oldu” kaydı, üç gün sonra “bu emir neden kayıp” sorusunun tek cevabı oluyor.
Bir de şu var: aynı anda iki master

Ağ bölünmesinde eski master hâlâ kendini master sanabilir ve yazım kabul etmeye devam edebilir. Bu, leader election'daki split-brain sorununun aynısı ve çaresi de aynı: kritik yazımlara bir dönem numarası koy ve eskisini reddet.

Hepsi bir arada: neyin ne zaman devreye girdiği

BelirtiMuhtemel modİlk bakılacak
Belirli saatlerde tepe yüklerThundering herdCron saatleri, TTL dağılımı
Düzenli aralıklarla CPU zıplamasıCache stampedeTTL değerleri, aynı anda ölen anahtarlar
Bir servis yavaşladı, hepsi çöktüCascade failureZaman aşımı var mı, kuyruk sınırlı mı
Thread pool tükeniyorCircuit breaker yokDış çağrıların zaman aşımı
Health check yeşil ama müşteri şikayetçiGray failureHazırlık kontrolü (readiness probe) gerçek işe bakıyor mu
Kısa kesintiden sonra tutarsız veriFailover data lossÇoğaltma senkron mu, mutabakat var mı

Kontrol listesi AI agent görev listesi

Bugün bakılacaklar
  • Bütün dış çağrıların zaman aşımı var mı? (Varsayılan genelde yok.)
  • Bütün kuyrukların üst sınırı var mı?
  • Zamanlanmış işler aynı saniyede mi başlıyor?
  • TTL değerlerinde sapma var mı, hepsi sabit mi?
  • Devre kesici yavaş çağrıyı da hata sayıyor mu?
  • 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?
  • Hazırlık kontrolü bağımlılığa bağlıysa hepsi birden düşer mi?
  • Kritik durumun tek kopyası önbellekte mi duruyor?
  • Failover sonrası otomatik mutabakat var mı?

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; sadece kötü bir sabahta görünüyorlar.

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.