Sertaç Yıldırımsaha notları

Ana sayfa → Teknik

Queue Anti-Patterns

Sabah 09:14. Emir kuyruğunda 40 bin bekleyen mesaj var ve sayı artıyor. Servisler ayakta, CPU rahat, veritabanı iyi. Sebep tek bir şey: kuyruğun başındaki bir tane bozuk mesaj.

Özet
  • Poison message sonsuza kadar retry edilir. DLQ yoksa kuyruk kilitlenir.
  • Jitter’sız exponential backoff, sorunu ritme sokar. Herkes aynı anda tekrar dener.
  • Head-of-line blocking: büyük iş küçükleri bekletir. Ayrı şerit aç ya da paralelliği artır.
  • Sıra bozulur (message reordering). En sağlam çözüm sıraya bağımlı olmayan mesaj tasarlamak: delta değil, mutlak değer gönder.
  • Sınırsız buffer bir çözüm değil, sadece geciktirilmiş bir bellek çökmesi.

1. Poison message: kuyruğu kilitleyen tek satır

Bir emir mesajı geldi ve ayrıştırılamıyor. Belki bir alan eksik, belki volume alanında sayı yerine boş string var, belki karakter kodlaması bozuk. Tüketici hata alıyor, ack göndermiyor, mesaj kuyrukta kalıyor ve yeniden teslim ediliyor.

Sonra aynı şey oluyor. Ve tekrar. Ve tekrar. Bu, saniyede yüzlerce kez dönen bir döngü ve arkadaki 40 bin emir sırada bekliyor.

En sinsi tarafı

Hiçbir servis çökmez. Sağlık kontrolleri yeşil. CPU normal. Grafiklerde tek anormal şey kuyruk uzunluğu — ki ona bakmıyorsan olayı ancak müşteri aradığında öğreniyorsun.

İlaç: dead letter queue (DLQ)

Kural basit: bir mesaj N kez retry edildiyse artık denenmez, kenara alınır.

Deneme sayacı mesajın üstünde
void isle(Mesaj m) {
    try {
        emriIsle(m);
        ack(m);
    } catch (KaliciHata e) {          // ayristirma, dogrulama, is kurali
        dlqYaz(m, e);                 // ← hic tekrar deneme, direkt kenara
        ack(m);
    } catch (GeciciHata e) {          // ag, zaman asimi, 503
        if (m.denemeSayisi >= 5) {
            dlqYaz(m, e);
            ack(m);
        } else {
            m.denemeSayisi++;
            gecikmeliTekrarKuyruguna(m, bekleme(m.denemeSayisi));
            ack(m);                   // asil kuyruktan CIKAR
        }
    }
}

İki ayrım kritik ve çoğu ekip birincisini atlıyor:

  • Kalıcı hata hiç denenmez. Ayrıştırılamayan bir mesaj beşinci denemede de ayrıştırılamayacak. Tekrar denemek sadece zaman ve kaynak yakıyor. Sunucudan 400 aldıysan tekrar deneme; 503 aldıysan de.
  • Tekrar denenecek mesaj asıl kuyruktan çıkarılır. Ayrı bir gecikmeli kuyruğa taşınır. Yerinde bırakırsan tekrar denemek ile sıradakileri bekletmek aynı şey olur.
DLQ tek başına çözüm değil

Kimsenin bakmadığı bir ölü mektup kuyruğu (dead letter queue), mesajları çöpe atmakla aynı şey — üstelik “biz önlem aldık” rahatlığıyla. Üç şey şart:

  • Sayaç: dlq_mesaj_toplam metriği.
  • Alarm: DLQ’ya tek bir emir düşerse haber ver. Emir sayısı sıfır olmalı; bir bile anormal.
  • Geri oynatma: Düzelttikten sonra mesajları asıl kuyruğa geri basacak bir düğme. Elle SQL yazarak yapılan iş, gece 3’te yapılmaz.

2. Exponential backoff — ve jitter’ın önemi

Geçici hatalarda hemen tekrar denemek işi kötüleştirir: karşı taraf zaten zorlanıyorsa, ikinci istek onu daha da zorlar. Klasik cevap her denemede beklemeyi katlamak:

Temel şema
100 ms → 200 ms → 400 ms → 800 ms → DLQ

Doğru ama eksik. Şunu düşün: köprü bir saniyeliğine düştü ve 800 emir aynı anda hata aldı. Hepsi 100 ms bekleyip aynı anda tekrar deniyor. Hepsi yine hata alıyor, hepsi 200 ms bekleyip yine aynı anda deniyor.

Yani rastgele bir yük yerine, senkronize dalgalar üretiyorsun. Köprü toparlanmaya çalışırken düzenli aralıklarla 800’lük tokat yiyor.

Jitter: dalgayı zamana yay
long bekleme(int deneme) {
    long taban = Math.min(TABAN_MS * (1L << deneme), TAVAN_MS);  // 100,200,400,800...
    // "full jitter": 0 ile taban arasinda rastgele
    return ThreadLocalRandom.current().nextLong(taban + 1);
}

