<

Outbox Deseni: Veritabanı ile Kafka Arasındaki Boşluğa Düşen Siparişler

Şubat ortasında pazarlama ekibi kısa süreli bir flaş kampanya açtı; sipariş oluşturma hızı 6 dakika boyunca normalin dört katına çıktı. Kampanyadan bir saat sonra veri ambarı ekibinden garip bir soru geldi: "Sipariş tablosunda olan ama sipariş olay akışında hiç görünmeyen 312 kayıt var, bunlar ne?" Kontrol ettik: siparişler gerçekti, müşteriler yemeklerini almıştı, para tahsil edilmişti. Ama bu siparişlerin Kafka'daki OrderCreated olayı hiç doğmamıştı — dolayısıyla fatura servisi onları faturalamamış, veri ambarı saymamış, kampanya bütçe hesabı yanlış çıkmış, restoran mutabakatına eksik yansımıştı. Siparişler veritabanı ile Kafka arasındaki görünmez boşluğa düşmüştü. O boşluğun ders kitaplarındaki adı: dual write problemi.

İki Sisteme Aynı Anda Yazmanın İmkansızlığı

Kod son derece masum görünüyordu: transaction içinde siparişi PostgreSQL'e yaz, commit et, sonra Kafka'ya olayı publish et. Yıllardır böyle çalışıyordu. Peki 312 sipariş nasıl kayboldu? Kampanya piki sırasında Kafka producer'ın buffer'ı doldu, birkaç pod bellek baskısıyla yeniden başlatıldı ve commit ile publish arasındaki o milisaniyelik pencerede ölen her pod, veritabanına yazılmış ama Kafka'ya söylenmemiş siparişler bıraktı. Sıralamayı ters çevirmek de çare değil: önce publish edip sonra commit ederseniz, bu kez rollback olan transaction'ların hayalet olayları akışa sızar. İki bağımsız sisteme tek atomik yazma yapmanın genel bir yolu yok; XA/iki fazlı commit teoride var ama Kafka tarafında pratik değil ve olsa da operasyonel maliyeti ödemek istemezsiniz. Bu, ayarlarla düzelecek bir aksaklık değil, mimari bir açıktır — trafik arttıkça istatistiksel kesinlikle vurur.

İlk hafta, klasik yanlış hipotez turlarımızı attık. "Producer'a retry ekleyelim" — retry, pod ölümünde işe yaramaz; bellek uçtuysa kuyruk da uçmuştur. "Publish'i transaction'ın içine alalım" — Kafka, PostgreSQL transaction'ının parçası olamaz; commit'ten önce publish edilen olay, rollback'te geri çağrılamaz. "Graceful shutdown süresini uzatalım" — pencereyi daraltır, kapatmaz; OOMKill graceful beklemez. Ekipten bir arkadaşın toplantıda kurduğu cümle tartışmayı bitirdi: "Boşluğu daraltmaya çalışıyoruz; boşluğun olmadığı bir tasarım lazım."

Outbox: Olayı da Aynı Kalemle Yazmak

O tasarımın adı outbox deseni ve fikri utandırıcı derecede basit: olayı Kafka'ya değil, aynı veritabanı transaction'ının içinde bir outbox tablosuna yaz. Sipariş satırı ile onun OrderCreated olayı aynı commit'te ya birlikte var olur ya birlikte yok olur — atomiklik, zaten sahip olduğunuz tek yerden, ilişkisel veritabanından gelir. Sonra ayrı bir mekanizma outbox tablosunu okuyup olayları Kafka'ya taşır. Boşluk kapanmıştır; en kötü senaryoda olay geç gider ama asla kaybolmaz.

Asıl mühendislik tartışması "taşıyıcı" katmanında koptu. İki yol vardı. Birincisi polling publisher: bir işlem, outbox tablosunu saniyede birkaç kez sorgular, yeni satırları Kafka'ya basar, işaretler. Basit, bağımlılıksız, ama gecikme ve veritabanına sürekli sorgu yükü getirir; bizim sipariş hacmimizde bu sorgu yükü ölçülebilir düzeydeydi. İkincisi CDC — change data capture: Debezium, PostgreSQL'in WAL'ını (write-ahead log) logical replication üzerinden dinler, outbox tablosuna düşen her satırı milisaniyeler içinde Kafka'ya akıtır. Veritabanına ek sorgu yükü yok, gecikme tipik olarak 50-200 milisaniye. Biz Debezium'u seçtik çünkü Kafka Connect altyapımız zaten vardı ve Debezium'un outbox event router'ı, tabloyu topic'lere yönlendirme işini konfigürasyonla çözüyordu.

Debezium'un Faturası: Bedava Atomiklik Yok

