Microservice Mimarisinde Two-Phase Commit(2PC) Pattern İle Transaction Yönetimi – Nedir? Nasıl Uygulanır? Saga’dan Farkı Nedir?
Merhaba,
Bu içeriğimizde microservice yaklaşımını uyguladığımız mimarilerde veri tutarlılığını sağlamanın davranışlarından biri olan Two-Phase Commit(2PC) modelini ele alacak, nasıl ve hangi durumlarda kullanıldığını değerlendiriyor olacağız. Ayrıca neden Saga pattern’ına nazaran daha az tercih edilmesi gerektiğine dair gerekli istişarede bulunacak ve içeriğimizin en önemli bölümü olarak pratiksel olarak ilgili konuyu bir senaryo üzerinden tecrübe ediyor olacağız. O halde buyurun başlayalım…
Öncelikle microservice mimarisinde transaction yönetimi için uyguladığımız Saga pattern’ına dair önceden klavyeye aldığımız makaleleri şöyle okunma sırasına göre aşağıda listeleyelim;
- Microservice Mimarilerde Saga Pattern İle Transaction Yönetimi
- Microservice – Saga – Commands/Orchestration Implemantasyonu İle Transaction Yönetimi
- Microservice – Saga – Commands/Orchestration Implemantasyonu İle Transaction Yönetimi
Two-Phase Commit(2PC) Modeli Nedir?
Two-Phase Commit(2PC) modeli, tıpkı Saga gibi distributed sistemlerdeki transaction yönetimini sağlayabilmek ya da verisel tutarsızlığa karşın çözüm getirebilmek için kullanılan bir senkronizasyon protokolüdür. Bu protokol sayesinde dağıtık sistemde rol oynayacak olan tüm düğümlerin(node) yani servislerin, bir işlemi atomik olarak gerçekleştirmesini veyahut o işlemin komple iptal edilmesini sağlamak için kullanılan bir bütünsel algoritma görevi görmektedir.
Two-Phase Commit pattern’ında, davranışsal olarak bir rolü ifade eden, sürecin bir parçası olarak katılım gösteren ve protokolün gerektirdiği görevleri yerine getiren katılımcılara(yani servislere) node denmektedir.
Atomik olarak diyoruz çünkü Two-Phase Commit modeli ile ya işlem tamamen başarılı olacaktır ya da hiçbir şey yapılmamış gibi esasında geri alınacaktır. Böylece bu protokol sayesinde, distributed yapılanmanın gereği olarak birden fazla serviste söz konusu olan veritabanı işlemleri sanki tek bir bütünmüş gibi başarılı bir şekilde tamamlanmış olacaktır.
2PC Modelinin Temel İlkeleri
Two-Phase Commit pattern’ının temel ilkesi aşağıdaki gibi iki aşamaya dayanmaktadır.
- Phase 1 – Prepare (Hazırlık Aşaması)
Yapılması gereken işlem doğrultusunda, iş/sorumluluk yürütecek olan her bir microservice’in öncelikle işlemi yapmak için hazır olduğunu belirteceği aşamadır.Bu aşamada, yandaki görselde de görüldüğü üzere bu koordinatör sorumluluğunu üstlenecek olan yönetici bir servis üzerinden koordinasyon sağlanacaktır.
Koordinatör; servislere hazır olup olmadıklarına dair bir talepte bulunacak, servisler de bu talep neticesinde durumlarını koordinatöre bildirecektirler. Bu durumda tüm servisler hazırsa eğer ikinci aşamaya geçilecek, yok eğer herhangi bir servis hazır olmadığını söyler ya da talebe karşılık vermezse 2PC’nin atomik mantığı gereği tüm işlemler iptal edilecek ve başarısız olarak değerlendirilecektir.
Anlayacağınız bu aşamada sistemin bütünsel yani atomik hareket edebilmesi için bir hazırlık süreci söz konusudur diyebiliriz. Normalde bu aşama olmaksızın direkt ikinci aşamayı uygulamaya kalkarsanız sistemin bütünselliği ve 2PC modelinin uygulanabilirliği zaafa uğrayacaktır ve erişilemeyen ya da hata veren servislere, operasyonun yapılması talimatının verilerek olası problemlere kapı aralanmış olacaktır. Bu sebeple bu aşama oldukça önem arz etmektedir.
Haliyle bu aşamayı adımsal olarak aşağıdaki gibi özetleyebiliriz;
- Koordinatör kullanıcıdan talebi alır.
- Koordinatör aldığı talep doğrultusunda, yapacağı işlemi ilgilendirecek olan tüm node’lara hazır olup olmadıklarına dair mesaj gönderir ve tüm katılımcılardan dönüş bekler.
-
- Katılımcıların hepsinden ‘Ok’, ‘Yes’, ‘he la hazırız’ vs. gibisinden mesajların gelmesi neticesinde koordinatör ikinci aşamayı başlatır.
- Katılımcıların en az birinden ‘Abort’, ‘No’, ‘ben hazır değilim aga’ vs. şeklinde mesaj alındığı ya da hiç cevap gelmediği taktirde ikinci aşamaya geçilmeksizin kullanıcıdan gelen talep iptal edilir.
- Phase 2 – Commit (Taahhüt Aşaması)
Bu aşama ise koordinatörün tüm microservice’lerin hazır olduğunu doğrulaması neticesinde hiçbir sorunun olmadığına kanaat getirmesiyle, tüm servislere, tüm işlemlerin kesin olarak gerçekleştirilmesi içincommit
talimatını verdiği aşamadır.Yani anlaşılan bu aşama artık servislerin operatif harekatı fiili olarak yürüttüğü aşamadır. Dolayısıyla bu aşamanın da adımsal olarak özeti aşağıdaki gibi olacaktır;
- Koordinatör tüm katılımcıların hazır olduğuna kanaat getirdikten sonra tüm servislerin sorumlulukları gereği operasyonlarını başlatması için
commit
mesajını gönderir. - Koordinatör, katılımcıların işlemlerini tamamlaması üzerine onlardan ‘Ack’, ‘Ok’, ‘ben tamamım müdür’ vs. şeklinde yanıt bekler.
-
- Katılımcıların hepsinden beklenen yanıt alındığı taktirde kullanıcı talebi başarıyla tamamlanmış olur.
- Katılımcılardan en az birinden beklenen cevabı alamazsa ya da hiç cevap gelmezse işlem iptal edilecek ve tüm servislerden yaptıklarının geri alınması için
abort
mesajını gönderir.
- Koordinatör tüm katılımcıların hazır olduğuna kanaat getirdikten sonra tüm servislerin sorumlulukları gereği operasyonlarını başlatması için
Bu ilkeler doğrultusunda 2PC davranışını atomik olarak sağlamış olacaktır.
2PC’nin Terminolojik Aktörleri
Yukarıdaki satırlarda 2PC modelinin tasarım sürecinde ‘Koordinatör’, ‘Katılımcı’ vs. gibi terminolojik aktörlerin olduğunu görüyoruz. Ee haliyle içeriği rahatça takip edebilmek ve ortak bir dil ortaya koyabilmek için bu protokol sürecinde kullanılan aktörleri tam olarak tanımlamakta fayda vardır;
- Koordinatör (Coordinator)
2PC protokolünün merkezi yönetici konumunda olan servistir. Koordinatör, işlemin başlatılmasından sonuna kadar tüm adımları kontrol etmekte, süreçte her microservice’in hazırlık aşamasındaki onayını beklemekte ve ardından işlemi tamamlamak yahut geri almak içincommit
veyaabort
mesajlarını göndererek katılımcılarla gerekli iletişimi kurmaktadır. - Katılımcılar (Participants)
2PC protokolü sürecinde, kullanıcıdan gelen talep doğrultusunda işlem/operasyon yürütecek olan microservice’lerin ta kendileridir. Her bir katılımcı yapılacak işlem için hazır olduğuna dair koordinatöre bilgi vermekte ve koordinatörden gelecek olancommit
veabort
talimatlarına göre işlevlerini yürütmektedir. - Veritabanları
Her bir katılımcı, genellikle kendine özgü bir veritabanına erişim sağlayarak işlem gerçekleştirmektedir. - Gözlemciler (Observers)
Gözlemciler ise 2PC protokol sürecini izleyen ancak doğrudan işleme katılmayan aktörlerdir.
2PC Protokolü Ne Amaçla Kullanılmalıdır?
Two-Phase Commit protokolü, yukarıdaki satırlardan anlaşılacağı üzere dağıtık sistemlerdeki veritabanı işlemlerini ya kümülatif olarak başarılı kılacak ya da hiçbiri gerçekleşmeyecek şekilde yani atomik bir yaklaşımla koordine etmek için kullanılmaktadır. Haliyle buradaki en büyük gayenin, microservice’lerin aralarındaki verisel tutarlılığın bütünsel olarak sağlanması veyahut korunması olduğunu söyleyebiliriz. Ayrıca microservice mimarisinin söz konusu olduğu senaryolarda, servisler arası işlem koordinasyonunun 2PC ile merkezi bir nokta üzerinden gerçekleştirilmesi ve böylece süreç yönetiminin de kolaylaştırılması amaçlanmaktadır diyebiliriz.
Ne Zaman Saga & Ne Zaman 2PC Kullanılmalıdır?
Eğer ki, distributed transaction’ın tüm katılımcılar açısından bütünsel olarak commit edilmesi ve gerektiği taktirde de rollback edilmesi durumları söz konusuysa tabi ki de buradaki ihtiyacın güvenliğini sağlayabilmek için 2PC protokolünden istifade edilebilir. Ancak içeriğimizin devamındaki satırlarda da göreceğimiz üzere 2PC, kullanıcıdan gelen talebe karşın atomik bir tutarlılık sağlayabilmek için servislerin kümülatif hareket edebilmesi amacıyla topyekün bir kilitlenmeye mahal verecek ve bu durum da yüksek oranda performans sorunlarına sebebiyet verecektir. Bu nedenle az sayıdaki katılımcının olduğu ve bu protokolün yaratacağı kilitleme neticesindeki gecikmelerin ve bu gecikmelerin de yaratacağı ölçeklenebilirlik sınırlarının yönetilebileceği senaryolarda 2PC modelinin tercih edilmesi en doğrusu olacaktır.
Saga ise yapılacak işlemin tek bir 2PC protokolü tarafından yönetilemeyecek kadar büyük olduğu durumlarda tercih edilebilir. Saga, kullanıcı tarafından talep edilen işlem her ne ise, bu işlemi her bir microservice tarafından bağımsız olarak yönetilebilecek ve işlenebilecek küçük local işlemlere bölecektir. Böylece her bir service ihtiyaç doğrultusunda daha rahat ölçeklendirilebilecek ve herhangi bir gecikme vs. olmaksızın, yüksek hata toleransı eşliğinde gayet performanslı bir süreç sonucu beklenen netice elde edilebilecektir. Ayrıca özellikle 2PC protokolü ile işlemin geri alınma durumunun oldukça maliyetli olabileceği karmaşık senaryolarda, Saga pattern’ı ile daha basit ama etkili çözümler getirilebilecektir.
2PC’nin Pratiksel Örneklendirilmesi
Şimdi 2PC protokolünü pratiksel olarak baştan sona bir örnek senaryo üzerinden tecrübe edelim istiyorum. Bunun için bir e-ticaret sisteminin klasik ‘Order.API’, ‘Stock.API’ ve ‘Payment.API’ servislerinden oluşan sipariş sürecini senaryo olarak değerlendiriyor olacağız. O halde vakit kaybetmeksizin adım adım örneğin geliştirilmesine başlayalım;
- Adım 1 (Participants & Coordinator Servislerinin Oluşturulması)
İlk olarak katılımcı servislerini ve bu servisler arasındaki koordinasyonu sağlayacak olan koordinatör servisini oluşturalım.(Participant) dotnet new webapi --name Order.API
(Participant) dotnet new webapi --name Stock.API
(Participant) dotnet new webapi --name Payment.API
(Coordinator) dotnet new webapi --name Coordinator
- Adım 2 (Coordinator Veritabanının Hazırlanması)
Koordinatör servisinin, katılımcıları yönetebilmesi için öncelikle katılımcı bilgilerini barındırdığı veritabanını tasarlamamız gerekmektedir. Bunun için aşağıdaki entity’lerden istifade edebiliriz:public record Node(string Name) { public Guid Id { get; set; } public ICollection<NodeState> NodeStates { get; set; } }
public record NodeState(Guid TransactionId) { public Guid Id { get; set; } public ReadyType IsReady { get; set; } public TransactionState TransactionState { get; set; } public Node Node { get; set; } }
Yukarıdaki entity’lere göz atarsanız eğer sistemdeki katılımcılar ‘Node’ entity’si ile, bu katılımcıların transaction kayıtları ise ‘NodeState’ ile bire çok bir ilişkiyle modellenmiştir. Her bir node için başlatılan transaction’ı diğerlerinden ayırt edebilmek için bir ‘TransactionId’ eşliğinde, 2PC protokolünün ilk aşamasındaki hazırlık durumunu ifade eden ‘IsReady’ ve nihai olarak da transaction’ın neticesindeki durumu ifade eden ‘TransactionState’ verilerini tuttuğumuza dikkatinizi çekerim.
‘NodeState’ entity’sinde kullandığımız enum değerlerin içerikleri de aşağıdaki gibi olacaktır:
public enum ReadyType { Ready, Pending, Unready }
public enum TransactionState { Done, Pending, Abort }
Bu tanımlamadan sonra context nesnesini aşağıdaki gibi oluşturalım:
public class TwoPhaseCommitContext : DbContext { public TwoPhaseCommitContext(DbContextOptions options) : base(options) { } public DbSet<Node> Nodes { get; set; } public DbSet<NodeState> NodeStates { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Node>().HasData( new Node("Order.API") { Id = Guid.NewGuid() }, new Node("Stock.API") { Id = Guid.NewGuid() }, new Node("Payment.API") { Id = Guid.NewGuid() } ); } }
Burada da context nesnesinin detayına göz atarsanız eğer sistemde kullanılacak tüm katılımcılar seed data olarak veritabanına eklenmektedir. Tabi burada katılımcıları eklemek için farklı davranışlar benimseyebilirdik amma velakin konuyu dallandırıp budaklandırmamak için bu şekilde bir yaklaşım tercih ediyorum.
Oluşturulan bu context nesnesini de kullanabilmek için koordinatör servisinin ‘Program.cs’ dosyasında aşağıdaki gibi yapılandırmada bulunalım:
var builder = WebApplication.CreateBuilder(args); . . . builder.Services.AddDbContext<TwoPhaseCommitContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("SQLServer"))); . . . app.Run();
Ve son olarak da
add-migration mig_1
talimatı eşliğinde bir migration oluşturalım ve ardından daupdate-database
talimatı ile oluşturulan bu migration’ı migrate edelim. - Adım 3 (Coordinator Servisinde ‘TransactionService’in Oluşturulması ve Temel Participant Ayarlarının Yapılması)
Artık koordinatör servisinde katılımcıların bilgilerini ve tüm transaction bilgilerini yönetebilecek veritabanı yapılanması oluşturulduğuna göre, koordinatörün bu sorumluluğunu üstlenecek olan ‘TransactionService’ isimli sınıfını oluşturabiliriz.Önce bu sınıfın imzası olan arayüzü aşağıdaki gibi tasarlayalım:
public interface ITransactionService { public Task<Guid> CreateTransaction(); public Task PrepareServices(Guid transactionId); public Task<bool> CheckReadyServices(Guid transactionId); public Task<bool> CheckTransactionStateServices(Guid transactionId); public Task Commit(Guid transactionId); public Task Rollback(Guid transactionId); }
Ardından bu arayüzü uygulayan sınıfın kendisini oluşturalım:
public class TransactionService : ITransactionService { public Task<bool> CheckReadyServices(Guid transactionId) { throw new NotImplementedException(); } public Task<bool> CheckTransactionStateServices(Guid transactionId) { throw new NotImplementedException(); } public Task Commit(Guid transactionId) { throw new NotImplementedException(); } public Task<Guid> CreateTransaction() { throw new NotImplementedException(); } public Task PrepareServices(Guid transactionId) { throw new NotImplementedException(); } public Task Rollback(Guid transactionId) { throw new NotImplementedException(); } }
Evet, birazdan bu servis içerisindeki metotları adım adım kodluyor olacağız. Şimdi koordinatör uygulamasının ‘Program.cs’ dosyası üzerinden IoC container’a ilgili servisi ekleyelim ve biryandan da katılımcıların base address bilgilerini ‘HttpClient’ konfigürasyonuyla tanımlayarak yapılandırmayı tamamlayalım.
var builder = WebApplication.CreateBuilder(args); . . . builder.Services.AddHttpClient("OrderAPI", client => client.BaseAddress = new("https://localhost:7287/")); builder.Services.AddHttpClient("StockAPI", client => client.BaseAddress = new("https://localhost:7033/")); builder.Services.AddHttpClient("PaymentAPI", client => client.BaseAddress = new("https://localhost:7121/")); builder.Services.AddTransient<ITransactionService, TransactionService>(); builder.Services.AddDbContext<TwoPhaseCommitContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("SQLServer"))); . . . app.Run();
Yukarıdaki kod bloğuna göz atarsanız eğer 9. satırda oluşturduğumuz servisi IoC container’a eklerken biryandan da 5 ile 7. satır aralığında ise bahsi geçen katılımcı servislerin adresleri tanımlanmaktadır.
Bu tanımlama neticesinde ‘TransactionService’ sınıfında bu katılımcıların ‘HttpClient’ bilgilerini aşağıdaki gibi hali hazır hale getirelim.
public class TransactionService(IHttpClientFactory _httpClientFactory, TwoPhaseCommitContext _context) : ITransactionService { HttpClient _httpClientOrderAPI = _httpClientFactory.CreateClient("OrderAPI"); HttpClient _httpClientStockAPI = _httpClientFactory.CreateClient("StockAPI"); HttpClient _httpClientPaymentAPI = _httpClientFactory.CreateClient("PaymentAPI"); . . . }
Tabi biryandan da oluşturduğumuz context nesnesini de inject ettiğime dikkatinizi çekerim.
- Adım 4 (Coordinator’de CreateTransaction Metodunun Tasarlanması)
Şimdi koordinatör servisinde tüm altyapı hazırlandığına göre kullanıcı talebi doğrultusunda transaction’ı başlatacak olan ‘CreateTransaction’ metodunu geliştirebiliriz. Bunun için aşağıdaki gibi bir çalışma gerçekleştirebiliriz:public class TransactionService(IHttpClientFactory _httpClientFactory, TwoPhaseCommitContext _context) : ITransactionService { HttpClient _httpClientOrderAPI = _httpClientFactory.CreateClient("OrderAPI"); HttpClient _httpClientStockAPI = _httpClientFactory.CreateClient("StockAPI"); HttpClient _httpClientPaymentAPI = _httpClientFactory.CreateClient("PaymentAPI"); . . . public async Task<Guid> CreateTransaction() { Guid transactionId = Guid.NewGuid(); var nodes = await _context.Nodes.ToListAsync(); nodes.ForEach(node => node.NodeStates = new List<NodeState> { new(TransactionId : transactionId) { IsReady = ReadyType.Pending, TransactionState = TransactionState.Pending }, }); await _context.SaveChangesAsync(); return transactionId; } . . . }
Görüldüğü üzere gelen isteğe karşın tüm node’lar için bir ‘NodeState’ oluşturulmakta ve üretilmiş olan transaction id ile bu state satırları eşleştirilmektedir. Ayrıca henüz bu servislerin hazır olup olmadıkları kontrol edilmediği için ve hala transaction süreci sona ermediği için ‘IsReady’ ve ‘TransactionState’ property’lerine ‘pending’ değeri verilmektedir.
- Adım 5 (2PC Protokolünün 1. Aşamasının Kodlanması – PrepareServices Metodunun Tasarlanması)
Artık transaction başlatıldığına göre 2PC protokolünün 1. aşaması olan katılımcıların hazırlanması evresini tasarlayabiliriz. Bunun için ‘PrepareServices’ metodunu aşağıdaki gibi geliştirebiliriz.public class TransactionService(IHttpClientFactory _httpClientFactory, TwoPhaseCommitContext _context) : ITransactionService { HttpClient _httpClientOrderAPI = _httpClientFactory.CreateClient("OrderAPI"); HttpClient _httpClientStockAPI = _httpClientFactory.CreateClient("StockAPI"); HttpClient _httpClientPaymentAPI = _httpClientFactory.CreateClient("PaymentAPI"); . . . public async Task PrepareServices(Guid transactionId) { var transactionNodes = await _context.NodeStates .Include(ns => ns.Node) .Where(n => n.TransactionId == transactionId) .ToListAsync(); foreach (var transactionNode in transactionNodes) { try { var response = transactionNode.Node.Name switch { "Order.API" => await _httpClientOrderAPI.GetAsync("ready"), "Stock.API" => await _httpClientStockAPI.GetAsync("ready"), "Payment.API" => await _httpClientPaymentAPI.GetAsync("ready") }; //Katılımcıların ready endpoint'inden result olarak true ya da false dönmesini bekliyoruz. var result = bool.Parse(await response.Content.ReadAsStringAsync()); await Console.Out.WriteLineAsync(result.ToString()); transactionNode.IsReady = result ? ReadyType.Ready : ReadyType.Unready; } catch { transactionNode.IsReady = ReadyType.Unready; } } await _context.SaveChangesAsync(); } . . . }
Yukarıdaki kod bloğunu incelerseniz eğer parametreden gelen transaction id değeriyle eşleşen ‘NodeStates’ler sorgulanmakta ve her bir node’un ‘ready’ endpoint’ine hazır olup olmadıklarına dair cevap alabilmek için istek gönderilmektedir. Bu istekler neticesinde kod içerisinde belirtildiği gibi gelecek olan result’lar bool türde beklenmekte ve ona göre 23. satırda dönüşüm gerçekleştirilmektedir. Eğer ki ‘true’ sonucu alınıyorsa eğer ‘IsReady’ property’sinin değeri ‘ready’ olarak değiştirilmekte, yok eğer alınmıyorsa veya herhangi bir hata durumu söz konusuysa ‘unready’ olarak değiştirilmektedir.
- Adım 6 (Tüm Katılımcıların Uygun Olup Olmadığının Kontrol Edilmesi – CheckReadyServices Metodunun Tasarlanması)
‘PrepareServices’ metodu ile tüm katılımcıların hazır olup olmadığına dair cevap alabilmek için gönderilen istek neticesindeki durumu bütünsel olarak kontrol etmemiz gerekmektedir. Bunun için de ‘CheckReadyServices’ metodunu aşağıdaki gibi geliştirebiliriz:public class TransactionService(IHttpClientFactory _httpClientFactory, TwoPhaseCommitContext _context) : ITransactionService { HttpClient _httpClientOrderAPI = _httpClientFactory.CreateClient("OrderAPI"); HttpClient _httpClientStockAPI = _httpClientFactory.CreateClient("StockAPI"); HttpClient _httpClientPaymentAPI = _httpClientFactory.CreateClient("PaymentAPI"); . . . public async Task<bool> CheckReadyServices(Guid transactionId) => (await _context.NodeStates .Where(ns => ns.TransactionId == transactionId) .ToListAsync()) .TrueForAll(n => n.IsReady == ReadyType.Ready); . . . }
Evet, görüldüğü üzere parametre üzerinden gelen transaction id değeriyle karşılaşan ‘NodeState’ verilerinin hepsinin, ‘IsReady’ değerinin true olup olmadığı kontrol edilmektedir.
- Adım 7 (2PC Protokolünün 2. Aşamasının Kodlanması – Commit Metodunun Tasarlanması)
Eğer ki ‘CheckReadyServices’ metodu geriye ‘true’ değerini döndürürse koordinatör 2PC protokolünün ikinci aşamasına geçecektir ve tüm katılımcılardan üzerlerine düşen operasyonları yapmalarının talimatını verecektir. Bunun için ‘Commit’ metodunda aşağıdaki gibi çalışma gerçekleştirebiliriz:public class TransactionService(IHttpClientFactory _httpClientFactory, TwoPhaseCommitContext _context) : ITransactionService { HttpClient _httpClientOrderAPI = _httpClientFactory.CreateClient("OrderAPI"); HttpClient _httpClientStockAPI = _httpClientFactory.CreateClient("StockAPI"); HttpClient _httpClientPaymentAPI = _httpClientFactory.CreateClient("PaymentAPI"); . . . public async Task Commit(Guid transactionId) { var transactionNodes = await _context.NodeStates .Include(ns => ns.Node) .Where(ns => ns.TransactionId == transactionId) .ToListAsync(); foreach (var transactionNode in transactionNodes) { try { var response = transactionNode.Node.Name switch { "Order.API" => await _httpClientOrderAPI.GetAsync("commit"), "Stock.API" => await _httpClientStockAPI.GetAsync("commit"), "Payment.API" => await _httpClientPaymentAPI.GetAsync("commit") }; //Katılımcıların ready endpoint'inden result olarak true ya da false dönmesini bekliyoruz. var result = bool.Parse(await response.Content.ReadAsStringAsync()); transactionNode.TransactionState = result ? TransactionState.Done : TransactionState.Abort; } catch { transactionNode.TransactionState = TransactionState.Abort; } } await _context.SaveChangesAsync(); } . . . }
Yukarıdaki çalışmayı gözlemlersek eğer yine parametre üzerinden gelen transaction id değerine göre state’ler elde edilmekte ve tüm node’ların ‘commit’ endpoint’ine istekler gönderilerek ikinci aşama katılımcılar açısından başlatılmaktadır. Ve yine koddan da anlaşılacağı üzere commit işlemleri neticesinde servislerden boolean türde sonuç beklenmekte ve 23. satırda bu sonuç boolean’a dönüştürülerek, ardından ‘TransactionState’ property’sinin değeri belirlenmektedir.
- Adım 8 (Tüm Katılımcılar Açısından Transaction’ın Başarılı Olup Olmadığının Kontrol Edilmesi – CheckTransactionStateServices Metodunun Tasarlanması)
Commit işlemi yapıldığına göre artık bu sürecinde tüm katılımcılar açısından başarılı olup olmadığının değerlendirilmesi gerekmektedir. Bunun için ‘CheckTransactionStateServices’ metodunun aşağıdaki gibi geliştirilmesi yeterlidir:public class TransactionService(IHttpClientFactory _httpClientFactory, TwoPhaseCommitContext _context) : ITransactionService { HttpClient _httpClientOrderAPI = _httpClientFactory.CreateClient("OrderAPI"); HttpClient _httpClientStockAPI = _httpClientFactory.CreateClient("StockAPI"); HttpClient _httpClientPaymentAPI = _httpClientFactory.CreateClient("PaymentAPI"); . . . public async Task<bool> CheckTransactionStateServices(Guid transactionId) => (await _context.NodeStates .Where(ns => ns.TransactionId == transactionId) .ToListAsync()) .TrueForAll(n => n.TransactionState == TransactionState.Done); . . . }
- Adım 9 (Rollback Metodunun Tasarlanması)
Eğer ki commit sürecinde herhangi bir hata söz konusu olursa yapılan tüm çalışmaların geri alınması gerekmektedir. Bunun için de ‘Rollback’ metodunun aşağıdaki gibi geliştirilmesi gerekmektedir:public class TransactionService(IHttpClientFactory _httpClientFactory, TwoPhaseCommitContext _context) : ITransactionService { HttpClient _httpClientOrderAPI = _httpClientFactory.CreateClient("OrderAPI"); HttpClient _httpClientStockAPI = _httpClientFactory.CreateClient("StockAPI"); HttpClient _httpClientPaymentAPI = _httpClientFactory.CreateClient("PaymentAPI"); . . . public async Task Rollback(Guid transactionId) { var transactionNodes = await _context.NodeStates .Include(ns => ns.Node) .Where(n => n.TransactionId == transactionId) .ToListAsync(); transactionNodes.ForEach(async transactionNode => { try { if (transactionNode.TransactionState == TransactionState.Done) _ = transactionNode.Node.Name switch { "Order.API" => await _httpClientOrderAPI.GetAsync("rollback"), "Stock.API" => await _httpClientStockAPI.GetAsync("rollback"), "Payment.API" => await _httpClientPaymentAPI.GetAsync("rollback"), }; transactionNode.TransactionState = TransactionState.Abort; } catch { transactionNode.TransactionState = TransactionState.Abort; } }); await _context.SaveChangesAsync(); } . . . }
Burada 16 ile 23. satır aralığına göz atarsanız eğer commit sürecinde ‘TransactionState’i ‘Done’a çekilen transaction’lar için rollback mekanizmasının başlatıldığına dikkatinizi çekerim.
- Adım 10 (Katılımcıların Yapılandırılması)
Artık koordinatörün temel operasyonunu tamamladığımıza göre katılımcıların yapılandırılmasına el atabiliriz. Bunun için ‘Order.API’, ‘Stock.API’ ve ‘Payment.API’ servislerinde sırasıyla aşağıdaki endpoint’leri oluşturalım.var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.MapGet("/ready", () => { Console.WriteLine("Order service is ready."); return true; }); app.MapGet("/commit", () => { Console.WriteLine("Order service is commited."); return true; }); app.MapGet("/rollback", () => { Console.WriteLine("Order service is rollbacked."); }); app.Run();
var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.MapGet("/ready", () => { Console.WriteLine("Stock service is ready."); return true; }); app.MapGet("/commit", () => { Console.WriteLine("Stock service is commited."); return true; }); app.MapGet("/rollback", () => { Console.WriteLine("Stock service is rollbacked."); }); app.Run();
var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.MapGet("/ready", () => { Console.WriteLine("Payment service is ready."); return true; }); app.MapGet("/commit", () => { Console.WriteLine("Payment service is commited."); return true; }); app.MapGet("/rollback", () => { Console.WriteLine("Payment service is rollbacked."); }); app.Run();
- Adım 11 (Coordinator’de ‘CreateOrderTransaction’ Endpoint’inin Oluşturulması)
Yapmış olduğumuz tüm bu çalışmaların, kullanıcılardan gelecek olan taleplerin koordinatör tarafından karşılanması ve işlem sürecinin 2PC protokolü eşliğinde distributed sistemde başlatılması için altyapı olarak kullanılması gerekmektedir. Bunun için de hem koordinatörün işlevini başlatacak hem de bu yapılanmayı altyapı olarak kullanacak olan endpoint’i koordinatör servisinde aşağıdaki gibi oluşturalım.var builder = WebApplication.CreateBuilder(args); . . . var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.MapGet("/create-order-transaction", async (ITransactionService transactionService) => { //Phase 1 - Prepare var transactionId = await transactionService.CreateTransaction(); await transactionService.PrepareServices(transactionId); bool transactionState = await transactionService.CheckReadyServices(transactionId); if (transactionState) { //Phase 2 - Commit await transactionService.Commit(transactionId); transactionState = await transactionService.CheckTransactionStateServices(transactionId); } if (!transactionState) await transactionService.Rollback(transactionId); }) .WithName("GetWeatherForecast") .WithOpenApi(); app.Run();
Yukarıdaki çalışmaya göz atarsanız eğer 15. satırda oluşturulan
/create-order-transaction
endpoint’inin içerisinde; 18. satırda oluşturduğumuzCreateTransaction
metodu ile transaction’ı başlatıyor ve elde ettiğimiz transaction id eşliğinde 19. satırdaPrepareServices
metodu ile tüm katılımcıların hazır olup olmadıklarına dair talepte bulunuyoruz. 20. satırda ise bu talep neticesinde servislerin hazırlık durumlarınıCheckReadyServices
fonksiyonuyla değerlendiriyoruz. Bu değerlendirme neticesinde transaction durumu geçerliyse ikinci aşamaya geçerekCommit
metoduyla tüm katılımcılara operasyon talimatını veriyoruz, yok eğer değilse ya da commit’ten sonra 26. satırdakiCheckTransactionStateServices
metodunda kontrol edildiği üzere tüm katılımcıların transaction durumları beklendiği gibi ‘done’ değilse her iki durum için transaction’ı 30. satırda olduğu gibi rollback ediyoruz. - Adım 12 (Test)
Ve artık yaptığımız bu çalışmayı test edebiliriz. Bunun için koordinatör eşliğinde tüm servisleri birlikte derleyip, ayağa kaldıralım. Ve ardından aşağıdaki gibi süreci adım adım takip edelim.- Transaction’ın Başlatılması (CreateTransaction)
CreateTransaction
metodu ile transaction başlatıldığında ilk etapta her bir katılımcıya karşın ‘NodeStates’ tablosuna kayıt atılmakta veIsReady
veTransactionState
kolonlarına ‘Pending’ değerine karşılık gelen ‘1’ değeri verilmektedir. - Katılımcıların Hazır Olup Olmadığına Dair Bildirimde Bulunulması (CheckReadyServices)
Transaction’ın işlenebilmesi için katılımcılara hazır olup olmadıklarına dair yapılan talep neticesinde, tüm katılımcılar hazır olduğu taktirde ‘NodeStates’ tablosununIsReady
kolonuna ‘Ready’ değerine karşılık ‘0’ değeri verilmektedir.Eğer ki katılımcılardan herhangi birinde problem, erişim hatası yahut cevap alınamamazlık olsaydı ilgili kolona ‘Unready’ değerindeki ‘2’ değeri verilecekti. - Tüm Katılımcılara Operasyon Talimatının Verilmesi (Commit)
Katılımcılar hazır olupCheckReadyServices
metodu ile transaction’ın state’i değerlendirildikten sonra 2. aşamadakiCommit
metodu ile tüm katılımcılara operasyonun başlatılması/işlenmesi/taahhüt edilmesi talimatı verilecektir. İşte bu süreçte katılımcıların işlemleri başarıyla tamamlanırsa ‘NodeStates’ tablosundakiTransactionState
kolonuna ‘Done’ değerine karşılık gelen ‘0’ değerleri verilmektedir. - Rollback Durumu
Eğer ki katılımcılardan en az biri hazır olmasa ya da işlem sürecinde herhangi bir problem meydana gelip başarıyla sonuçlanmasa ‘NodeStates’ tablosundakiIsReady
veTransactionState
kolonları sırasıyla ‘Unready’ ve ‘Abort’ değerlerine karşılık gelen ‘2’ değerini alacaktırlar. Misal olarak; ‘Order.API’ servisinde ‘ready’ endpoint’inden ‘false’ değerini dönecek şekilde ayarda bulunup, tekrar uygulamayı ayağa kaldırırsak eğer aşağıdaki gibi yapılan transaction’a ait tüm veriler için ilgili kolonlarda beklenen değerlerin işlendiğini gözlemliyor olacağız.
- Transaction’ın Başlatılması (CreateTransaction)
İşte bu kadar 🙂 Görüldüğü üzere 2PC protokolü bu şekilde, bu mantıkta uygulanmaktadır 🙂 Şimdi gelin bu tecrübe ettiğimiz protokolün avantajlarını ve dezavantajlarını değerlendirerek içeriğimize devam edelim.
2PC Protokolünün Avantaj ve Dezavantajları
2PC protokolü, görüldüğü üzere oldukça güçlü data consistency modeli sağlayan bir pattern’dır. İşlenecek distributed transaction’ın atomik olmasının garantisini sağlamakta ve tüm microservisler açısından kümülatif olarak ya başarıyla sonuç alınmasını ya da topyekün rollback ile işlemlerin geri alınmasını sağlamaktadır. Lakin bunu yaparken tüm servisler üzerinde bir senkronizasyon sağlayacağından dolayı sistemi kilitleme/engelleme durumu söz konusu olacaktır. İşte bu durumdan dolayı microservices mimarilerde data consistency süreçlerinde pek önerilen yaklaşım değildir diyebiliriz.
Ayrıca fark ettiyseniz 2PC protokolünde büyük ölçüde koordinatör tarafından gönderilen mesajlara bağımlılık söz konusudur. Yani net kriz yaratabilecek tek bir hata noktası mevcuttur ki bu noktada bir problem ya da sorun yaşanırsa sistemin kontrolden çıkması içten bile değildir.
2PC Protokolünün Tercih Edilebileceği Senaryolar
2PC protokolü aşağıdaki senaryolara uygun davranış sergileyebilir;
- Finansal İşlemler
Finansal uygulamalar; para transferleri, ödeme ve hesap işlemleri gibi hassas ve kritik durumlar barındırdığı için veri bütünlüğü oldukça önem arz eden senaryoları sağlamaktadırlar. İşte bu tarz finansal senaryolarda 2PC protokolü korunaklı işlev yapılmasını sağlayabilir. - Envanter Yönetimi
Büyük çaplı envanter yönetimi sistemlerinde; ürün eklemek, güncellemek veya silmek gibi işlemlerin tutarlı bir şekilde koordine edilmesi 2PC ile mümkün olabilir. - Sipariş İşleme Sistemleri
E-ticaret platformları ve sipariş işleme sistemleri, müşteri siparişlerini işlemek ve envanteri güncellemek için dağıtık bir mimariye sahip olabilmektedir. Bu tür bir mimaride sipariş işlemleri ve envanter güncellemeleri 2PC ile atomik bir şekilde çözümlenebilir. - Yedekleme ve Geri Yükleme
Veritabanı yedekleme ve geri yükleme işlemleri, veri bütünlüğünün korunması açısından kritik arz etmektedir. Dolayısıyla verilerin güvenli bir şekilde geri yüklenmesi için 2PC protokolünden istifade edilebilir. - Kurumsal Uygulamalar
Büyük ölçekli kurumsal uygulamalar, birden fazla microservice arasında işlem koordinasyonu gerektirebilir. 2PC bu tarz uygulamalarda da tercih edilebilir.
Nihai olarak;
Two-Phase Commit(2PC) pattern’ı nispeten basit bir prensibe sahip olsa da, performans ve maliyet açısından pek avantajlı diyemeyeceğimiz bir protokoldür. Transaction süreci boyunca sistemin kitlenmesi ve tüm işlemlerin bağlı olduğu koordinatörün olası hataları durumunda tüm sistemin krize girmesi 2PC protokolünün optimizasyonunu da zorlaştırdığı görülmektedir. Bizler bu içeriğimizde mevzu bahis pattern’ı tam teferruatlı bir şekilde artıları ve eksileri eşliğinde değerlendirmiş ve yazıya aktarmış bulunmaktayız. Artık ihtiyaçlar doğrultusunda tercih edip etmemek, sizlerin kritiğine, gereksinimlerine ve tercihlerine kalmıştır 🙂
İlgilenenlerin faydalanması dileğiyle…
Sonraki yazılarımda görüşmek üzere…
İyi çalışmalar…
Not : Örnek çalışmaya aşağıdaki github adresinden erişebilirsiniz.
https://github.com/gncyyldz/Two.Phase.Commit.Example