Sertaç Yıldırım saha 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;       // fiyat icin kabul; fill/bakiye tarafinda ASLA double, BigDecimal/numeric
    long    kaynakZamani;   // saglayicinin damgasi
    long    varisZamani;    // bizim aldigimiz an
    long    sira;           // saglayicidan gelen artan numara
}

// Tick gelince: ONCE ele, SONRA bosluga bak (sira guvenilir anahtar, zaman degil)
if (gelen.sira <= son.sira) return;                 // tekrar ya da gec gelen eski tick: at
if (gelen.sira > son.sira + 1) {                    // yalnizca ILERI bosluk (negatif sayim yok)
    tickKayipToplam.arttir(gelen.sira - son.sira - 1);
    if (deltaAkisi) supheli.put(sembol, true);      // delta: snapshot gelene kadar
}
if (!deltaAkisi) supheli.put(sembol, false);        // tam kotasyon: yeni tick guncel fiyat, suphe biter
son = gelen;
sonCanlilik.put(sembol, simdi());

// Heartbeat gelince: fiyat degismedi ama saglayici canli.
// Heartbeat de sira tasir (cogu protokolde "bir sonraki beklenen numara"): bosluk varsa
// canlilik GUNCELLENMEZ, yoksa UDP'de tick'ler duserken heartbeat'ler eski fiyati taze gosterir
if (hb.beklenenSira > son.sira + 1) {               // yalnizca ileri bosluk
    tickKayipToplam.arttir(hb.beklenenSira - son.sira - 1);
    son.sira = hb.beklenenSira - 1;                 // ayni bosluk bir daha sayilmasin
    supheli.put(sembol, true);                      // delta: snapshot'a kadar, tam kotasyon: sonraki tick'e kadar
} else {
    sonCanlilik.put(sembol, simdi());               // bosluk yoksa canli say
}

// Snapshot gelince: durum yeniden kuruldu, sira buradan devam
// (yeniden baglanma / gun basinda saglayici sirayi sifirlar; son.sira'yi burada ayarlamazsan
//  yukaridaki "gelen.sira <= son.sira" satiri gelen her tick'i atar)
supheli.put(sembol, false);
son.sira = snapshot.sira;

// Emir acmadan ONCE, uc kontrol:
long sessizlik = simdi() - sonCanlilik.get(sembol);            // tick VEYA bosluksuz heartbeat
if (sessizlik > STALE_ESIGI_MS)      reddet("stale quote: " + sessizlik + " ms");
long kaynakYasi = simdi() - kotasyon.kaynakZamani - saatOffset; // iki farkli saat: olculen offset dusulur
if (kaynakYasi > KAYNAK_ESIGI_MS)    reddet("stale at source");  // ayri, toleransli esik (orn. 2 sn)
if (supheli.get(sembol))             reddet("supheli: snapshot / yeni tick bekleniyor");

Dört ayrıntı:

  • Kayıp tick’in kendisi değil, arkasındaki sessizlik tehlikeli. Her tick tam bid/ask taşıyorsa, boşluğu fark ettiğin an güncel fiyat zaten elinde; kaçan fiyat bu emir için önemsiz. Tehlike, kayıptan sonra yeni tick gelmemesi — onu yaş kontrolü yakalar. Ama yaş, son tick’e değil son canlılığa bakmalı: sessiz piyasada (gece, tatil) sağlayıcının heartbeat’i de sonCanlilik’i günceller, yoksa değişmeyen meşru fiyatı stale diye reddedersin.
  • Şüpheli işaretini ne temizler, akışa bağlı. Order book gibi artımlı (delta) akışlarda tek kayıp delta, snapshot gelene kadar bütün durumu bozar; orada yalnızca snapshot temizler. Tam kotasyon akışında yeni tick zaten güncel fiyatı getirir, şüphe bir sonraki tick’le biter — ama heartbeat boşluk bildirmişse o tick gelene kadar emir açılmaz.
  • Eleme sıra numarasıyla, boşluk kontrolünden önce. Geç gelen eski bir tick önce elenmezse gelen.sira - son.sira - 1 negatif çıkar: kayıp sayacı azalır, sembol boşuna şüpheli olur.
  • kaynakZamani iki farklı saati karşılaştırır. Sağlayıcının saati ile seninki arasındaki kayma (clock skew) meşru tick’i reddettirir; ölçülen offset düşülür ve eşik varış yaşından ayrı, toleranslı tutulur.

