Söylemesi havalı, bir o kadar sinir bozucu: “Idempotency in Event Driven Architecture”

Bir babayiğit bu çoklanmaya dur demeliydi zaten!

Öncelikle herkese güzel haftasonları,

Bu platformda ilk yazımı yazıyor olmaktan dolayı gerçekten mutluyum. Aslında yıllardır süregelen bir istekti içimde ancak hep doğru konuyu seçemediğimi, dolayısıyla doğru kitleye ulaşamayacağımı düşünerek ertelemiştim. Umarım bu konuda sorun yaşayan ve yaşaması muhtemel herkesin baş ağrısına bir noktada ilaç olabilirim. Peki ben kimim? Aslında profilimde de yazdığı üzere ben Serdar Usta, Hepsiburada’da yazılım geliştirme uzmanı olarak görev alıyorum. Çok yakın zamanda hepimizin, özellikle de yazılım aleminin de aşina olduğu üzere Hepsiburada Premium adında, e-ticaret odaklı bir müşteri sadakat programı ortaya çıktı. İşte ben de o işin ortaya çıkma aşamasında etkin rol alan, işin mutfağındakilerden sadece biriyim. Aslında yazıyı yazmama sebep olan şey de bu ürünün geliştirme sürecinde yaşananlar oldu. Yazının devamında olabildiğince detaylı ancak çok da sıkmadan değineceğim bu konuya. Başlamadan önce bir iki teşekkürü de es geçemeyeceğim. Beni bu konuda teşvik eden takım liderim Batuhan güngör’e ve işin en başından beri beni sonsuz destekleyen
abim Selçuk Usta’ya en içten teşekkürlerimi sunmak isterim. Var olun :)

Nedir bu Idempotency?

Idempotency ifadesinin kısa ve öz anlamı ile başlamak isterim konuya. Idempotency aslında üniversitede matematik, mühendislik ve türevlerini okumuş, eğitim içeriğinde İngilizce dersler de bulunan bir çok arkadaşımız tarafından belki defalarca duyulmuş ancak tabiri caizse “ya hocam bununla gerçek hayatta ne zaman karşılacağız ki” diyebileceğimiz bir kavram. Bu kavramı kısaca incelemek gerekirse;

Idempotence, matematik ve bilgisayar bilimlerinde, bir işlemin veya fonksiyonun ilk kez uygulanmasının ötesinde, sonucu değişmeden birden çok kez uygulanabilir olduğunu göstermektedir. Matematikte kısaca f(f(x)) = f(x) denklemi ile açıklanır. Bu terim, Amerikalı matematikçi Benjamin Peirce tarafından 1870'te ortaya çıkarılmıştır.

İşin sıkıcı tarafını burada noktalamak istiyorum. Daha detaylı bilgiye referanslar kısmından ulaşabilirsiniz ancak konunun özünü kaybetmemek açısından ben işin matematiksel boyutunu kısa ve öz olarak anlattığımı düşünüyorum. Gelelim kavramın biz yazılımcıların evrenindeki karşılığına ve Hepsiburada Premium olarak bu sorunla nasıl karşılaştık ve nasıl bir çözüm ürettik kısmına.

Yazılım dünyasında Idempotency kavramı

Aslında bunu paylaşmakta bir sakınca görmüyorum. Sonuçta günümüz web tabanlı projelerinin çoğu artık mikroservis yapılar üzerine kurulu tıpkı Hepsiburada Premium’un da çok benzer bir sistem üzerine kurulu olduğu gibi. Biz de bu altyapısal sebeple ve bir çok iç-dış ekiple entegre olacağımız için yapımızı event driven bir mimari üzerine kurguladık ve bunun için bir message broker sistem bir de bu sistemi koşturacak bir event-bus çözümü seçmek durumunda kaldık. Bizim tercihlerimiz message broker kısmında RabbitMQ ve event-bus çözüm olarak da yavaşça trend haline gelen DotnetCore CAP oldu. İşte tam da bu noktada tercihlerimizden kaynaklı bazı yeni problemler ortaya çıktı ve buna karşın özelleştirilmiş bazı çözümlerimizi devreye almak durumunda kaldık.

Idempotency sorunu event driven mimarilerde aslında tercih ettiğiniz yardımcı araçlara göre fark göstermekle birlikte 90% oranında kaçınılamayacak bir sorun. Yazılım dünyasında bu sorun “delivery guarantees” başlığı altında ele alınıyor. Bu noktada bazı araçlar size şu 3 çözümden birini sunuyor:

  1. Exactly Once (event kesinlikle bir consumer tarafından bir kez işlenecek, ki çoğu sistem bunu size garanti edemez)
  2. At Most Once (event en fazla bir kez işlenecek)
  3. At Least Once (event en az bir kez işlenecek, CAP’in idempotency tarafında sunduğu çözüm)

Ben CAP’in bu konudaki yaklaşımını bu başlık altında detaylı bir şekilde açıklamayı doğru bulmuyorum ancak link aracılığıyla CAP’in bu sorunu nasıl ele aldığını ilgili sayfanın sonunda yakalayabilirsiniz.

