Sertaç Yıldırım saha notları

Ana sayfa → Teknik

Dual Write Problem

Emir PostgreSQL’e yazıldı. Ardından Redis stream’e olay (event) gönderilecekti ama tam o anda bağlantı koptu. Şimdi veritabanında var olan, streaming-engine’in ise hiç haberi olmayan bir emir var. Müşteri ekranında göremiyor, destek ekibi “sistemde görünüyor” diyor.

Özet
  • İki ayrı sisteme atomik yazamazsın. Ne kadar dikkatli kod yazarsan yaz.
  • Çözüm sırayı değiştirmek değil, ikinci yazmayı aynı veritabanına almak. Outbox tam olarak bu.
  • Gelen tarafta da aynı sorun var ve cevabı inbox: önce yaz, sonra işle.
  • CDC güçlü ama sözleşmen tablo şeman olur. En sağlamı outbox + CDC birleşimi.

Neden try/catch bunu çözmüyor?

Önce kısa bir bağlam. Bu sistemde emir PostgreSQL’e yazılır, ardından Redis stream’e bir olay olarak çıkar. O olayı üç tüketici (consumer) okur: streaming-engine müşteri ekranına basar, köprü (bridge) emri likidite sağlayıcıya iletir, risk motoru pozisyonu izler. “Veritabanına yaz, sonra olayı yayınla” adımının adı çift yazma (dual write) ve ilk refleks genelde şu:

Çalışmayan kod
db.insert(emir);                    // 1. yazma
try {
    redis.xadd("emirler", olay);    // 2. yazma
} catch (Exception e) {
    log.error("olay gonderilemedi", e);
    // ve simdi ne olacak?
}

Buradaki sorun hatayı yakalamamak değil; yakaladıktan sonra yapabileceğin bir şey olmaması. Asıl tuzak şu: exception almak, mesajın gitmediği anlamına gelmez. Bağlantı zaman aşımına düşmüş ama Redis mesajı çoktan yazmış olabilir. catch bloğuna düştüğünde olayın gidip gitmediğini bilmiyorsun. Seçeneklerine bak:

  • Emri geri sil? Olay gittiyse köprü onu çoktan işledi; sildiğinde daha büyük bir tutarsızlık yaratırsın. Gitmediyse silmek doğruydu — ama hangisi olduğunu bilmiyorsun.
  • Tekrar dene? Süreç bu satırda çökerse tekrar deneme de ölür.
  • Loga yaz ve geç? Yaptığın şey, tutarsızlığı görünmez kılmak oluyor.

Sırayı ters çevirmek de kurtarmıyor: önce olayı yayınlayıp sonra veritabanına yazarsan, bu sefer olmayan bir emir için olay yayınlamış oluyorsun ve streaming-engine hayalet bir emir gösteriyor.

SenaryoNe bozulurBelirti
Önce DB, sonra olayOlay kaybolurEmir var, kimsenin haberi yok. Sessiz.
Önce olay, sonra DBEmir kaybolurHayalet emir; tüketiciler var olmayan kayda bakıyor
İkisi de “başarılı”, olay COMMIT’ten önce çıkarYarış durumu (race condition)Tüketici olayı alır, kaydı sorar, “kayıt yok” cevabı gelir

Üçüncü satır en az bilineni ve gerçekten başımıza geldi: olay yayınlandı, tüketici hemen okudu ve o kaydı almak için veritabanına gitti — ama transaction henüz kesinleşmemişti. Tüketici “kayıt yok” hatası aldı. Olay COMMIT’ten önce yayınlanmıştı.

İki sistemin ortak bir COMMIT’i yoksa, ikisine birden atomik yazamazsın. Çözüm ikinci yazmayı aynı sisteme almaktan geçiyor.

Transactional outbox: olayı da veritabanına yaz

Fikir şaşırtıcı derecede basit: olayı mesaj sistemine göndermek yerine, aynı veritabanında bir tabloya aynı transaction içinde yaz.

