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.
- Üç 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.
| Garanti | Söz verdiği | Bedeli | Trade’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.
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.
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 - 1negatif çıkar: kayıp sayacı azalır, sembol boşuna şüpheli olur. -
kaynakZamaniiki 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:
| Senaryo | Olan |
|---|---|
| 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.
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.
-- 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
ClOrdIDde 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.
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’deDELETE ... LIMITyok, 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’ı
DROPetmek 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 veolusma 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?
- Emir açma / iptal
- Fill, kısmi fill (partial fill)
- Bakiye ve teminat hareketi
- Mutabakat kaydı
- 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):
-
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. - 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ı.
-
Tekrar sayacı metriğe döküldü.
tekrar_gelen_olay_toplamsı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
- 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ş.