Sertaç Yıldırımsaha notları

Ana sayfa → Teknik

Mesaj Kaç Kez Gelir?

Salı günü: fill mesajı iki kez geldi, çift kayıt açıldı, bakiye yanlış. Perşembe günü: bir price tick kayboldu, streaming-engine eski fiyatla çalıştı, emir olmayan bir seviyeden açıldı. İki farklı hastalık ve tek bir ilaç aramak boşuna.

Özet
  • Üç garanti var ve üçü de bir şeyden vazgeçiyor. Ya kayıp, ya tekrar, ya gecikme.
  • Exactly-once teslim yok. Olan şey: at-least-once teslim + tekrarları eleyen tüketici.
  • Kayıp kabul edilebilir olabilir — fiyat tick’i için evet, fill için asla.
  • Tekrar kaçınılmaz. O yüzden her tüketici, aynı mesajı iki kez görmeye hazır olmalı.

Neden garanti seçmek zorundasın?

Şu basit soruyla başlayalım: mesajı gönderdin, cevap gelmedi. Ne oldu?

  • Mesaj hiç gitmedi.
  • Mesaj gitti, işlendi, ama onay dönerken kayboldu.

Gönderen tarafta bu ikisi birbirinden ayırt edilemez. İkisi de aynı görünüyor: sessizlik. Ve verebileceğin sadece iki karar var:

  • Tekrar gönder → mesaj kaybolmaz ama iki kez işlenebilir. Bu at-least-once.
  • Gönderme → iki kez işlenmez ama kaybolabilir. Bu at-most-once.

Üçüncü bir seçenek yok. Bütün konu bu tek belirsizlikten çıkıyor.

GarantiSöz verdiğiBedeliTrade’de nerede
At-most-once Asla iki kez gelmez Kaybolabilir Fiyat tick’i (UDP multicast), metrik, ısı haritası
At-least-once Asla kaybolmaz Tekrar gelebilir Emir, fill, pozisyon değişimi, bakiye hareketi
Exactly-once Tam bir kez işlenir Ek altyapı + gecikme Yukarıdakinin üstüne tüketici tarafında kurulan şey

At-most-once: kaybolan tick meselesi

Fiyat akışı genelde UDP üzerinden gelir ve UDP hiçbir söz vermez: paket düşerse düşer, kimse tekrar göndermez. İlk duyduğunda insana yanlış geliyor — niye TCP kullanmıyoruz?

Çünkü tick’in tekrarı işe yaramaz. 50 ms önceki EURUSD fiyatını yeniden almanın hiçbir değeri yok; zaten yenisi geldi. TCP’nin tekrar denemesi için harcadığı süre, veriyi kurtarmak yerine bütün akışı geciktirir. Kayıp burada kabul edilebilir; gecikme değil.

Ama şu tuzağa dikkat

Tick kayboldu ve streaming-engine elindeki son fiyatla çalışmaya devam etti. Piyasa hareket etti, senin fiyatın yerinde durdu ve o eski fiyattan emir açıldı. Buna stale quote deniyor ve müşteriye açıklaması olmayan bir zarar üretiyor.

Asıl sorun kaybın kendisi değil, kaybın fark edilmemesi.

Çözüm: her fiyatın bir yaşı olsun
class Kotasyon {
    String  sembol;
    double  bid, ask;
    long    kaynakZamani;   // saglayicinin damgasi
    long    varisZamani;    // bizim aldigimiz an
    long    sira;           // saglayicidan gelen artan numara
}

// Emir acmadan ONCE:
long yas = simdi() - kotasyon.varisZamani;
if (yas > STALE_ESIGI_MS) {          // orn. 500 ms
    reddet("stale quote: " + yas + " ms");
}

Ve bir de kayıp ölçülmeli: sağlayıcı artan bir sıra numarası veriyorsa atlanan numaraları say. tick_kayip_toplam diye bir metrik, “ağ iyi mi” sorusunu tahminden çıkarıp veriye bağlıyor. Kaybın kabul edilebilir olması, ölçülmemesi anlamına gelmiyor.

At-least-once: iki kez gelen fill

Şimdi asıl mesele. Emir, fill, bakiye hareketi — bunların kaybolması kabul edilemez, dolayısıyla at-least-once kullanıyorsun. Yani tekrar gelecek.

Nasıl oluyor, tek tek:

SenaryoOlan
Onay kayboldu Köprü fill’i gönderdi, sen işledin, ack dönerken bağlantı koptu. Köprü tekrar gönderiyor.
Tüketici işlerken çöktü Mesajı aldın, veritabanına yazdın, ack göndermeden pod öldü. Mesaj kuyrukta duruyor, yeniden geliyor.
Kullanıcı iki kez bastı Zaman aşımı (timeout) gördü, sabırsızlandı, tekrar tıkladı. İki ayrı emir isteği.
Yeniden dengeleme (rebalancing) Worker öldü, işi devralındı, yarım kalan mesaj baştan işleniyor.

Dördü de normal işleyiş. Hiçbiri hata değil. Yani “tekrar gelmesin” diye uğraşmak yanlış yol; doğru yol “iki kez gelirse ne olacak” sorusuna cevap vermek.

Mesajın iki kez gelmesini engelleyemezsin. İkinci gelişin bir şey değiştirmemesini sağlayabilirsin.

Exactly-once neden bir efsane (ve gerçekte ne var)

“Exactly-once destekliyoruz” cümlesini duyduğunda sorulacak soru şu: teslimde mi, işlemede mi?

  • Exactly-once delivery (tam bir kez teslim): mümkün değil. Yukarıdaki belirsizlik — onay mı kayboldu, mesaj mı — ortadan kalkmıyor.
  • Exactly-once processing (tam bir kez işleme): mümkün. Yöntemi de belli: at-least-once teslim + tekrarları eleyen tüketici.

Kafka’nın exactly-once’ı da bu ikinciye giriyor ve bir şartı var: hem okuma hem yazma Kafka’nın içinde olmalı. Kafka’dan okuyup PostgreSQL’e yazıyorsan o garanti seni kapsamıyor — orada dual write sorununa girmiş oluyorsun.

İlaç 1: Idempotency key (request path)

Emri gönderen taraf, her mantıksal emir için tekil bir anahtar üretir ve tekrar denemelerde aynı anahtarı gönderir.

Sunucu tarafı
-- Anahtar tablosu, tekil kisit sart
CREATE TABLE istek_anahtarlari (
  anahtar    text PRIMARY KEY,       -- client_order_id
  hesap_id   bigint NOT NULL,
  sonuc      jsonb,                  -- ilk cevabin kopyasi
  olusma     timestamptz NOT NULL DEFAULT now()
);

BEGIN;
  INSERT INTO istek_anahtarlari (anahtar, hesap_id)
  VALUES (:client_order_id, :hesap)
  ON CONFLICT (anahtar) DO NOTHING
  RETURNING anahtar;
  -- Satir donmediyse: bu istek daha once islendi.
  --   -> ISLEM YAPMA, kayitli sonucu don.

  -- Satir dondiyse: ilk kez goruyoruz, emri ac
  INSERT INTO emirler (...) VALUES (...);
  UPDATE istek_anahtarlari SET sonuc = :cevap WHERE anahtar = :client_order_id;
COMMIT;

İki ince nokta var ve ikisi de gerçek olaylardan öğrenildi:

  • Anahtarı istemci üretmeli, sen değil. Sunucuda üretirsen her tekrar denemede yeni anahtar çıkar ve koruma hiç çalışmaz.
  • İlk cevabı sakla ve aynısını dön. “Zaten var” diye hata dönersen istemci bunu başarısızlık sanıp yine dener. İkinci istek, birincinin cevabını almalı.

İlaç 2: Idempotent consumer (event path)

Kuyruktan gelen fill bildirimi için istemci yok; anahtarı olayın kendisi taşıyor (fill_id, deal_id, event_id). Tüketici, bu kimliği görüp görmediğine bakar.

Tek transaction, iki iş
BEGIN;
  INSERT INTO islenen_olaylar (olay_id, tur)
  VALUES (:fill_id, 'FILL')
  ON CONFLICT (olay_id) DO NOTHING
  RETURNING olay_id;
  -- Satir donmediyse: bu fill zaten islenmis -> COMMIT ve cik

  -- Ilk kez: asil isi yap
  INSERT INTO fill_kayitlari (...) VALUES (...);
  UPDATE pozisyonlar
     SET lot = lot + :lot, ort_fiyat = ...
   WHERE id = :pozisyon_id;
COMMIT;