Tek transaction, iki insert
CREATE TABLE outbox (
  id            bigserial PRIMARY KEY,      -- INSERT aninda alinir (asagiya bak)
  olay_tipi     text        NOT NULL,       -- 'EmirOlusturuldu'
  aggregate_id  text        NOT NULL,       -- order_id: ayni emrin olaylari
  govde         jsonb       NOT NULL,
  olusma        timestamptz NOT NULL DEFAULT now(),
  yayinlandi    boolean     NOT NULL DEFAULT false
);
-- kismi indeks: yalnizca bekleyen satirlar, tablo buyuse de kucuk kalir
CREATE INDEX ON outbox (id) WHERE NOT yayinlandi;

-- Uygulamada:
BEGIN;
  INSERT INTO emirler (id, sembol, lot, ...) VALUES (991, 'EURUSD', 1.0, ...);
  INSERT INTO outbox (olay_tipi, aggregate_id, govde)
  VALUES ('EmirOlusturuldu', '991', '{"orderId":991,"symbol":"EURUSD",...}');
COMMIT;   -- ← ya ikisi birden, ya hicbiri

Artık “emir var ama olay yok” durumu fiziksel olarak imkânsız. Ya ikisi de tabloda, ya hiçbiri.

Relay: outbox’u okuyup yayınlayan

Relay
-- Ayri bir surec, 100 ms'de bir:
BEGIN;
  SELECT * FROM outbox
   WHERE NOT yayinlandi
   ORDER BY id                 -- ← id sirasi; commit sirasi degil (asagiya bak)
   LIMIT 200
   FOR UPDATE SKIP LOCKED;     -- ← birden fazla relay calisabilsin

  -- her biri icin: redis.xadd(...) / kafka.send(...)
  -- (transaction bu sirada acik: kisa tut)

  UPDATE outbox SET yayinlandi = true WHERE id = ANY(:idler);
COMMIT;

Dört detay. İlk üçü sonradan öğrenildi; dördüncüsünü şimdiden bil:

  • Yayınlama, işaretlemeden önce olur. Yani yayınladıktan sonra çökersen olay iki kez gider. Bu kabul edilebilir — çünkü at-least-once zaten öyle çalışıyor ve tüketici tarafında dedup var (detay). Tersini yaparsan — önce işaretle, sonra yayınla — olayı kaybedersin, ki bu çok daha kötü.
  • id sırası, commit sırası değil. bigserial değeri INSERT anında alınır. T1 id=10’u alır, T2 id=11’i alır; T2 önce commit ederse relay 11’i yayınlar, 10 o anda görünmez bile — bir sonraki turda gelir. Yani tek relay ile de sıra bozulabilir. SKIP LOCKED ile birden fazla relay çalıştırdığında garanti zayıflamaz, fiilen ortadan kalkar: iki relay farklı batch’leri aynı anda yayınlar. İki ayrı sorun, iki ayrı çözüm: çoklu relay için aggregate_id’ye göre bölümle (sharding). Commit sırası için aynı emre yazan transaction’ların emirler satırındaki kilitle serileştiğinden emin ol; pratikte sıra bu yüzden korunur. İkisinden de kurtulmak istiyorsan aşağıdaki Outbox + CDC’ye geç: WAL, transaction’ları commit sırasıyla verir.
  • Temizlik şart. Kısmi indeks sayesinde relay’in SELECT’i tablo boyutundan etkilenmez; sorun başka yerde. Her satır bir kez UPDATE görüyor, yani her yayın bir ölü satır (dead tuple) bırakıyor: bloat, vacuum yükü ve disk büyür. Günlük bir iş: 3 günden eski ve yayınlanmış satırları sil.
  • Transaction, ağ çağrısı sırasında açık. FOR UPDATE ile aldığın satır kilitleri, Redis ya da Kafka cevap verene kadar duruyor. Broker yavaşlarsa uzun transaction’lar ve kilitler birikir. Batch’i küçük tut, yayın çağrısına kısa bir zaman aşımı koy.
Gecikme meselesi

Yoklamayla (polling) çalışan bir relay, olayı en fazla yoklama aralığı kadar geciktirir. 100 ms’de bir yokluyorsan ortalama 50 ms gecikme eklemiş oluyorsun. Milisaniye hassasiyeti isteyen bir akış için bu çok; emir olayları için genelde sorun değil.