tick_kayip_toplam metriği de “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ü (bridge — trading platformu ile bizim sistem arasındaki bağlantı servisi) 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 — ve idempotency anahtarı tıklama başına üretiliyorsa iki farklı anahtar, yani koruma yok.
Yeniden dengeleme (rebalancing) Worker öldü ya da ölçekleme oldu ya da max.poll.interval.ms aşıldı; partition başka bir worker’a devredildi, 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: DB yazımı ile offset commit iki ayrı sistemde ve arasında çökebilirsin. Bilinen iki çıkış var: offset’i de aynı DB transaction’ında saklamak, ya da aşağıdaki idempotent consumer. (Bunun akrabası olan “DB’ye yaz + event yayınla” sorunu ayrı bir yazı: dual write.)

İ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: tekillik HESAPLA BIRLIKTE
CREATE TABLE istek_anahtarlari (
  hesap_id    bigint NOT NULL,
  anahtar     text   NOT NULL,        -- client_order_id
  istek_hash  bytea  NOT NULL,        -- sembol+yon+lot+fiyat'in hash'i
  sonuc       jsonb,                  -- ilk cevabin kopyasi
  olusma      timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (hesap_id, anahtar)
);

BEGIN;
  INSERT INTO istek_anahtarlari (hesap_id, anahtar, istek_hash)
  VALUES (:hesap, :client_order_id, :hash)
  ON CONFLICT (hesap_id, anahtar) DO NOTHING
  RETURNING anahtar;
  -- Satir donmediyse: bu anahtar daha once goruldu.
  --   istek_hash ayniysa  -> ISLEM YAPMA, kayitli sonucu don.
  --   istek_hash farkliysa -> 422 "ayni anahtar, farkli istek" (sessizce eski cevabi DONME)

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

Dört ince nokta var ve hepsi gerçek olaylardan öğrenildi:

  • Anahtarı istemci üretmeli, sen değil. Sunucuda üretirsen her tekrar denemede yeni anahtar çıkar ve koruma hiç çalışmaz. Ve istemci onu mantıksal emir başına üretmeli — form açıldığında bir kez — tıklama başına değil; yoksa çift tıklama iki farklı anahtarla gelir.
  • Anahtar tek başına değil, hesapla birlikte tekil. FIX’teki ClOrdID de yalnızca oturum/firma içinde tekildir. Anahtar global PK olursa iki hesap aynı client_order_id’yi ürettiğinde ikincisi yanlış reddedilmekle kalmaz, birincinin kayıtlı cevabını alır: başka hesabın emir bilgisi. Bu bir güvenlik açığı.
  • Aynı anahtar, farklı içerik → hata. İstemci aynı anahtarla farklı lot ya da sembol gönderirse sessizce eski cevabı dönmek yanlış; isteğin hash’ini sakla, uyuşmazlıkta açık hata dön (Stripe ve benzerleri böyle yapıyor). Emir sisteminde “1 lot mu 10 lot mu” farkı sessiz kalamaz.
  • İ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ş
CREATE TABLE islenen_olaylar (
  tur      text NOT NULL,             -- 'FILL', 'CANCEL', ...
  olay_id  text NOT NULL,             -- execution kimligi (deal_id / exec_id)
  olusma   timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (tur, olay_id)          -- farkli turlerin kimlikleri cakisabilir
);

