MongoDb, Lucene, Redis ve .NET Core ile Büyüyen Verilerin Gerçek Zamanlı İşlenmesi

Bu makale; milyonlarca verinin olabildiğince hızlı işlenip, tüm aramalara yeterli hızda cevap verilebilmesi için neler yapılabileceğinden bahsetmektedir.

Bu sebeple;
 lineFrontend: frontend peri masalı arayanlar için TL;DR :)

Durum:
 Proje istatistikleri; 15 milyonu aşmış benzersiz oturum ve 30 milyonu aşmış transaction (işlem) barındıran veri kümelerini kapsıyor. Bu rakamlar her geçen gün daha da artarak çözülmesi gereken büyük veri probleminin katsayısını arttırıyor.

İlişkisel bir veritabanında milyonlarca kayıt içeren yüklü bir veri kitlesi mevcut. Bu veri her geçen dakika artarak, uygulamanın bu veriler üzerinde anlamlı aramalar yapabilmesine müsade etmiyor, zaman kaybettiriyor ve en önemlisi katma değer üretemiyor. Peki bu durum için neler yapabiliriz? İlişkisel veritabanı tasarımı, indeksleme, performans ipuçları, dosya ayrıştırma, ön bellekleme, shrink, no lock, tuning, profiling vs. liste bayağı kabarık. İyi hoş hepsi güzel fakat bunların hepsine çalışmak yerine bir geliştirici çözümü üretelim mi?

MongoDb, Lucene ve Redis Muhteşem Üçlü

Bizden istenen verinin gerçek zamanlı erişilebilir, tutarlı ve hızla elde edilebilir olmasıydı.Veri kitlesinin her defasında tekrardan kaynaktan başka bir hedef veritabanına aktarılması ve indekslenmesi pek makul gözükmüyordu. Ancak veri tutarlılığından şüphe ettiğimiz durumlar için veri kitlesinin hızlıca sıfırdan oluşturulması da bir o kadar önemliydi.

Bu veri kitlesinin tamamını istenildiği anda yeniden, olabildiğince hızlı (1–2 saat içerisinde) işleyip sonrasında anlık değişiklikleri gerçek zamanlı yansıtıp veri tutarlılığını sağlamaya yönelik yöntemler arayarak işe koyuldum.

Önce Kaynaktan Seri Okuma

SQL veritabanları için genellikle sayfalama için kullandığım OFFSET ve LIMIT aralıklarında yazılan sorgular OFFSET değerinin artmasıyla inanılmaz yavaşlamıştı. 2000 ‘er demetler olarak çekilen veri kümeleri 50 ila 100 bin aralığındaki kayıtlardan sonra beyaz bayrak sallıyordu! Oysa işimiz milyonlarca veriydi.
 Kısa bir araştırmanın ardından basit ama hızlı bir sorgu tarzı buldum.

select * from sourceQuery where id > lastIndexedId and id <= rangeIndex

Hep aynı hızda, tutarlı ve gayet basit çalışan bu sorgu her zaman belirli aralıklardaki kayıtların getirilmesini sağlıyordu. Tabii bu işlem öncesinde döngü için toplam veritabanı kayıt sayısını veren bir “count” sorgusu da çekmem gerekti.

İndeksleme Süreçleri

1. Lucene

Kendine has doküman formatı ile veri kitlesinin tamamını indekslemek (her bir alan — key:value) makul sonuçlar doğurmadı. Özellikle metin aramadaki performansı tartışılmaz fakat veri kitlesi büyüyüp, metin alanının karakter sayısının artmasıyla senkronlama ve “index-maintenance” performansının düştüğünü gözlemledim. Veritabanından tamamen bağımsız sorguları Lucene ile düşünmek pek doğru olmadı belki fakat veri kümesine ait üyeler basit tipler (integer, string, boolean) ve sayıca az olsaydı (member — property) makul sonuç verebilirdi. Bu sebeple; Id ve Metin ikilisini indeksleme hızı, metin arama performansı ve bunları yaparken ihtiyaç duyduğu az sayıda kaynak ihtiyacı onu çözümün uygun olan yerinde kullanmamı sağladı. Lucene’ e tekrar dönüş yapacağız.

2. MongoDb vs ElasticSearch

Burada iki farklı teknolojiyi yarıştırmıyorum fakat çözüm gereği, veri kitlesinin çabuk işlenmesi ve yeterince hızlı sorgu sonuç değerinin elde edilmesi gerekiyordu. Böyle olunca da farklı teknolojilerin özelliklerini anlamak, alternatiflerden faydalanmak ve an azından bir şans verip denemek gerektiğini düşünüyorum.