Nasıl ve ne zaman?

Aslında consumer uygulamalarımız bu kadar kötü karakterler değiller :)

Peki biz ekip olarak bu sorunlarla hangi konu başlıklarında mücadele ettik? Müşterilerin abonelikleri sonrası bilgilendirme amacıyla geliştirmiş olduğumuz notifikasyon sistemlerimiz de asenkron olarak event driven mimari de bir mikroservisle yönetildiği için bazı bildirimlerimizin kullanıcılara birden fazla kez gittiğini anladığımız an bu sorunla yüzleştiğimiz ilk andı. Hatta bizim için güzel bir anısı bile var. Bunu bir talihsizlik olarak mı nitelendirmeli, yoksa her ekibin muhakkak başına gelir mi demeliyiz bilemiyoruz ama bu sorunu yaşadığımızda Twitter’da komünite tarafından bu kadar eleştiri yağmuruna tutulacağımızı biz de beklemiyorduk. Hem Twitter’da hem de komünitede aktif olarak rol alan bir meslektaşımızın Hepsiburada Premium aboneliğiyle ilgili SMS’i belki 10'dan fazla kez göndermemizle birlikte, hem şirket içerisinde hem de yazılım dünyasında (özellikle Twitter’da) ürün bir anda konuşulur hâle geldi. Evet, iyi bir reklam diyemeyiz ancak daha önce de söylediğim gibi canlıya ürün çıkmış her ekibin başına bu tarz olaylar en az bir kez gelir. Ayrıca reklamın iyisi kötüsü de olmaz :)

Aslında sorunun oluşma aşaması, bir verinin güncellenme sürecinde, diğer istemcilerin aynı veriyi okuması sonucu gerçekleşmektedir. Sorunu tam olarak ifade etmek gerekirse; kullandığımız event-bus çözümü kapsamında, işlenecek event objelerimiz “Processing” statüsüne çekilip tekrar dikkate alınmaması için işaretlenecekken, diğer bir istemcinin aynı veriye erişmesi sonucu mesajın iki veya daha fazla kez işlenmesi sonucu ortaya çıkan hatalar olarak nitelendirilebilir.

Bununla ilgili basit bir akış diyagramını aşağıya görsel olarak bırakıyorum ancak konu şöyle gelişiyor. Aboneliği yenilenen kullanıcımız için yenilemeyi yapan sistemimiz gerekli event’i servisimize gönderiyor. Servisimizin ise aynı anda çalışan ve dinleyici konumunda 10 tane consumer uygulaması olduğunu düşünelim. Bizim kullandığımız event-bus sistemi gelen bu eventleri önce belirlediğimiz bir depolama ortamına işlenmek üzere kaydediyor. Daha sonra yakalanan bu event objelerimizin içeriklerini “Processing” konumuna alıyor ve belirlediğimiz iş kuralları işlenmeye başlıyor. Süreç başarılı bir şekilde tamamlanırsa “Succeeded”, aksi takdirde “Failed” duruma alıyor ve rollback/retry gibi alternatif senaryoları devreye sokuyor. İşte tam da bu noktada bu sistemin kullandırdığı “At Least Once” opsiyonu bizde soğuk terlerin akmasına sebep oldu. Başta bu kadar derinlemesine incelemediğimiz için ürün yoğun kullanılana dek fark edemediğimiz bir sorun ile yüzleştik. Çalışan consumer uygulamalarımızın bazı instanceları veriyi aynı anda işleme alıyor ve ilgili operasyonu (SMS/Email gönderimi) birden fazla kez gerçekleştiriyordu. İşte buradan sonra küçük bir ArGe maratonu başlıyor.

Bu soruna nasıl bir çözüm üretebiliriz?

Küçük bir not, aşağıdaki sistemde mikroservislerimiz birer consumer uygulaması olarak çalışmaktadır. Sisteme bağlı consumer servislerimizin birden fazla hatta çoğu zaman yirmi ve üzeri instance ile çalıştığını hayal edelim.

Microsoft’un eShopOnContainer örnek projesinde de kullanılan CAP’in küçük bir akış diyagramı.

Acaba nedir, nedir?

Bu araştırma maratonunda ekip olarak bir çok arkadaşımızın vardığı sonuçlar genel manada şu şekilde oldu; her bir event benzersiz bir değer ile işaretlenmeli ve işleme alındığı anda başka bir uygulama tarafından işlenmesinin önüne geçilmeli. Ki zaten üzerine yazılan bir çok yazıda da çözümün bu olduğundan açıkça bahsediliyordu, ama nasıl? Bir çok parametre vardı. Hangi teknoloji kullanılmalıydı? En hızlı şekilde bu eventler nasıl işaretlenebilirdi? İşaretlendikten sonra tekrar işlenmeyeceğinden nasıl emin olunabilirdi? Gibi gibi… Araştırmalar neticesinde bir çok alternatif üzerinde fikir alışverişinde bulunduk. (Redis distributed lock, InMemory cache, SQL veya NoSQL veritabanları ile benzersizlik sağlanması vb.)