BEGIN;
  INSERT INTO islenen_olaylar (tur, olay_id)
  VALUES ('FILL', :fill_id)
  ON CONFLICT (tur, 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. Ayırırsan iki sıra da kötü: önce işaretleyip sonra iş yaparsan aradaki çökme “işlendi işaretlendi ama iş yapılmadı” üretir — kayıp, ve kimse fark etmez; önce iş yapıp sonra işaretlersen aynı çökme çift işlemeye döner. Tek transaction ikisini birden kapatıyor.

Anahtar execution kimliği olmalı, emir kimliği değil: bir emir birkaç kısmi fill ile dolabilir ve hepsi aynı order_id’yi taşır. MT5’teki deal_id gibi execution başına ayrı bir kimlik kullan; yoksa meşru kısmi fill’leri tekrar diye reddedersin.

Dedup tablosu sonsuza kadar büyümemeli

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

  • Pencereyi en uzun olası tekrardan uzun seç. Tekrarların çoğu dakikalar içinde gelir ama hepsi değil: DLQ’dan günler sonra yeniden işleme, uzun süre kapalı kalan consumer, topic’i baştan replay etmek. Kafka’nın varsayılan retention’ı 7 gün; pencere en az retention + DLQ bekleme süresi kadar olmalı. Bizde 30 gün.
  • Eskiyi temizle, ama tek DELETE ile değil. İndekssiz DELETE ... WHERE olusma < ... tabloyu kilitlemez; seq scan yapar, uzun bir transaction, yüksek I/O ve bloat üretir. Parçalı sil (PostgreSQL’de DELETE ... LIMIT yok, alt sorgu gerekir):
    DELETE FROM islenen_olaylar
     WHERE (tur, olay_id) IN (SELECT tur, olay_id FROM islenen_olaylar
                               WHERE olusma < now() - interval '30 days' LIMIT 10000);
    -- 0 satir donene kadar dongu, aralarda kisa bekleme
    -- olusma uzerinde indeks SART: yoksa her tur alt sorgu tabloyu bastan tarar,
    -- milyonlarca satirda tek buyuk DELETE'ten bile pahali olur
  • Partition’a geçeceksen PK’yi bozma. Eski partition’ı DROP etmek en ucuz temizlik ama PostgreSQL’de partition’lı tabloda PRIMARY KEY partition anahtarını içermek zorunda. olusma’ya göre bölersen PK (tur, olay_id, olusma) olur ve olusma DEFAULT now() olduğu için tekrar gelen olayın zamanı farklıdır: kısıt tekrarı hiç yakalamaz, dedup sessizce ölür. Doğrusu olayın kendi taşıdığı, tekrarında da aynı kalan damgayı (olay_zamani, execution zamanı) kolon yapıp ona göre bölmek ve PK’yi (tur, olay_id, olay_zamani) yapmak. Bunu yapamıyorsan partition’sız kal, yukarıdaki gibi parçalı sil.
  • 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 (boşlukta snapshot ile kurtarma şartıyla)
  • 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ı; o gün dedup diye bir şey yoktu. İ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 (ikincisi ayrı bir olay, birkaç hafta sonra):

  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ı. İlk sürümde dedup tablosuna yazıp sonra pozisyonu güncelliyorduk; aradaki bir çökme “işlendi ama yazılmadı” üretti — bu sefer çift değil kayıp, ve çift kayıttan çok daha zor fark edildi: mutabakat yakaladı.
  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

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, mantıksal emir başına mı?
  • Anahtar hesapla birlikte mi tekil? Aynı anahtar farklı içerikle gelirse hata dönüyor mu?
  • Dedup anahtarı execution kimliği mi, emir kimliği mi? (Kısmi fill’ler.)
  • Yaş kontrolü son tick’e mi, heartbeat dahil son canlılığa mı bakıyor?
  • Delta akışında (order book) boşlukta sembol şüpheli oluyor, snapshot’ta temizleniyor mu?
  • Tekrar eden isteğe ilk cevabın aynısı mı dönüyor?
  • Dedup penceresi en uzun replay / DLQ süresinden uzun mu? Temizlik parçalı mı, tek DELETE mi?
  • Partition’lıysa: partition anahtarı olayın kendi damgası mı, now() mu?

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. Çekirdeği bir ON CONFLICT DO NOTHING; etrafı — tek transaction, hesapla tekil anahtar, saklanan cevap, temizlik — asıl iş.