Debezium'u anlatan yazılar genelde burada biter; bizim hikayenin en öğretici kısmı buradan sonra başladı. Birinci ders: replication slot bir taahhüttür. PostgreSQL, Debezium'un okuduğu logical slot ilerlemedikçe WAL dosyalarını silemez. Nisan'daki bir bakımda Debezium connector'ı 14 saat durdu ve kimse fark etmedi; WAL diski 40 GB büyüdü ve veritabanı disk alarmıyla bizi gece uyandırdı. Slot gecikmesi artık birinci sınıf metrik: pg_replication_slots üzerinden restart_lsn farkını Grafana'da izliyoruz, 2 GB'ta uyarı, 10 GB'ta kritik alarm var. İkinci ders: CDC hattı da en az bir kez teslimattır. Connector yeniden başladığında son offset'ten devam eder ve bazı olaylar Kafka'ya iki kez düşer. Bu bizi şaşırtmadı çünkü consumer'larımız çift kayıt vakasından beri zaten idempotent'ti — outbox, dedup ihtiyacını ortadan kaldırmaz, sadece kayıp ihtiyacını ortadan kaldırır. Üçüncü ders: outbox tablosu bir kuyruktur ve kuyruklar temizlenmelidir. Debezium okuduktan sonra satırların işi biter; biz günlük bir işle 48 saatten eski satırları siliyoruz ve tabloyu partition'ladık ki silme işlemi vacuum baskısı yaratmasın. İlk ay bunu yapmadık; tablo 30 milyon satıra ulaştığında sorgu planları bozulmaya başlamıştı.

Bir de kimsenin baştan düşünmediği dördüncü ders: şema evrimi. Outbox'taki olay gövdesi JSON ve bu JSON artık iki dünyanın sözleşmesi. Bir geliştirici sipariş olayına alan eklerken artık sadece kendi servisini değil, o olayı tüketen yedi servisi düşünmek zorunda. Bunu süreçle çözdük: olay şemaları ayrı bir repoda, versiyonlu, geriye uyumluluk kuralı CI'da otomatik denetleniyor. Outbox deseni teknik bir yamadan ibaret kalsaydı bu disiplin doğmazdı; desen, olayları "yan ürün" olmaktan çıkarıp "ürün" statüsüne yükseltti.

Sıralama garantisi de outbox'ın az konuşulan bir artısı oldu. Eski dünyada aynı siparişin OrderCreated ve OrderPaid olayları iki ayrı pod'dan publish edildiğinde, Kafka'ya varış sıraları garanti değildi ve tüketicilerde "ödenmemiş siparişe ödeme geldi" savunma kodları birikmişti. Outbox'ta olaylar tek tablodan, sipariş kimliğini partition anahtarı yaparak akıtıldığı için aynı siparişin olayları artık hem üretim hem tüketim tarafında sıralı. Geçişten üç ay sonra tüketici servislerdeki o savunma kodlarının bir kısmını sildik — silinen kod, yazılan koddan daha büyük bir hediyedir.

Boşluğun Kapandığını Nereden Biliyoruz?

Geçişi büyük patlama yapmadık. İlk olarak yalnızca sipariş servisini outbox'a aldık ve dört hafta boyunca eski usul publish ile outbox akışını paralel çalıştırıp iki akışı sayaçlarla karşılaştırdık — fark, gölge modda günde ortalama 30-80 olaydı ve tamamı eski yolun kayıplarıydı; outbox tarafı tek olay düşürmedi. Sonra ödeme, iade ve kampanya servisleri geçti. Bugün "veritabanında var, akışta yok" denetimi saatlik bir mutabakat işi olarak çalışıyor ve son dört ayda sıfır vaka raporladı. Kampanya piklerinde Debezium gecikmesi p99'da 1,8 saniyeye çıkıyor; olaylarımız için kabul edilebilir, çünkü sözleşmemiz "anında" değil "eninde sonunda ve eksiksiz".

Dual write, mikroservis mimarilerinin sessiz vergisidir: sistem küçükken görünmez, büyüyünce mutabakat toplantılarında ortaya çıkar ve faturası hep başka bir ekibe kesilir — bizde veri ambarına kesiliyordu. Outbox + CDC bu vergiyi kaldırmıyor, onu görünür ve yönetilebilir bir altyapı maliyetine çeviriyor: bir tablo, bir connector, üç alarm, bir temizlik işi. Kendi sisteminizde "veritabanında var ama olayı yok" vakaları görüyorsanız ya da gördüğünüzden şüpheleniyor ama ölçemiyorsanız bana yazın; mutabakat sorgumuzu ve Debezium konfigürasyonumuzun kritik satırlarını paylaşırım.

Kaybolan 312 siparişin faturalama düzeltmesi üç kişinin dört gününü yedi. Outbox tablosunun aylık maliyeti, birkaç gigabayt disk ve bir connector pod'u. Bu sektörde nadiren bu kadar net hesap çıkar: boşluğa düşen her sipariş, o boşluğu kapatmayan her sprintin faiziydi.

📅 Yayınlanma:  ·  Yakup Zengin