Biz MongoDB üzerinde eventlerin benzersiz değerleri üzerinden bir index mekanizması kurmayı planladık. Bu işlem ile birlikte, gelen event objeleri üzerindeki benzersiz parametreler ile “Unique” indexler yarattık ve her event işlenmeden önce veritabanı üzerine ilgili eventlerin bu değerlerini kaydettik. Storage yapımız üzerindeki performans optimizasyonunu sağlamak için de verilere TTL indexler atarak yoğunluğu minimum seviyede tutmayı hedefledik. Event herhangi bir consumer tarafından işleme alındığında veritabanına gerekli kayıt milisaniyeler içerisinde atılıyor. Diğer uygulama instancelarımız bu eventi işlemeye çalıştığında verinin başka bir consumer üzerinde işleniyor durumda olup olmadığını anlamak için bu koleksiyonda ilgili kaydın varlığını kontrol ediyor. Dolayısıyla kaydı gördüğü anda işlemi durduruyor ve ilk işleme alan consumer uygulamasına “buyur hocam top sende” diyor. İşte bu şekilde daha önce de bahsettiğim SMS/Email bildirimi sorunu gibi bir çok event consume duplication sorunumuzun önüne geçtik ve nihayetinde sistemi daha stabil bir hale getirebildik.

Aslında biraz da neden bu çözümü tercih ettiğimizden bahsetmek gerekir tam da bu noktada. Tercih edilme noktasında bir çok etken var tabi ki. Sonuçta bir ekip olarak çalışıyorsunuz ve bu sorun projenin geneline yayılmış bir sorun. Nihai sonuca ekip olarak varmak doğru olacaktır her zaman. Alternatifler arasında daha önce de bahsettiğim Redis distributed lock, InMemory cache, SQL veya NoSQL bazı teknolojiler gibi her biri ayrı ayrı önemli konu başlıkları vardı. Öncelikle InMemory Cache konusunun baştan elenmesinin sebebi kullanılan teknolojik alt yapıydı. Sonuçta aynı uygulamanın farklı instanceları kendi InMemory cache yapılarını kullanacakları için distributed yapıda aynı eventleri tekrar işleme riskini açık açık ortada bırakıyordu. Rediste distributed lock konusu aslında bu başlıkta çok yaygın kullanılan bir çözüm ancak bizim bulunduğumuz noktada mevcut kaynakları optimize kullanabilme açısından yeterli verimi sağlamayacağını düşündük. Performans olarak bazı kayıplar ön görülüyordu ve beklediğimiz tekilleştirme operasyonları milisaniyeler içerisinde gerçekleşmesi gerektiğinden yapılan bazı POC çalışmaları sonucu Redis’in bize bir çözüm olmayacağı kanaatine vardık. Ayrıca altyapısal olarak bazı çalışmalar gerektiriyordu. Biz ise mevcut altyapıyı kullanarak çözüm üretmeyi daha doğru bulduk. Gelinen son noktada daha önce de aşina olduğumuz MongoDB’de Unique ve TTL indexler ile sorunun üstesinden gelmek en akılcı ve en hızlı çözüm olarak ortaya çıktı.

Tabi benim bugüne kadar ki tecrübelerim hep şunu gösterdi. Yazılımda bir sorunun çözümü için aldığınız önlemler daima başka minör veya majör sorunlar ortaya çıkarır. Karar aşamasında sadece “worth” mü değil mi konusuna iyi odaklanmak gerekir. Biz bu çözümü servislerimize uygularken hem müşteri memnuniyetini ön planda tutmak, hem bazı riskli operasyonların tekrarlanmasını önlemek gibi amaçlarımız vardı. Bunların hepsinde de başarılı olduk ama gelgelelim tabi ki bir veritabanına yazım operasyonu yaptığımız için event consume operasyonları öncesi çok ufak da olsa bir zaman kaybını göze aldık. Bu zaman kaybı herhangi başka bir kayba daha yol açmadığı sürece elimizde var olan bu çözümle devam edilecektir ama kim bilir belki günün birinde bu da ayrı bir sorun olarak karşımıza çıkar ve bu da üzerine düşünülecek yeni bir mühendislik problemi demek. Belki benim için de üzerine düşünülüp yazılacak yeni bir konu başlığı demek olur :)

GitHub profilimde RabbitMQ, CAP ve MongoDB ile uyguladığımız çözümü de sizlerle paylaşmak isterim. Konuyla alakalı alternatif fikirleri de duymayı çok isterim açıkçası, veya daha önce karşılaşıp çözüm üretmiş birileriyle üzerine konuşmak da çok faydalı olur. Bu tarz durumlar için LinkedIn profilim aracılığıyla bana ulaşmanızı da rica etmiş olayım, naçizane. Umarım üretmiş olduğumuz bu çözümün bir gün birine faydası dokunur :)

Sağlıcakla kalın.

Kaynakça:

--

--