ElasticSearch HTTP üzerinden çalışıyordu. Kendisine 2000 kayıttan oluşan bir demet (bundle) yolladım; her 2000 kayıtlık demet için indeksleme ortalama 2.5 saniye sürdü. Çözüm gereği milyonlarca kaydın çok daha hızlı ve yeri geldiğinde sıfırdan indekslenmesi gerektiğini göz önüne alarak tüm veri kitlesini test edemeden ElasticSearch’ ten vazgeçmek durumunda kaldım. Üstelik detaylı sorgu ve index hususlarına dahi bakamadan. Elastic Stack bir çok araç ve yöntem barındırıyor, maalesef hepsini test edebilecek vaktim yoktu.

Artan indekslenmiş kayıt sayısından sonra her bir teknolojide sonraki kayıtların daha yavaş indekslendiğini ve daha fazla kaynak kullandıklarını gözlemledim.

MongoDb ile o ana kadar elde ettiğim en hızlı indeksleme değerlerine ulaştım. 2000 lik demetleri bir saniyenin altında indeksleyerek kaydedebiliyordu. 2 milyon kaydın 13–14 dakika aralığında MongoDb’ ye aktarılması bizim için mükemmel bir sonuçtu taki büyük metin alanını “text” index tipinden belirtip süreci tekrarlayana dek. Bir anlık hayal kırıklığı oldu. Saatler geçiyor ama MongoDb veri kitlesini indekslemeyi bitiremiyordu.

Büyük metin alanını MongoDb içerisinde indekslemekten vazgeçip basit string — sözce alanlar için (Ad, Soyad) varsayılan tekil indeks özelliklerini kullanmayı denedim. Ayrıca sayısal ve koleksiyon alanlar içinde bu indeksleri tercih ettim. Süreci tekrarladım ve yarım saat içerisinde büyük metin araması olmaksızın diğer tüm istediğim alanlarda beklenen performansa eriştim. Aktarılan veri ve indeks tiplerine bağlı olarak 2 milyon kayıt için 6 GB RAM kullandığını gözlemledim. Bu arada MongoDb’nin varsayılan tekil “string” alan için indeks özelliği büyük-küçük karaktere duyarlıdır. Bunun için “Case Insensitive Indexes” kavramını araştırmanızı öneririm.

ElasticSearch’ ü büyük metin alanı olmaksızın indeksleme ve sorgu yapabilmesi için tekrar denemedim. MongoDb yolu yarılamıştı, zamanla yarışıyorduk artık bir dahaki sefere deyip devam ettim.

Buraya kadar herşey iyi gözüküyordu ama “Metin” içi arama krizini hala çözebilmiş değildim. MongoDb tarafında basit tipler, sayısal — lookup değerler muazzam sonuç vermişti ancak işin içine büyük metin girdiğinde ne indeksleme nede performans hatta “text” arama kalitesi tatmin etmemişti.

İşte burada Lucene devreye girdi. Veri kitlesinin indeksleme ve gerçek zamanlı senkronlanması sürecine Lucene’i de dahil etme kararı aldım. Böylece büyük “metin” alanı ve id (rowId) bilgisini burada indeksleyip, kapsamlı metin içi arama özelliğini tamamiyle Lucene’ e bıraktım. Tabii 2 milyon kaydın anlık yeniden indekslenmesi 1 saat aralığını geçti ama metin içi arama ve diğer basit tip aramalarının ayrıştırılmış olmasından doğan performans ağır bastı :)

Böylelikle metin içi arama yapıldığında Lucene — Term Query ile gelen skor ve ilişkili doküman id (rowId) lerine ait tüm kayıtlar, MongoDb den (indekslenmiş id alanına karşılık) 1–3 saniye aralığında dönüyordu. Hem sayfalama (pagination) hemde ön filtreleri (pre-filters, claims, flags) rahatça uygulayabiliyordum. İşlem hızlarında gözle görülür, sevindirici bir performans artışı söz konusuydu.

Örneğin metin içerisinde “front end frontend” kelime veya cümlesi geçen tüm kayıtlar, 1–2 saniye içerisinde “meşhur” Frontend cilerin UI Grid’ inde allı pullu listeniyordu :)

3. Redis

Veri tutarlılığını id ler üzerinden yürütmeyi düşündüm. Uygulama — Kod seviyesinde veri kitlesini ilgilendiren model-entity değişikliklerini bir proxy servis (HTTP) aracılığıyla anlık olarak sürekli senkronladım. Senkronlama sürecinde yaşanabilecek aksaklıklar veya hatalar için try-catch bloklarında typed-event tipinde Redis’ de daha sonra zamanlanmış görev-kod parçacıkları (Cron Job) tarafından tekrar çalıştırılmak üzere kayıtlar oluşturdum. İndeks ıskalama şansımız yoktu. Bir dakika arayla çalışan görev kodu Redis’ den DomainEvent:* öneki (prefix) ile kaydedilmiş tüm anahtarları (keys) getirip ilgili olay tipine göre oluşan context — sync işlemini tekrarlıyordu ta ki olumlu sonlanana dek. Bu arada Redis’ in Lua script ile işleyişi, kolaylığı muhteşem. Örnekler için StackExchange.Redis’ i incelemenizi öneririm.

