Sertaç Yıldırımsaha notları

Ana sayfa → Teknik

Dual Write Problem

Emir PostgreSQL’e yazıldı. Ardından Redis stream’e olay 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?

İlk 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ı. Seçeneklerine bak:

  • Emri geri sil? Belki köprü onu çoktan işledi. Sildiğinde daha büyük bir tutarsızlık yaratırsın.
  • 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.

SıraNe 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ı”Sıra bozulurOlay, veritabanı işlemi kesinleşmeden tüketiciye ulaşır

Üçü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: event’i de veritabanına yaz

Fikir sürpriz 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,      -- sirayi bu belirliyor
  olay_tipi   text        NOT NULL,       -- 'EmirOlusturuldu'
  toplam_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
);
CREATE INDEX ON outbox (yayinlandi, id) WHERE NOT yayinlandi;

-- Uygulamada:
BEGIN;
  INSERT INTO emirler (id, sembol, lot, ...) VALUES (991, 'EURUSD', 1.0, ...);
  INSERT INTO outbox (olay_tipi, toplam_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 publish eden

Relay
-- Ayri bir surec, saniyede birkac kez:
BEGIN;
  SELECT * FROM outbox
   WHERE NOT yayinlandi
   ORDER BY id                 -- ← sira garantisi burada
   LIMIT 200
   FOR UPDATE SKIP LOCKED;     -- ← birden fazla relay calisabilsin

  -- her biri icin: redis.xadd(...) / kafka.send(...)

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

Üç detay ve üçü de sonradan öğrenildi:

  • 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ü.
  • Sıra id üzerinden korunur. Aynı emrin olayları ORDER BY id ile sırayla gider. Birden fazla relay kopyası çalıştırıyorsan bu garanti zayıflar; sıra kritikse toplam_id’ye göre bölümle (sharding).
  • Temizlik şart. Yayınlanmış satırlar birikirse tablo devleşir ve SELECT’ler yavaşlar. Günlük bir iş: 3 günden eski ve yayınlanmış satırları sil. Kısmi indeks (yukarıdaki WHERE NOT yayinlandi) sorguyu tablo boyutundan bağımsız tutuyor.
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. Fiyat 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 consumer 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
);

-- 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
   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.
  • 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.

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 da 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.

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 %90’ını çözüyor. Gecikme gerçekten sorun olduğunda CDC eklersin — ve outbox tablosu zaten hazır olduğu için o geçiş sancısız oluyor.

Kontrol listesi AI agent görev 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.)
  • Gelen olaylar önce inbox’a mı yazılıyor, yoksa doğrudan mı işleniyor?
  • Inbox’ta olay kimliği birincil anahtar mı?
  • 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ı.