800 emir artık 0–800 ms arasına yayılıyor. Köprü sabit bir yükle karşılaşıyor ve nefes alabiliyor. Tek satır ve etkisi grafiklerde anında görünüyor.

Emirde bir şey daha var: son kullanma

Fiyat 5 saniyede değişir. Bir emri 800 ms bekletip tekrar denemek makul, ama 30 saniye sonra göndermek makul değil — artık müşterinin istediği fiyat orada değil.

O yüzden emir mesajlarında gecerlilik_bitis alanı var. Tekrar denemeden önce bakılıyor: süresi geçmişse denenmiyor, doğrudan reddedilip müşteriye bildiriliyor. Geç gelen doğru cevap, yanlış cevaptır.

3. Head-of-line blocking: büyük emir küçükleri bekletir

Tek bir Redis stream var, bütün emirler oradan akıyor. Kurumsal bir müşteri 1000 lotluk bir emir gönderiyor; bu emir 40 parçaya bölünüp köprülere dağıtılıyor ve işlenmesi 2 saniye sürüyor.

O 2 saniye boyunca arkadaki 0,01 lotluk emirler bekliyor. Grafikte gecikme ortalaması iyi görünüyor ama %99’luk dilim tavan yapıyor.

ÇözümNasılNe zaman
Ayrı şerit Büyük emirler ayrı kuyruğa, ayrı tüketiciye En basit ve en etkilisi. İlk denenecek.
Bölümleme Hesap kimliğine göre partition; bir hesabın büyük emri sadece kendi partition’ını bekletir Aynı hesabın sırası korunmalıysa (detay)
Paralellik Tek tüketici yerine N tüketici Sıra önemli değilse; en ucuzu
Parçalama Büyük işi mesaj düzeyinde küçük parçalara böl İş bölünebiliyorsa; kuyruğu hiç şişirmez

Bizim yaptığımız birinci ve dördüncünün karışımıydı: 100 lot üstü emirler ayrı şeride alındı ve orada parçalara bölünüp işlendi. Küçük emirlerin gecikmesi %99’luk dilimde 2100 ms’den 40 ms’ye indi — ortalama zaten iyiydi, sorun hep uçlardaydı (tail latency).

Ortalama gecikmeye bakan ekip, tıkanmayı hiç göremez. Tıkanma her zaman uçlarda yaşar.

4. Message reordering: #3 önce, #1 sonra

Kısmi fill (partial fill)’ler sırayla gönderiliyor: #1 (10 lot), #2 (15 lot), #3 (5 lot). Sana geliş sırası: #3, #1, #2. Kümülatif hesap anlık olarak yanlış çıkıyor ve arayüzde müşteri tuhaf sayılar görüyor.

Neden olur?

  • Mesajlar farklı bölümlere düştü, farklı tüketiciler işledi.
  • #1 bir kez hata alıp tekrar denendi, bu sırada #2 ve #3 geçti.
  • Ağda farklı yollar, farklı gecikmeler.

Üç çözüm, en zayıftan en sağlama

a) Bölümlemeyle sırayı koru

Aynı emrin bütün mesajları aynı bölüme gitsin (anahtar: order_id). Böylece tek bir tüketici sırayla işler. Basit ve etkili — ama yeniden dengeleme anında garanti yine kayboluyor.

b) Sıra numarası + kısa tampon
// Beklenen: 1. Gelen: 3.
if (m.sira > beklenenSira) {
    bekleyenler.put(m.sira, m);          // kenarda tut
    if (bekleyenler.enEski() > 200) {    // 200 ms'den fazla bekledi
        uyar("eksik mesaj: " + beklenenSira);
        beklenenSira = bekleyenler.ilkAnahtar();   // atla, kilitlenme
    }
    return;
}
isle(m);
beklenenSira++;
bekleyendekileriBosalt();

Çalışıyor ama bir kusuru var: eksik mesajı beklerken sen de tıkanıyorsun. O yüzden zaman aşımı (timeout) şart, yoksa 3. bölümdeki hastalığı kendi elinle üretirsin.

c) En sağlamı: sıraya ihtiyaç duymayan mesaj tasarla
// Kirilgan — sira sart, delta gonderiyor
{ "order_id": 991, "fill_no": 2, "eklenen_lot": 15 }

// Saglam — sira onemsiz, mutlak deger gonderiyor
{ "order_id": 991, "fill_no": 2,
  "kumulatif_lot": 25, "kumulatif_tutar": 27512.50,
  "kalan_lot": 5 }

İkincisinde #3 önce gelirse ne olur? Kümülatif değeri yazarsın. Sonra #1 gelir, daha küçük bir fill_no taşıdığı için yok sayılır. Sonuç yine doğru.

UPDATE pozisyonlar
   SET kumulatif_lot = :kumulatif_lot,
       son_fill_no   = :fill_no
 WHERE order_id = 991
   AND son_fill_no < :fill_no;     -- ← eski mesaj yazamaz

Bu, fencing token ile aynı fikir: doğruluğu zamanlamaya değil sıraya bağla, ve sırayı verinin içine göm.

