Merhaba,
Bu içeriğimizde .NET ile yaptığımız tekrarlayan mimarisel tasarım sorunlarına karşın nasıl çözümler getirebileceğimize dair örnek olaylar eşliğinde istişare ediyor olacağız. Özellikle yan etkilerin (side effect) söz konusu olduğu distributed senaryolara odaklanacak ve değerlendirmede bulunacağız. O halde hazırsanız hadi başlayalım…
Distributed mimarilerde asıl problem çoğu zaman işlemi yapmak değil, işlemin yan etkilerinin ne zaman, kaç kere ve hangi garantilerle gerçekleştiğini kontrol etmektir.
Side effect, bir fonksiyonun kendi çıktısından daha fazlasını değiştirmesi durumudur. .NET açısından distributed sistemlerde side effect durumunu 6 ana sınıfa ayırabilmemiz mümkündür.
| Tür | Ne değişiyor? | Örnek |
|---|---|---|
| State | Uygulamanın/DB’nin durumu değişkenlik gösterir. | DB UPDATE, Redis SET |
| I/O | Uygulama dışındaki dünyada değişkenlik gösterir. | HTTP, dosya, S3, SMTP |
| Temporal | Zaman/sıralama davranışlarındaki değişiklikler kastedilmektedir. | DateTime.Now, retry, timeout |
| Messaging | Mesaj akışlarıyla başka servisleri tetikleyerek değişkenlik gösterir. | Kafka/NATS/RabbitMQ event publish |
| Resource | Kaynak tüketimi yahut değişimi sağlayabilir. | Transaction, lock, connection, thread |
| Observability | Monitoring sistemlerinde değişkenlik gösterir. | Log, metric, trace |
Evet, şimdi senaryolara geçebilir ve mimarisel olarak olası sorunlara karşın nasıl çözümler önerdiğimize dair istişarede bulunabiliriz;
Senaryo 1 : DB’ye yazdım, event publish edemedim
Bir sipariş sürecini ele alalım… Sipariş geldikten sonra, bu siparişe dair bilgileri veritabanına kaydediyor ardından siparişle alakalı diğer işlemlerin yürütülebilmesi için message broker ile diğer servislere event fırlatıyoruz.
İki ayrı sisteme aynı iş akışı içerisinde veri yazılması durumlarına Dual Write denir.
Şöyle ki;
await dbContext.Orders.AddAsync(order);
await dbContext.SaveChangesAsync();
await eventBus.PublishAsync(new OrderCreated(order.Id));
Şimdi böyle bir çalışmada veritabanına kayıt yapıldıktan sonra message broker’dan hata alınırsa eğer bu durum oldukça tehlikeli bir durum arz edecektir. Düşünsenize… veritabanında kayıt var, lakin diğer servislerin bundan haberi yok. Böyle bir durum bütünsel anlamda alenen tutarsızlığa mahal verecektir. Keza daha da kötüsü, bu siparişe dair uygulamanın nihai sonucu elde edebilmesi için belki de ilgili veritabanı işleminin baştan tekrar yapılması gerekecektir. Çünkü, message broker’ın hata verdiği anda veritabanına kaydedilen siparişin hiçbir anlamı olmayacaktır.
Çözüm
İşte böyle bir duruma karşın en ideal çözüm message broker’a gönderilecek verinin Outbox Pattern ile önceden farklı bir tabloya işlenmesi ve ardından ayrı bir publisher ile bu tablodan event’lerin message broker’a gönderilmesidir. Bunun için konuya dair baştan sona derinlemesine incelemede bulunduğum Outbox Design Pattern Nedir? Nasıl Uygulanır? (Inbox Pattern Eşliğinde) başlıklı makalemi inceleyebilirsiniz.
Senaryo 2 : Event’i iki kere işledim
Outbox kullanınca tabi ki de problemler bitmemektedir 🙂 Publisher ile event’leri message broker’a gönderdiğimizde bu sefer de consumer’da event’in received edilme sürecinde patlama riski vardır. Şöyle ki, consumer gelen event’i işleyip veritabanına kaydedebilir ancak ACK göndermeden çökebilir. Haliyle message broker açısından bu durum “ACK gelmedi, demek ki bu event’e dair işlem yapılmadı” olarak yorumlanabilir.
Çözüm
İşte burada Idempotency kavramı devreye girmektedir. Yani her bir publish edilecek olan event için özel bir anahtar, token veya id gibi değer üretecek ve consumer’da bu değerle birlikte ilgili event’in işlendiğine dair veritabanına kayıt atacağız. Böylece consumer’a gelen her event’i kontrollü bir şekilde işleme tabi tutacak ve önceden bir kayıt yoksa gönül rahatlığıyla işleyerek tutarlılık açısından güvence sağlayacağız!
Senaryo 3 : Exactly-once denen şey esasında yok!
Event’in consumer’a bir kez ulaştırılması, exactly-once business effect garantisi vermeyecektir. Event consumer’a ulaştıktan sonra business işlem sistemin geri kalanında istenilen atomiklikte tamamlanamayabilir:
Event geliyor ➔ Para çekiliyor ➔ ACK gönderilecek ➔ 💥
Para çekme işlemi başarıyla gerçekleşmişken consuemr ACK göndermeden çökerse eğer, sistem event’in işlenip işlenmediğini kesin olarak bilemeyecektir. Consumer’ın retry/recovery mekanizması devreye girdiğinde aynı event yeniden işlenebilir ve para çekme gibi non-idempotent bir business operation birden fazla kez çalıştırılabilir.
Aynı durum request/response süreçleri için de geçerlidir. Kullanıcıdan gelen bir request neticesinde işlem gerçekleştirilebilir ama sonrasında türlü nedenlerden kaynaklı istek timeout’a düşebilir. Böyle bir durumda kullanıcının farkında olmaksızın tekrar aynı isteği gerçekleştirmesi yüksek olasıdır. Haliyle aynı işlem tekrar gerçekleştirilmiş olacaktır.
Dolayısıyla exactly-once delivery ile exactly-once business effect aynı problemler değildir. İlki mesajın consumer’a kaç kez ulaştığıyla ilgilenirken, ikincisi ise mesajın tetiklendiği business işlemin etkili olarak kaç kez gerçekleştiğiyle ilgilenir.
Çözüm
Bu tarz durumlara karşın yine Idempotency kavramı oldukça ideal çözüm getirmektedir.
Senaryo 4 : Retry mekanizması yan etkiyi büyütüyor
Şöyle bir kod oldukça masum görünüyor olabilir…
await retryPolicy.ExecuteAsync(async () =>
{
await paymentService.ChargeAsync(order);
});
Ancak burada ChargeAsync metodu idempotent değilse eğer retry davranışı -hata olursa işlemi tekrar yap- şeklinde değil, -belirsizlik durumunda yan etkiyi tekrar üret- şeklinde bir varlık gösterecektir.
Çözüm
Burada çözümden ziyade dikkat edilmesi gereken noktaları şöyle genelleyebiliriz; eğer retry mekanizması GET, DB SELECT vs. gibi okuma işlemlerinde kullanılıyorsa oldukça güvenlidir, ancak PUT ve POST işlemlerinde kesinlikle idempotency ile hareket edilmelidir.
Senaryo 5 : Saga Pattern’da side effect üretebilir!
Diyelim ki şöyle aşağıdaki gibi bir sipariş süreci olsun;
Burada her servisin kendi transaction’ına sahip olduğunu düşünelim.
Şimdi varsayalım ki bir siparişin işlenmesi sürecinde şu sorun meydana gelmiş olsun;
Payment ✓
Inventory ✓
Shipping ✗
Çözüm
Böyle bir durumda SQL transaction rollback yapılamaz çünkü, tüm bu servislerin her biri ayrı sistemlerdir. Dolayısıyla burada Saga ile bir kontrol sağlamalı ve gerektiği taktirde compensation transaction davranışı uygulanmalıdır.
Ayrıca Saga için kesinlikle şu makalelere göz atmanızı tavsiye etmekteyim;
- Microservice – Saga – Commands/Orchestration Implemantasyonu İle Transaction Yönetimi>
- Microservice Mimarilerde Saga Pattern İle Transaction Yönetimi
- Microservice – Saga – Events/Choreography Implemantasyonu İle Transaction Yönetimi
- Microservice – Saga – Commands/Orchestration Implemantasyonu İle Transaction Yönetimi
Tabi burada şuna dikkat etmekte de fayda görmekteyim. Compensation bir rollback değildir. Çünkü ChargePayment() işleminin matematiksel olarak RollbackPayment() diye bir karşılığı tabi ki de yoktur! Onun yerine RefundPayment() davranışı daha ideal olacaktır! Bu açıdan bakıldığında evet, Saga’nın kendisi de yeni side effect’ler üretmektedir.
Senaryo 6 : Distributed Lock kullanayım dedim, işler daha da karıştı
Varsayalım ki iki instance aynı job’ı çalıştırıyor olsun;
├──► GenerateInvoice()
Instance B ──┘
Burada görülen olası riske karşı tabi ki de çözüm olarak distributed lock akıllara gelecektir. Ancak burada Time To Live (TTL) ile ilgili bir durum söz konusudur.
TTL, koyulan lock’ın ne kadar süre geçerli kalacağını ifade eden bir terimdir.
Mesela:
TTL: 30 saniye
şeklinde bir yapılandırmadan 30 saniyelik bir kilitleme durumunun söz konusu olacağı anlaşılmaktadır. Yani 30 saniye sonra lock otomatik olarak silinecektir. Burada akıllara şu soru gelebilir: Neden lock’a TTL koyuyoruz? El-cevap, bunun nedeni lock’ı alan server’ın/instance’ın ölme ihtimalidir. Eğer alınan lock’un TTL’i yoksa bu lock sonsuza kadar tutulduğu storage’da (örneğin Redis) kalabilir ve böylece sistem kilitlenebilir. Böylece TTL sayesinde lock’ı alan server ölse dahi belirtilen süre kadar sonra lock’un kendiliğinden ortadan kalkacağının garantisini vermiş oluyoruz.
İşte distributed lock süreçlerinde tehlikeli nokta tamda burada söz konusu olabilmektedir. Diyelim ki A instance’ı TTL 30 saniye olacak şekilde lock’u aldı ve iş yapmaya başladı. Ve varsayalım ki işlemin 10 saniye civarında sürmesi bekleniyordu. Ancak beklenmeyen bir durum söz konusu oldu ve A instance’ının işlemi haddinden uzun sürmeye başladı ve TTL zamanı dolarak expire oldu. Artık yapılan işlemle ilgili bir lock yok! Haliyle A instance’ının işlemi devam ederken B instance’ı da storage’dan lock isteyebilir ve lock olmadığı için işlem sürecinde yeni bir lock edinerek A instance’ı hala çalışırken işlemlere başlayabilir.
işlem
işlem
B ───────────────────────► işlem
Bu vaziyette son duruma bakarsak eğer her iki instance gözünden de lock sahibi olduğu sanılmaktadır. Ancak storage’a gidersek son durumda B instance’ının lock sahibi olduğu görülecektir. Yani arap saçı misali bir problem durumuyla karşı karşıya kalındığı aşikardır.
Şöyle bir senaryoyla düşünsenize:
↓
Redis lock
↓
Siparişi işle
↓
Bankadan para çek
↓
Yeni lock aldı
↓
Aynı siparişi işle
↓
Bankadan para çek
Farkındaysanız buradaki asıl problem lock’ın bozulması değil, lock’ın geçerliliği bitmesine rağmen ilgili server’ın kendini hala yetkili sanmasıdır. Bu çok önemli ve kritik bir distributed systems problemidir.
Distribured lock, distributed consistency problemini ortadan kaldırmamakta, yalnızca belirli bir concurrency problemini yönetmektedir.
Çözüm
Çözüm olarak bu tarz bir durumda TTL’i sürekli yenilemek akla gelebilir. Evet, bu mümkündür de… Ki böylece lock yapılan işlem süreci boyunca canlı tutulacaktır.
TTL’in sürekli yenilenmesi davranışına lock renewal veya lease renewal denmektedir.
Ancak burada yeni problemler de söz konusu olabilir. Misal olarak server ile lock TTL’inin tutulduğu storage arasındaki bağlantı koparsa artık TTL yenilenemeyecektir ve daha da kötüsü ilgili server hala çalışırken diğer server storage’dan lock alıp mükerrer işlem durumu söz konusu olabilecektir.
Bu tarz bir duruma karşın çözüm getirebilmek için Fencing Token kavramı geliştirilmiştir.
İşlem mantığı oldukça basittir: sıralı bir numara gibi çalışma sergiler.
Diyelim ki;
A instance’ı lock alıyor…
ve çalışmaya başlıyor.
Sonra lock’ın TTL’i doluyor ve B gelip lock alıyor…
Son durumda;
B = 42 ← yeni
şeklinde bir vaziyet söz konusu oluyor.
Haliyle bu vaziyette A hala çalıştığını sanıp database’e bir şey yazmaya çalışırsa;
DB → “Hayır. En son 42’yi gördüm. Sen eskisin.”
şeklinde bir önlemle işlem reddediliyor.
Unutmayın! Bir distributed lock’ın sahibi olmak, işlemin geri kalanında hala yetkili olunduğunun garantisini vermemektedir!
Senaryo 7 : Cache invalidation: “DB güncel ama kullanıcı eski veriyi görüyor”
Redis’te cache’lenmiş bir verinin veritabanında güncellenmesi durumunda cache’deki halinin de ya güncellenmesi ya da silinmesi gerektiğini hepimiz biliyoruz. Ancak distributed bir mimaride burada bir risk teşkil ettiğine dikkatinizi çekmek isterim.
Diyelim ki aşağıdaki gibi iki adet API instance’ımız olsun…
/ \
/ \
Instance A Instance B
│ │
└──────┬──────────┘
│
Redis
│
SQL Server
Örneğin kullanıcı tarafından cache’lenmiş bir verinin orjinal veritabanında güncellendiğini ve bu talebin A instance’ı üzerinden alındığını varsayalım. Burada akış şöyle olacaktır;
↓
Instance A
↓
[Update] Data
↓
[Delete or Update] Cache
Ancak farklı bir kullanıcı tarafından gelen başka bir talepte ise cache’lenmiş verinin okunduğunu bunu da B instance’ının karşıladığını varsayalım.
↓
Instance B
↓
Redis
↓
???
İşte tam da burada zaman oldukça önemlidir… A instance’ı veritabanı güncelledikten sonra cache’i henüz silemeden ya da güncellemeden tam o sırada B instance’ı tarafından cache’deki veri okunursa eğer işte burada bir tutarsızlık durumu söz konusu olabilir!
│ │
│ DB → [New Data] │
│ │
│ ├── Redis → [Old Data]
│ │
├── Cache Delete │
│ │
İşte bu vaziyette ortaya çıkan probleme cache invalidation diyoruz…
Cache Invalidation; gerçek veri değiştiğinde, onun cache’teki eski kopyasını ne zaman ve nasıl geçersiz hale getireceğiz? problemidir!
Keza burada bahsettiğimiz problem bir tek cache’in kullanılmadığı senaryolar için daha yıkıcı olacaktır. Düşünsenize, aşağıdaki gibi daha gerçekçi bir durumda;
│
┌──────────┼──────────┐
↓ ↓ ↓
Redis Elasticsearch CDN
│ │ │
↓ ↓ ↓
API cache Search Web
veritabanındaki bir değişikliğin esasında buradaki tüm yapılanmalara yansıtılması gerekmektedir. Bu durumda da side effect explosion (yan etki patlaması) adını verdiğimiz durum cereyan edecektir. Çünkü bir fiziksel değişiklik aynı zamanda hem cache’e, hem Elasticsearch’e hem de CDN’e yansıtılacaktır… Ha yansıtılmıyor mu? Bu da farklı bir sı*ış senaryosuna karşılık gelecektir.
Örnek durumu daha da derinleştirirsek eğer;
await productRepository.UpdateAsync(product);
await redis.RemoveAsync($"product:{product.Id}");
await searchIndex.UpdateAsync(product);
await cdn.InvalidateAsync(product.Id);
await eventBus.PublishAsync(new ProductUpdated(product.Id));
vaziyetini ele alalım. Burada;
Redis delete ✓
Search update ✓
CDN invalidate ✗
Event publish …
şeklinde bir durumun söz konusu olduğunu varsayalım. Yani dikkat ederseniz DB, Redis ve Elasticsearch tutarlıyken CDN’de bir çökme meydana geldiği için tutarlılık bozulmaktadır. Yani artık sistemin farklı noktalarında aynı ürünün farklı gerçekleri mevcuttur.
İşte tüm bu vurguladığımız senaryolar gerçekten karşılaşılabilecek kritik durumlardır.
Çözüm
Ana veri güncellendiği her an cache’i de güncellemeye yahut silmeye özen gösterilmelidir. Ancak cache’deki bu işlem başarısız olursa yine tutarsızlık mevzu bahis olacağı için dikkat edilmelidir. Eğer ki, sistemin bağımlılığı bir tek Redis değilse (yani Elasticsearch, CDN vs. varsa) bu servislere doğrudan tek tek müdahale etmek yerine event-driven bir davranışla Updated event’i yayınlanabilir ve bu event yayınlandığında sisteme bağlı olan tüm harici servislere gerekli müdahalede bulunulabilir. Bu daha doğru ve esnetilebilir bir yaklaşım olacaktır. Ancak güvenilir olabilmesi için Outbox pattern ile birlikte Inbox pattern‘ı uygulamak gerekecektir. Çünkü bu event’in kaybolma kaygısı ile birlikte duplicate olarak işlenmesi durumları da kaçınılmaz olarak mevzu bahis olacaktır. Eee event kavramının ve Outbox davranışının olduğu yerde de ister istemez mükerrer işlemlere de dikkat edilmesi gerekecek ve sistemin idempotent olarak tasarlanması da gerekecektir.
Nihai olarak;
Fark edildiği üzere birçok senaryoda Idempotency, Outbox / Inbox, Saga ve benzeri pattern’lar sürekli karşımıza çıkmaktadır. Bunun temel sebebi, distributed sistemlerde başarısızlıkların istisnai durumlar değil, tasarımın doğal bir parçası olmasıdır.
Dolayısıyla bir sistemi tasarlarken bu pattern’ları yalnızca ihtiyaç ortaya çıktığında başvurulacak çözümler olarak görmek yerine, olası başarısızlık senaryolarını, tekrar çalıştırılmaları ve oluşabilecek side effect’leri daha en baştan hesaba katarak mimariyi şekillendirmek gerekmektedir.
Asıl hedef, tüm bu pattern’ları sisteme dahil etmek değil, sistemin hangi noktalarında tekrar, belirsizlik, kısmi başarısızlık ve yan etki oluşabileceğini önceden görerek, bunlara karşı doğru garantileri tasarımın bir parçası haline getirmektir. Böylece mimariyi sonradan yamalarla düzeltmek yerine, daha ilk günden güvenilir, öngörülebilir ve başarısızlıklar karşısında dayanıklı bir yapıya doğru evriltmemiz en doğru ve ideal zemini sağlıyor olacaktır.
İlgilenenlerin faydalanması dileğiyle…
Sonraki yazılarımda görüşmek üzere…
İyi çalışmalar…