“Şimdi tüm bu teknolojileri kullanmaya gerek var mı?” diye sorabilirsiniz. Cevabım: evet var! Hem de kesinlikle var! Tüm sorunları tekelleşmiş sağlayıcılar, onların yöntemleri ve teknolojileri ile çözmek bana doğru ve etik gelmiyor, zaten onlar da yeterli çözüm üretemediğinden alternatif teknolojiler geliştirilmiş, tabii anlayana. İstenirse yapılabileceğine inanıyorum, biraz gayret ve çaba yeterli.

Anlaşılan o ki, bu satıra kadar geldiysen bir ders çıkarmak niyetindesin. O zaman sırasıyla neler yapıldığından bahsedeyim. Bu arada aşağıda bahsedilen yöntemleri birebir uygulamak zorunda değilsin, her bir tanım farklı şekillerde de uyarlanabilir! Ben seçimlerimden bahsedeceğim.

Sistemini, kurguladığın mimariyi bugünler için hazır ettin mi peki? Yoksa sende hazırlıksız mı yakalandın? Frontend mi? hadi bakalım o zaman;

jmp lineFrontend :)

Backend — Underworld

1 — Uygulama içerisinde veritabanına kayıt, güncelleme veya silme işlemi yapan kodlar ortak bir yolu kullandılar. Böylece yapılan her işlemden haberi olan ve buna karşılık gelen olayı doğru “context” parametreleri ile tetikleyen olay merkezli yapıdan faydalanıldı.

... Database.ChangeSomething(new SomeModel { RowId = 1, ... }); await filter.InvokeAsync(new SomethingChangedEvent { ... }); ...

2 — Yapılan veritabanı işlemini ilgilendiren olay modelini IEquatable<SomethingChangedEvent> yapmanda fayda olacak, olayın aynı veritabanı modelini ilgilendiren veri için en fazla bir kez tetiklendiğinden emin olmalısın. Bunun için Id alanı veya GetHashCode tipi yöntemler kullanabilirsin.

public class SomethingChangedEvent : IEquatable<SomethingChangedEvent> { public string Id => "<RowIdValue>"; }

2 — Olay dağıtıcı görevini yerine getiren bir tip oluşturmalısın. Böylece çalışma zamanında oluşan olayları işleyicisine (Handler) doğru bir şekilde aktarabilirsin.

public interface IEventDispatcher { Task DispatchAsync<TEvent>(TEvent evnt) where : TEvent : IDomainEvent } ... var handlerInstance = ApplicationServices.GetService<IDomainHandler<TEvent>>(); ...

3 — Arkaplanda; belirlenen zaman aralıklarında bazı görevleri(Task) — kodları çalıştırabilen yapın olmalı. Dağıtık uygulamanın her bir parçacığı Redis’ e çalıştırmayı deneyip hata aldıkları olayları kaydediyor olacaklar. İşte bu zamanlanmış görevler, sistem için kritik ama bir şekilde çalışma anında hata almış olayları Redis’ ten çekip tekrar deneyecekler. İnatçı terslikler için bir “dev-dashboard” iyi olurdu. Burada NetCoreStack.Jobs veya Hangfire arama sonuçlarını inceleyebilirsin.

4 — İyi bir Proxy mimarisine ihtiyacın olacak; özellikle dağıtık servislerin yönetilmesinde type-safe ve ölçeklenebilir özellikler taşıması performans, bug fix, refactoring anlarında imdadına yetişir. Bunun için önerim NetCoreStack.Proxy Github şeklinde arama yapıp kütüphaneyi incelemen olacak.

5 — Kullandığın veritabanı veya ORM aracına uygun bir yaklaşımda kısaca Database Filter — Interceptor olarak adlandırabileceğimiz tasarım kurgulaman faydalı olacak. Aspect Oriented falan diyenlerde var bu tip işler için kısaca uygulama — kod seviyesinde her veritabanı değişikliğinden haberdar olabileceğin ortak bir kod bloğu prensibin olsun, işlerin oradan onay almadan geçmesine müsade etme mesala. Burada NetCoreStack.Mvc ve NetCoreStack.Data kütüphanelerinde kullandığımız “Data” ile ilgili bölümlerini inceleyebilirsin.

Bahsi geçen herşeyin yapılmışı var ama ne biliyor musun? Bak sana bir yol göstermeye çalıştım boşver yapılmışını bir de sen tek başına dene! Kendi mimari yapını yaz ve kurgula. Çabuk öğrenip uyarlamalısın, başka şansın yok! Burası underworld yani backend :) Eğer frontend ciysen (Webci, dbci, analizci…) yazık olmuş ben sana başta dedim;

jmp lineFrontend :)

Beğendiysen paylaş yok bu olmamış diyorsan; yorumların için burdayım @Gencebay


Originally published at gencebaydemir.com on December 3, 2017.