Aradaki fark tasarım seviyesinde: delta gönderen sistem her mesajın gelmesine ve sırasına muhtaç; mutlak değer gönderen sistem tek bir mesajla kendini toparlıyor. Yeniden bir mesaj şeması tasarlarken sorulacak soru şu: “Bu mesajı tek başına alsam, doğru duruma varabilir miyim?”

5. Backpressure: 50 bin tick, yetişemeyen consumer

Piyasa hareketlendi. Saniyede 50 bin tick geliyor, streaming-engine 30 bin işleyebiliyor. Aradaki 20 bin nereye gidiyor? Üç seçeneğin var ve üçüncüsü bir seçenek değil:

SeçenekSonuçNe zaman doğru
Düşür Veri kaybı, ama sistem ayakta Fiyat tick’i, metrik, canlı yayın
Üreticiyi yavaşlat Gecikme artar, kayıp olmaz Emir, fill, bakiye
Sınırsız biriktir Bellek biter, süreç ölür Hiçbir zaman

Üçüncüsü en çok yapılanı, çünkü kod yazarken en görünmezi. Sınırsız bir kuyruk oluşturmak tek satır ve o satır, sistemin ne zaman öleceğini belirsizleştiriyor. Her kuyruğa bir üst sınır koy ve sınır dolduğunda ne olacağını bilinçli seç.

Fiyat için: drop değil, conflation

Fiyat tarafında güzel bir numara var. EURUSD için 40 tick birikmişse, 40’ını da işlemenin bir anlamı yok — müşteri sadece sonuncuyu görecek. Bu yüzden kuyrukta biriktirmek yerine sembol başına son değeri tutuyorsun:

Conflation: en son fiyat kazanır
// Kuyruk degil, sembol basina tek slot
ConcurrentHashMap<String, Kotasyon> sonFiyat = new ConcurrentHashMap<>();

// Uretici: uzerine yaz, biriktirme
sonFiyat.put(k.sembol, k);

// Tuketici: yetisebildigi hizda oku
for (var e : sonFiyat.entrySet()) {
    yayinla(e.getValue());
}

Bellek sabit: sembol sayısı kadar. Yük ne olursa olsun büyümüyor ve müşteri her zaman en güncel fiyatı görüyor — ara tick’leri kaçırmak zaten sorun değil.

Emir için: bounded queue ve dürüst ret

Emir düşürülemez. Ama sınırsız da biriktirilemez. Doğru davranış, kuyruk dolduğunda yeni emri açıkça reddetmek:

Dolduğunda ne olacağını sen seç
BlockingQueue<Emir> kuyruk = new ArrayBlockingQueue<>(10_000);

if (!kuyruk.offer(emir)) {                 // dolu
    metrik.artir("emir_reddedildi_kuyruk_dolu");
    return hata("SYSTEM_BUSY, lutfen tekrar deneyin");
}

Kulağa kötü geliyor ama alternatifi daha kötü: emri kabul edip 40 saniye sonra eski fiyattan açmak. Hızlı ret, geç kabulden iyidir — müşteri en azından ne olduğunu biliyor ve kararını kendisi veriyor.

Ne izlemeli?

Bu beş hastalığı gösteren metrikler
  • Kuyruk derinliği — artıyorsa tüketici yetişemiyor.
  • En eski mesajın yaşı — derinlikten daha iyi bir sinyal; tıkanmayı doğrudan gösterir.
  • DLQ sayacı — emir tarafında sıfır olmalı, alarm bağlı olmalı.
  • Tekrar deneme sayacı — zıplarsa dışarıda bir sorun var demektir.
  • Gecikmenin %99’luk dilimi — ortalama değil. Tıkanma sadece burada görünür.
  • Düşürülen tick sayısı — kayıp bilinçliyse bile ölçülmeli.

Kontrol listesi AI agent görev listesi

Her kuyruk için
  • Kalıcı hata ile geçici hata ayrılıyor mu? (Kalıcı olan hiç denenmemeli.)
  • Deneme üst sınırı var mı, sonrasında DLQ’ya gidiyor mu?
  • DLQ’nun sayacı ve alarmı var mı?
  • DLQ’dan geri oynatma yolu var mı? (Elle SQL sayılmaz.)
  • Geri çekilmede jitter var mı?
  • Emirlerin son kullanma süresi kontrol ediliyor mu?
  • Büyük işler küçükleri bekletiyor mu? Ayrı şerit var mı?
  • Mesajlar mutlak değer mi taşıyor, delta mı?
  • Her kuyruğun bir üst sınırı var mı?
  • Sınır dolduğunda ne oluyor — düşürme mi, ret mi? Bilinçli mi seçildi?

Sonuç

Kuyruk, sistemler arasına konan bir yastık. Ama yastık aynı zamanda bir gizleyici: arkasındaki yavaşlığı bir süre saklıyor, sonra hepsini birden önüne koyuyor.

Baştaki 40 bin mesajlık kuyruk, tek bir bozuk emirden çıkmıştı. Düzeltmesi on beş satırdı. Asıl ders, o on beş satırın ilk günden yazılması gerektiğiydi — çünkü zehirli mesaj bir ihtimal değil, bir kesinlik.