<

Kafka'da Exactly-Once Efsanesi: Çift Sipariş Kaydının Peşinde

Ocak sonundaki büyük indirim gecesinde, saat 22.15'te finans ekibinden bir mesaj düştü: "Mutabakatta 1.847 sipariş çift görünüyor." Müşteriler tek sipariş vermiş, tek ödeme yapılmış; ama sipariş kayıt tablosunda ve arkasındaki muhasebe akışında bazı siparişler iki satırdı. Restoranlara giden bazı siparişler mutfakta iki kez hazırlanmış, iki kurye aynı adrese yollanmıştı. Savaş odasında birisi tahtaya "Kafka exactly-once açık mıymış, kontrol edelim" yazdı. O cümle, bu yazının sebebi. Çünkü üç haftalık kazı çalışmasının sonunda çift kayıtların Kafka'nın "exactly-once" bayrağıyla uzaktan yakından ilgisi olmadığını, sorunun bizim uçlardaki kendi kodumuzda olduğunu görecektik.

Bayrağın Koruduğu Dar Koridor

Önce herkesin atladığı ayrımı netleştireyim. Kafka'nın idempotent producer özelliği şunu garanti eder: producer, broker'a aynı batch'i ağ hatası yüzünden iki kez gönderirse, broker sequence numarasına bakıp kopyayı sessizce düşürür. Transactional API ise "birden çok partition'a yazma + offset commit" işlemini tek atomik pakete bağlar; bu da Kafka içinde kalan okuma-işleme-yazma zincirleri (Kafka Streams tarzı) için uçtan uca tekillik sağlar. Koridor bu kadar: Kafka'dan Kafka'ya. Zincirin ucunda bir veritabanı, bir ödeme sağlayıcısı, bir HTTP çağrısı varsa — ki gerçek hayatta hep vardır — o sınırda Kafka'nın garantisi biter, sizin mühendisliğiniz başlar. Bizim çift siparişlerimiz de tam o sınırda doğuyordu.

Kazıya trace'lerden başladık. Çift kayıtların dağılımı ilginçti: rastgele değildi, dakika bazında kümeleniyordu. Kümelenme anlarını consumer grubu loglarıyla üst üste koyunca desen ortaya çıktı: her küme, bir rebalance anına denk geliyordu. İndirim gecesi trafiği ikiye katlayınca HPA sipariş işleme servisine yeni pod'lar eklemiş, her pod eklenişinde consumer grubu yeniden dengelenmiş, her dengelenmede de bir grup mesaj "işlendi ama offset'i commit edilmedi" limbosuna düşmüştü. Servisin akışı şuydu: mesajı al, siparişi PostgreSQL'e yaz, sonra offset commit et. Rebalance tam ikisinin arasında gelirse yeni pod aynı mesajı tekrar alıyor ve siparişi tekrar yazıyordu. Klasik at-least-once davranışı; kitapta yazar, sınavda çıkar, üretimde gece 22.15'te bulur sizi.

Yanlış Çare: Ayarları Kahramanlaştırmak

İlk hafta iki yanlış çareyle oyalandık. Birincisi "processing.guarantee=exactly_once yapalım her yerde" kampanyasıydı; oysa bizim akışın yarısı Kafka Streams değildi ve olan kısımda da sorun zaten orada değildi. İkincisi daha sinsiydi: "offset'i mesajı işlemeden önce commit edelim, çift kayıt biter." Doğru, biter — yerine veri kaybı gelir. Rebalance yine aynı yerde gelirse bu kez sipariş hiç yazılmaz ve müşteri parasını verdiği yemeği alamaz. At-least-once ile at-most-once arasında bir ayar salınımı, sorunu çözmez; sadece hangi geceyi kabusa çevireceğinizi seçer. Bu tartışmayı ekiple yaparken tahtaya yazdığım cümle şuydu: teslimat garantisi bir Kafka ayarı değil, uçtan uca bir sistem özelliğidir ve zincirin en zayıf halkası kadardır.

Kalıcı çözümün üç ayağı oldu. Birinci ayak, işlemenin idempotent hale getirilmesi. Her sipariş olayına, üretildiği yerde — müşterinin "siparişi onayla" dokunuşunda — bir idempotency key verdik ve sipariş tablosuna bu anahtar üzerinde unique constraint koyduk. Consumer artık INSERT ... ON CONFLICT DO NOTHING deseniyle yazıyor; aynı mesaj beş kez de gelse veritabanında tek satır oluşuyor. İkinci ayak, offset commit ile veritabanı yazmasının kaderini birleştirmek: offset'i Kafka'ya değil, işlenen verinin yanına, aynı PostgreSQL transaction'ının içine yazdık; pod açılışta kaldığı yeri veritabanından okuyup seek ediyor. Böylece "yazıldı ama commit edilmedi" limbosu tanım gereği ortadan kalktı. Üçüncü ayak, producer tarafında enable.idempotence ve acks=all'un istisnasız açılması — bu, ağ hıçkırıklarındaki broker seviyesi kopyaları kesti; ölçümümüzde bunlar toplam çiftlerin yalnızca yüzde 6'sıydı ama sıfırlanması bedavaydı.