Gecikmeyi sıfıra yaklaştırmak istiyorsan yoklama yerine LISTEN/NOTIFY kullanabilirsin ya da bir sonraki bölümdeki CDC’ye geçersin.

Inbox pattern: aynı sorunun tüketici tarafındaki hali

Şimdi ters yön. Köprüden bir fill bildirimi geldi. Yapman gerekenler: kaydet, pozisyonu güncelle, müşteriye bildir, mutabakata yaz. Ortasında çökersen ne olacak?

Cevap yine aynı fikir: önce yaz, sonra işle.

Inbox
CREATE TABLE inbox (
  olay_id    text PRIMARY KEY,     -- ← dedup burada
  govde      jsonb NOT NULL,
  alinma     timestamptz NOT NULL DEFAULT now(),
  islendi    boolean NOT NULL DEFAULT false,
  deneme     int     NOT NULL DEFAULT 0    -- poison message icin
);

-- 1. Adim: sadece kaydet ve ACK don. Hizli.
INSERT INTO inbox (olay_id, govde) VALUES (:fill_id, :govde)
ON CONFLICT (olay_id) DO NOTHING;     -- ikinci kez geldiyse yok say

-- 2. Adim: ayri bir surec islesin
BEGIN;
  SELECT * FROM inbox WHERE NOT islendi
   ORDER BY alinma             -- varis sirasi; olayin kendi sirasi degil
   LIMIT 100 FOR UPDATE SKIP LOCKED;
  -- ... asil isi yap ...
  UPDATE inbox SET islendi = true WHERE olay_id = ANY(:idler);
COMMIT;

Üç problemi birden çözüyor:

  • Tekrar: birincil anahtar sayesinde aynı olay iki kez girmiyor.
  • Çökme: işlerken çökersen olay tabloda duruyor, yeniden işleniyor. Asıl iş aynı veritabanındaysa (pozisyon, mutabakat satırı) bu atomik. Ama “müşteriye bildir” gibi dış yan etkiler transaction’a dahil değil; yeniden işlemede müşteri iki bildirim alabilir. Dış çağrılar için idempotency yine senin işin.
  • Hız: göndericiye anında onay dönüyorsun; ağır iş arka planda. Köprü zaman aşımına (timeout) düşmüyor.
İki tuzak

Dedup, satır durduğu sürece çalışır. Inbox’ı temizliyorsan saklama süresi, göndericinin tekrar deneme penceresinden uzun olmalı. Yoksa temizlenmiş bir olay_id tekrar geldiğinde yeni sanılır ve yeniden işlenir.

ORDER BY alinma zayıf bir sıra. now() transaction başlangıcını döner, eşitlik olabilir ve verdiği şey olayın sırası değil varış sırası. Sıra önemliyse olayın içindeki sıra numarasını kullan. Ayrıca batch içindeki tek bir bozuk mesaj (poison message) 100’lük batch’in tamamını sürekli geri aldırır. deneme sayacını tut, 3 denemeden sonra o satırı kenara al. Dikkat: sayacı aynı transaction içinde artırırsan rollback onu da geri alır; ya ayrı bir transaction’da güncelle ya da her satırı bir SAVEPOINT ile işle.

Change Data Capture: uygulamaya hiç dokunmadan

Üçüncü yol, veritabanının değişiklik günlüğünü (PostgreSQL’de WAL, MySQL’de binlog) okuyup oradan olay üretmek. Debezium bunu yapıyor.

Cazip tarafı açık: uygulama kodunda tek satır değişiklik yok. Pozisyon tablosuna yazılan her satır, otomatik olarak bir olaya dönüşüyor ve risk motoruna akıyor.

Ama bir bedeli var ve büyük

CDC’nin ürettiği olay, tablonun kendisi. Yani pozisyonlar tablosuna yeni bir kolon eklediğinde ya da bir kolonu yeniden adlandırdığında, o olayı dinleyen bütün servisler etkileniyor.

Sonuç: veritabanı şeman, farkında olmadan servisler arası bir sözleşmeye dönüşüyor. Ve o sözleşmeyi değiştirmek, bir migration yazmak kadar kolay göründüğü için insanlar farkında olmadan bozuyor.

En iyisi: ikisini birleştir