Kritik olan şu: dedup kaydı ile asıl iş aynı transaction’da. Ayrı yaparsan aradaki çökme, “işlendi işaretlendi ama iş yapılmadı” durumunu üretir — ki bu, çift işlemekten daha kötüdür: kayıp veriyi kimse fark etmez.

Dedup tablosu sonsuza kadar büyümez

Günde milyonlarca olay geliyorsa bu tablo bir yıl sonra ana yükün kaynağı olur. Pratik yaklaşım:

  • Bir pencere belirle. Tekrarlar dakikalar içinde gelir, aylar sonra değil. 7 gün fazlasıyla yeterli.
  • Eskiyi temizle. Günlük bir iş: DELETE FROM islenen_olaylar WHERE olusma < now() - interval '7 days'. Tarih alanına indeks koy, yoksa temizlik işi tabloyu kilitler.
  • Pencere dışını da düşün. Pencere dolduktan sonra gelen bir tekrar yeniden işlenir. Kabul edilemezse asıl tabloda da tekil kısıt (unique constraint) olsun — ikinci savunma hattı.

Peki hangi veri için hangisi?

Karar vermenin en kolay yolu tek soru: bu mesajı kaybetmek mi daha kötü, iki kez işlemek mi?

Kayıp daha kötü → at-least-once + dedup
  • Emir açma / iptal
  • Fill, kısmi fill (partial fill)
  • Bakiye ve teminat hareketi
  • Mutabakat kaydı
Tekrar daha kötü ya da anlamsız → at-most-once
  • Fiyat tick’i, derinlik güncellemesi
  • Anlık kâr/zarar yayını
  • Sistem metrikleri
  • Arayüz canlı güncellemeleri

Dikkat: aynı sistemde ikisi birden olur ve olmalıdır. Fiyat akışını at-least-once yapmak gecikmeyi öldürür; emirleri at-most-once yapmak parayı öldürür. Tek bir garantiye standart diye sarılmak, iki taraftan birinde mutlaka yanlış olmak demek.

Sahadan: çift fill’i nasıl kapattık

Baştaki olaya dönelim. Aynı fill iki kez geldi ve iki kayıt açıldı. İlk refleks “köprüyü düzeltelim, iki kez göndermesin” oldu. Yanlış refleks: köprü doğru davranıyordu, onay alamadığı için tekrar gönderiyordu.

Yaptığımız üç şey:

  1. deal_id üzerinde tekil kısıt. Uygulamada bir kontrol değil, veritabanında bir yasak. Kaç kopya çalışırsa çalışsın kural tek yerde.
  2. Dedup ile pozisyon güncellemesi aynı transaction’a alındı. Öncesinde ayrıydılar ve arada bir çökme “işlendi ama yazılmadı” üretmişti — ki bu çift kayıttan daha zor fark edilir.
  3. Tekrar sayacı metriğe döküldü. tekrar_gelen_olay_toplam sıfır olmuyor, olmasını da beklemiyoruz. Ama sayı zıplarsa bu genelde köprüde ya da ağda bir sorunun ilk işareti oluyor. Yani dedup sadece koruma değil, aynı zamanda bir sensör.

Kontrol listesi AI agent görev listesi

Her mesaj akışı için
  • Bu akışta kayıp mı daha kötü, tekrar mı? Yazılı cevabı var mı?
  • At-most-once ise: verinin bir yaşı ve eskime eşiği var mı?
  • Kayıp ölçülüyor mu (atlanan sıra numarası)?
  • At-least-once ise: tüketici aynı olayı iki kez görmeye hazır mı?
  • Dedup kaydı ile asıl iş aynı transaction’da mı?
  • Tekillik uygulamada mı kontrol ediliyor, veritabanında mı? (İkincisi olmalı.)
  • Idempotency anahtarını istemci mi üretiyor?
  • Tekrar eden isteğe ilk cevabın aynısı mı dönüyor?
  • Dedup tablosunun temizlik işi var mı?

Sonuç

Teslim garantisi seçmek teknik bir tercih gibi görünüyor ama aslında ticari bir tercih: neyi kaybetmeyi göze alıyorsun? Fiyatta kaybı göze alıyorsun çünkü yenisi geliyor. Fill’de göze almıyorsun çünkü para.

Ve aklında tek cümle kalsın: exactly-once bir teslim garantisi değil, bir tüketici tasarımıdır. Kimse sana onu satamaz; onu sen yazarsın, üstelik sekiz satırlık bir ON CONFLICT DO NOTHING ile.