Deduplikasyonun Kendi Dertleri

İdempotency'yi "unique constraint koyduk, bitti" diye anlatmak haksızlık olur; iki tuzağa düştük. Birincisi pencere sorunu: sipariş olayları için anahtar tablosu sonsuz büyüyemez. Biz anahtarları sipariş kaydının kendisine gömdüğümüz için bu dert hafif atlatıldı ama ödeme bildirimi gibi kendi tablosu olmayan olaylarda ayrı bir dedup tablosu ve 7 günlük temizlik işi gerekti. Pencereyi neden 7 gün seçtik? Ölçtük: gördüğümüz en geç mükerrer teslim 41 saat arayla gelmişti (bir DLQ yeniden işleme vakası), 7 gün dört katından fazla pay bırakıyordu. İkinci tuzak daha ince: idempotent yazma, yan etkileri kapsamaz. Sipariş satırı tek kalıyordu ama satırın oluşması bir "restorana bildir" HTTP çağrısı tetikliyordu ve kod, ON CONFLICT durumunda bile bildirimi gönderiyordu. Mutfaktaki çift hazırlıkların son kalıntısı buydu. Kuralı şöyle koyduk: her yan etki, "bu satırı gerçekten ben yarattım" bilgisine bağlanır; INSERT'in etkilediği satır sayısı sıfırsa akış orada sessizce biter.

Rebalance sıklığını da ayrıca ehlileştirdik: static group membership (group.instance.id) ve daha tolere edilebilir session timeout ile pod yeniden başlatmalarının çoğu artık rebalance tetiklemiyor; cooperative sticky assignor da geçişleri yumuşattı. İndirim gecesi ölçeklenmesi hâlâ rebalance üretiyor — bu kaçınılmaz — ama artık rebalance bizim için tehlikeli bir an değil, sıradan bir olay.

Transactional producer'ı tamamen dışladığımız sanılmasın: platformda gerçek Kafka-içi zincirlerin olduğu iki yerde — kampanya puanlarını akış üzerinde toplayan bir Kafka Streams işi ve olay zenginleştirme hattı — exactly_once_v2 açık ve orada gerçekten işini yapıyor. Ölçtüğümüz ek maliyet, commit aralığına bağlı olarak throughput'ta yüzde 8-12'lik bir düşüş ve uçtan uca gecikmede commit interval kadar (bizde 100 ms) bir taban. Bu bedel, o hatlarda dedup altyapısı kurmaktan ucuz. Yani kural şu: garanti sınırınız nerede bitiyorsa, orayı dürüstçe çizin; sınırın içinde Kafka'nın araçlarını, dışında kendi idempotency'nizi kullanın.

Mutabakat Panosu: En Dürüst Hakem

Bütün bu işin bitiş çizgisini kod değil, bir Grafana paneli belirledi. Finansın elle yaptığı mutabakatı sürekli çalışan bir karşılaştırmaya çevirdik: ödeme sağlayıcısından gelen işlem sayısı ile sipariş tablosundaki satır sayısı, 5 dakikalık pencerelerde yan yana. Fark binde 1'i aşarsa alarm. Değişikliklerden önceki dört haftada bu panel günde ortalama 120-400 arası çift kayıt gösteriyordu, kampanya gecelerinde binlerce. Değişikliklerden sonraki ilk büyük kampanyada — trafik yüzde 180 artmışken — çift kayıt sayısı: 0. Sonraki altı ayda toplam 3 vaka gördük, üçü de harici ödeme sağlayıcısının webhook'unu çift göndermesiydi ve dedup katmanı hepsini yuttu; panelde iz bile bırakmadılar, onları DLQ loglarından bulduk.

Bu hikayeden taşıdığım ders şu: "Kafka'da exactly-once var mı?" sorusunun cevabı "evet, ama sizin sisteminizde değil" olur çoğu zaman. Bayrak, Kafka'nın kendi sınırları içinde dürüstçe çalışır; sınırın dışını — veritabanınızı, HTTP çağrılarınızı, mutfaktaki ikinci pideyi — sizin idempotency tasarımınız korur. Çift kayıtlarla, kayıp mesajlarla ya da "rebalance olunca tuhaflaşan" consumer'larla boğuşuyorsanız iletişim sayfamdan yazın; mutabakat panosunun sorgusunu ve dedup şemamızı memnuniyetle paylaşırım. O panel, yazdığımız her satırdan daha çok tartışma bitirdi.

İndirim gecesindeki 1.847 çift siparişin faturası, iade ve operasyon maliyetiyle birlikte yaklaşık 900 bin TL'ydi. Unique constraint'in maliyeti: bir index. Mühendislikte bazen kahramanlık, en sıkıcı aracın ne zaman yeteceğini bilmektir.

📅 Yayınlanma:  ·  Yakup Zengin