Olayı outbox tablosuna yaz (sözleşmeyi sen belirle), o tabloyu CDC ile oku (yoklama gecikmesi olmasın). İki dünyanın da iyi yanını alıyorsun:

uygulama → BEGIN; emirler INSERT + outbox INSERT; COMMIT;
                                    ↓
                          Debezium (WAL okur)
                                    ↓
                          Kafka / Redis stream
                                    ↓
                              tuketiciler

Relay süreci yazmana gerek kalmıyor; olay gecikmesi milisaniyelere iniyor ve olay şemasının kontrolü sende kalıyor. Debezium’un “outbox event router” diye hazır bir bileşeni de var.

Bir fark: bu kurulumda yayinlandi kolonuna ihtiyaç yok, çünkü satırları Debezium WAL’dan okuyor; tabloda neyin yayınlandığını takip etmesi gerekmiyor. Tipik yöntem satırı INSERT edip aynı transaction içinde hemen silmek; tablo hep boş kalır, INSERT WAL’da vardır ve router silmeleri yok sayar. Yukarıdaki tabloyu olduğu gibi taşıma.

Hangisini seçmeli?

Outbox + yoklamaCDC (doğrudan tablo)Outbox + CDC
KurulumKolay, ek altyapı yokOrta (Debezium, Kafka Connect)Orta
Kod değişikliğiVarYokVar
Olay şemasıSen belirlersinTablo yapısıSen belirlersin
GecikmeYoklama aralığıMilisaniyelerMilisaniyeler
Ne zamanBaşlangıç, tek servisEski sisteme dokunamıyorsanKalıcı çözüm

Pratik tavsiye: outbox + yoklama ile başla. Kurulumu bir öğleden sonra, ek altyapı sıfır ve problemin çoğunu çözüyor. Gecikme gerçekten sorun olduğunda CDC eklersin — outbox tablosu ve olay şeması zaten hazır olduğu için o geçiş küçük. Ama sırası önemli: Debezium bağlandığı andaki WAL konumundan okumaya başlar; relay’i önce kapatırsan o anda yayinlandi = false duran olaylar kaybolur. Önce Debezium’u bağla, relay kuyruğu boşaltsın, sonra relay’i kapat; aradaki kısa çift yayını dedup karşılar. Ardından yayinlandi kolonunu ve ona bağlı kısmi indeksi bırak, uygulamaya INSERT’ten sonra DELETE’i ekle.

Kontrol listesi

Olay yayınlayan her yerde
  • Kodda “DB’ye yaz, sonra mesaj gönder” deseni var mı?
  • Olay, asıl kayıtla aynı transaction’da mı yazılıyor?
  • Relay, yayınlamadan önce mi işaretliyor? (Yanlış — sonra işaretlemeli.)
  • Outbox tablosunun temizlik işi var mı?
  • Yayınlanmayı bekleyen en eski satırın yaşı izleniyor mu? (Relay ölürse tek sinyal bu.)
  • Olay sırası önemli mi? Öyleyse ORDER BY id’ye mi güveniyorsun, yoksa aggregate_id’ye göre mi bölümledin?
  • Gelen olaylar önce inbox’a mı yazılıyor, yoksa doğrudan mı işleniyor?
  • Inbox’ta olay kimliği birincil anahtar mı? Saklama süresi, göndericinin tekrar deneme penceresinden uzun mu?
  • Inbox işleyicisinde bozuk mesajı kenara alan bir mekanizma var mı, yoksa tek satır bütün batch’i mi kilitliyor?
  • CDC kullanıyorsan: tablo şeman servisler arası sözleşme haline mi geldi?

Sonuç

Çift yazma, mikroservise geçen ekiplerin neredeyse tamamının bir noktada çarptığı duvar. Ve çoğu ekip bunu bir hata sanıp daha dikkatli kod yazmaya çalışıyor. Oysa bu bir hata değil, iki ayrı sistemin ortak bir onay noktasının olmamasından doğan yapısal bir sonuç.

Çözümün tamamı tek cümlede: iki yazma yapmak yerine, tek yazma yapıp ikincisini ondan türet. Outbox da, inbox da, CDC de aynı fikrin farklı yerlerdeki uygulaması.