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.
- İ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:
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.
| Senaryo | Ne bozulur | Belirti |
|---|---|---|
| Önce DB, sonra olay | Olay kaybolur | Emir var, kimsenin haberi yok. Sessiz. |
| Önce olay, sonra DB | Emir kaybolur | Hayalet emir; tüketiciler var olmayan kayda bakıyor |
İkisi de “başarılı”, olay COMMIT’ten önce çıkar | Yarış 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ı.
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.
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
-- 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ü.
-
idsırası, commit sırası değil.bigserialdeğeriINSERTanında alınır. T1id=10’u alır, T2id=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 LOCKEDile 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çinaggregate_id’ye göre bölümle (sharding). Commit sırası için aynı emre yazan transaction’larınemirlersatı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 kezUPDATEgö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 UPDATEile 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.
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.
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.
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.
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.
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 + yoklama | CDC (doğrudan tablo) | Outbox + CDC | |
|---|---|---|---|
| Kurulum | Kolay, ek altyapı yok | Orta (Debezium, Kafka Connect) | Orta |
| Kod değişikliği | Var | Yok | Var |
| Olay şeması | Sen belirlersin | Tablo yapısı | Sen belirlersin |
| Gecikme | Yoklama aralığı | Milisaniyeler | Milisaniyeler |
| Ne zaman | Başlangıç, tek servis | Eski sisteme dokunamıyorsan | Kalı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
- 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, yoksaaggregate_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ı.