Distributed Transaction ve Two-Phase Commit: Neden Microservice'lerde Kullanılmaz?
Two-phase commit (2PC), birden fazla participant'ın bir transaction'ı birlikte commit ya da abort etmesini sağlar, ama coordinator karar vermeden çökerse YES oyu veren participant'lar kilitleri tutarak bekler. System.Transactions'ın 2PC'si volatile participant'larla çalışıyor; ikinci durable participant ise macOS ve Linux'ta PlatformNotSupportedException veriyor. Microservice'lerde availability, coupling ve broker desteği yüzünden yerine saga ve outbox kullanılır.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un checkout akışı iki şeyi birlikte yapmalı: Inventory veritabanında kitabın stoğunu düşmek ve Payment veritabanında müşterinin cüzdanından para çekmek. Biri olup diğeri olmazsa ya parası alınmış ama kitabı ayrılmamış bir müşteri ya da bedava kitap olur. Klasik çözüm two-phase commit (2PC): bir coordinator iki veritabanına "commit'e hazır mısın?" diye sorar, ikisi de "evet" derse "commit" der.
İki veritabanı da "evet" diyor ve stok satırıyla cüzdan satırını kilitliyor. Tam o anda coordinator'ın çalıştığı makinenin elektriği gidiyor. Inventory ve Payment şimdi ne yapmalı? Commit etseler, ya coordinator abort kararı vermişse? Abort etseler, ya diğeri commit etmişse? Kilitler ne zaman açılır?
Atomic Commit Problemi
Tek bir veritabanında atomicity'yi transaction log sağlar: commit kaydı diske yazıldıysa transaction olmuştur, yazılmadıysa olmamıştır. Birden fazla node'a yayılan bir transaction'da bu yetmez; her node kendi kararını verirse biri commit, diğeri abort edebilir. Atomic commit problemi, bütün participant'ların aynı sonuca (hepsi commit ya da hepsi abort) varmasını ister.
Bu problem bir önceki yazıdaki consensus'a benzer ama ondan farklıdır. Consensus'ta çoğunluğun kararı yeterlidir ve önerilen herhangi bir değer seçilebilir. Atomic commit'te ise her participant'ın veto hakkı vardır: bir tanesi bile "hayır" derse (constraint ihlali, disk dolu, deadlock) sonuç abort olmalıdır. Ve commit, ancak herkes "evet" dediyse mümkündür. Bu yüzden atomic commit, çoğunlukla ilerleyebilen consensus'tan daha kırılgandır: tek bir participant'ın ya da coordinator'ın erişilemez olması bütün transaction'ı durdurabilir.
2PC'nin İki Fazı
Jim Gray'in 1978 tarihli "Notes on Data Base Operating Systems" notlarında tarif ettiği protokol iki fazdan oluşur. Birinci fazda coordinator her participant'a prepare gönderir. Participant transaction'ı commit edilebilecek noktaya getirir: değişiklikleri ve kilitleri kalıcı olarak kaydeder, constraint'leri kontrol eder, sonra YES ya da NO oyu verir. YES oyu bir sözdür: "commit dersen, çöküp yeniden başlasam bile commit edeceğim". Bu sözden sonra participant tek başına abort edemez.

Bütün oylar YES ise coordinator kararını (COMMIT) önce kendi log'una yazar; bu kayıt commit noktasıdır. Sonra ikinci fazda kararı bütün participant'lara gönderir ve onay bekler. Tek bir NO ya da timeout varsa karar ABORT olur. Participant'lar kararı aldıkça kilitlerini açar. Kararı alamayan bir participant'a coordinator, çökse bile yeniden başladığında log'undan okuyup tekrar tekrar gönderir.
.NET'te 2PC: System.Transactions
TransactionScope'u çoğumuz tek bir veritabanı bağlantısıyla kullanırız, ama altındaki System.Transactions gerçek bir 2PC coordinator'ıdır. Bir kaynağın transaction'a katılması için IEnlistmentNotification'ı implemente etmesi yeterli: Prepare birinci faz, Commit ya da Rollback ikinci faz. Her çağrıyı ekrana yazan bir participant:
using System.Transactions;
namespace Bookstore.TwoPhase;
// System.Transactions'ın yürüttüğü 2PC'de oy veren bir participant
public sealed class LoggingResource(string name, bool voteYes = true) : IEnlistmentNotification
{
public void Prepare(PreparingEnlistment preparingEnlistment)
{
Console.WriteLine($" {name}: prepare -> {(voteYes ? "YES" : "NO")}");
if (voteYes) preparingEnlistment.Prepared();
else preparingEnlistment.ForceRollback();
}
public void Commit(Enlistment enlistment)
{
Console.WriteLine($" {name}: commit");
enlistment.Done();
}
public void Rollback(Enlistment enlistment)
{
Console.WriteLine($" {name}: rollback");
enlistment.Done();
}
public void InDoubt(Enlistment enlistment)
{
Console.WriteLine($" {name}: in doubt");
enlistment.Done();
}
}
İki volatile participant (bellekte durumu tutan, çökünce kurtarılması gerekmeyen kaynaklar) ile iki checkout, ardından iki durable participant (çökünce kurtarılması gereken, gerçek bir veritabanı gibi kaynaklar) ile bir deneme, sonra kendi yazdığımız küçük bir coordinator:
using System.Transactions;
using Bookstore.TwoPhase;
static void Checkout(bool paymentVotesYes)
{
try
{
using TransactionScope scope = new();
Transaction.Current!.EnlistVolatile(new LoggingResource("Inventory"), EnlistmentOptions.None);
Transaction.Current!.EnlistVolatile(new LoggingResource("Payment", paymentVotesYes), EnlistmentOptions.None);
Console.WriteLine(" scope.Complete()");
scope.Complete();
} // Dispose faz 1'i ve faz 2'yi çalıştırır
catch (TransactionAbortedException ex)
{
Console.WriteLine($" {ex.GetType().Name}: {ex.Message}");
}
}
Console.WriteLine("--- both vote yes ---");
Checkout(paymentVotesYes: true);
Console.WriteLine("--- payment votes no ---");
Checkout(paymentVotesYes: false);
Console.WriteLine("--- two durable resource managers ---");
try
{
using TransactionScope scope = new();
Transaction.Current!.EnlistDurable(Guid.NewGuid(), new LoggingResource("InventoryDb"), EnlistmentOptions.None);
Transaction.Current!.EnlistDurable(Guid.NewGuid(), new LoggingResource("PaymentDb"), EnlistmentOptions.None);
scope.Complete();
}
catch (Exception ex)
{
Console.WriteLine($" {ex.GetType().Name}: {ex.Message}");
}
Console.WriteLine("--- hand-written 2PC, coordinator crash ---");
Participant inventory = new("Inventory", "row lock on Stock(978-1449373320)");
Participant payment = new("Payment", "row lock on Wallet(customer-7)");
new Coordinator([inventory, payment]).Run(crashBeforeDecision: true);
Console.WriteLine($" {inventory.TryTerminate([payment])}");
Console.WriteLine($" {payment.TryTerminate([inventory])}");
--- both vote yes ---
scope.Complete()
Inventory: prepare -> YES
Payment: prepare -> YES
Inventory: commit
Payment: commit
--- payment votes no ---
scope.Complete()
Inventory: prepare -> YES
Payment: prepare -> NO
Inventory: rollback
TransactionAbortedException: The transaction has aborted.
--- two durable resource managers ---
PlatformNotSupportedException: This platform does not support distributed transactions.
İlk iki senaryo protokolü olduğu gibi gösteriyor. Complete yalnız "commit etmek istiyorum" der; asıl iş Dispose'da başlar: önce iki prepare, sonra iki commit. Payment NO dediğinde Inventory'ye rollback gidiyor ve TransactionAbortedException fırlatılıyor; NO diyen Payment'a ayrıca rollback gönderilmiyor, çünkü oyu zaten abort demekti.
Üçüncü senaryo platform kısıtını gösteriyor. Volatile participant'lar ya da tek bir durable participant olduğu sürece işi .NET'in kendi içindeki lightweight transaction manager görür. İkinci bir durable participant katıldığı anda transaction distributed transaction'a yükseltilmek (escalation) zorundadır ve bunun için işletim sisteminin transaction coordinator'ı gerekir. .NET 7'den beri bu yalnız Windows'ta, MSDTC (Microsoft Distributed Transaction Coordinator) ile ve TransactionManager.ImplicitDistributedTransactions = true ayarlanarak destekleniyor; macOS ve Linux'ta yukarıdaki PlatformNotSupportedException geliyor. Gerçek hayatta bu, aynı TransactionScope içinde iki farklı veritabanına iki SqlConnection açtığında olur. Microservices serisinin "Microservice'lerde Transaction Yok: Eventual Consistency'yi Tasarımla Kabullenmek" yazısında aynı hatayı iki gerçek SQL (Structured Query Language) Server veritabanıyla görmüştük.
Coordinator Çöktüğünde: Blocking
Açılıştaki soruya dönelim. Kendi yazdığımız küçük coordinator, bütün YES oylarını topladıktan sonra, kararı yazmadan ve göndermeden çöküyor. Participant'lar bir süre sonra timeout'a düşüyor ve ne yapacaklarını diğer participant'lara soruyor:
namespace Bookstore.TwoPhase;
public enum State { Working, Prepared, Committed, Aborted }
public sealed class Participant(string name, string lockedRow)
{
public string Name { get; } = name;
public State State { get; private set; } = State.Working;
public string? HeldLock { get; private set; } = lockedRow;
// YES oyu veren participant, istenirse commit edeceğine söz vermiştir: tek başına abort edemez
public bool Prepare() { State = State.Prepared; return true; }
public void Commit() { State = State.Committed; HeldLock = null; }
public void Abort() { State = State.Aborted; HeldLock = null; }
// Prepared durumda timeout: kararın ne olduğunu diğer participant'lara sor
public string TryTerminate(IEnumerable<Participant> peers)
{
if (State != State.Prepared) return $"{Name}: already {State}";
if (peers.Any(p => p.State == State.Committed)) { Commit(); return $"{Name}: learned COMMIT from a peer"; }
if (peers.Any(p => p.State is State.Aborted or State.Working)) { Abort(); return $"{Name}: safe to ABORT"; }
return $"{Name}: BLOCKED, still holding {HeldLock}";
}
}
public sealed class Coordinator(IReadOnlyList<Participant> participants)
{
public void Run(bool crashBeforeDecision)
{
bool allYes = participants.All(p => p.Prepare());
Console.WriteLine($" phase 1: votes = {(allYes ? "all YES" : "some NO")}");
if (crashBeforeDecision)
{
Console.WriteLine(" coordinator crashes before writing/sending the decision");
return;
}
foreach (Participant p in participants)
{
if (allYes) p.Commit(); else p.Abort();
}
Console.WriteLine($" phase 2: {(allYes ? "COMMIT" : "ABORT")} sent");
}
}
--- hand-written 2PC, coordinator crash ---
phase 1: votes = all YES
coordinator crashes before writing/sending the decision
Inventory: BLOCKED, still holding row lock on Stock(978-1449373320)
Payment: BLOCKED, still holding row lock on Wallet(customer-7)
İki participant da kilitli kaldı. Sebep, YES oyunun anlamında: Inventory, Payment'ın da YES dediğini biliyor, ama coordinator'ın COMMIT'i log'una yazıp yazmadığını bilmiyor. Coordinator commit noktasını geçtikten sonra çöktüyse (karar log'da ama mesaj gitmedi) abort etmek atomicity'yi bozar; geçmeden çöktüyse commit etmek, coordinator'ın yeniden başlayınca vereceği abort kararıyla çelişebilir. Doğru tek cevap beklemek. Participant'lardan biri NO demiş ya da kararı almış olsaydı diğeri ondan öğrenebilirdi (cooperative termination); hepsi YES deyip beklediğinde bu da işe yaramaz.
Bu bekleme sırasında kilitler açılmaz. Stok satırına dokunmak isteyen her sorgu bekler; coordinator saatlerce dönmezse sistemin o kısmı saatlerce durur. SQL Server bu durumdaki transaction'ları "in-doubt" olarak işaretler ve gerekirse bir yöneticinin MSDTC üzerinden elle commit ya da abort etmesini bekler. Elle verilen karar coordinator'ın gerçek kararıyla çelişirse atomicity bozulur. 2PC'nin bu özelliğine blocking denir ve protokolün en büyük zayıflığıdır.
2PC ile Consensus Farkı ve Fault-Tolerant 2PC
Sorunun kökü, kararın tek bir node'da, coordinator'da yaşaması. Çözüm de bir önceki yazıdan geliyor: kararı consensus ile replike etmek. Jim Gray ve Leslie Lamport'un 2006 tarihli "Consensus on Transaction Commit" makalesindeki Paxos Commit, her participant'ın oyunu Paxos ile birden fazla acceptor'a kaydeder; coordinator'lardan biri çökse bile çoğunluk kararı bilir. Google Spanner aynı fikri uygular: her participant zaten bir Paxos grubudur, 2PC bu grupların leader'ları arasında çalışır ve her grubun durumu Paxos ile replike edildiği için tek bir makinenin çökmesi transaction'ı bloke etmez.
Dale Skeen'in 1981'de önerdiği three-phase commit (3PC) blocking'i araya bir faz ekleyerek çözmeye çalışır, ama bunu ancak mesaj gecikmesinin sınırlı olduğu synchronous bir network varsayımıyla yapabilir. İkinci yazıda gördüğümüz gibi gerçek network'ler bu varsayımı tutmaz, bu yüzden 3PC pratikte yaygınlaşmadı.
XA ve Heterojen Distributed Transaction'lar
Farklı ürünlerin aynı 2PC'ye katılabilmesi için X/Open grubu 1991'de XA (eXtended Architecture) standardını yayımladı: transaction manager ile resource manager'lar arasındaki interface (prepare, commit, rollback, recover). Java'daki JTA (Jakarta Transactions), MySQL'in XA START komutları ve PostgreSQL'in PREPARE TRANSACTION komutu bu ailedendir. Windows tarafında MSDTC hem kendi protokolünü hem XA'yı konuşabilir.
Kâğıt üzerinde güzel, pratikte sorunlu. Transaction manager bir single point of failure'dır ve kendisi genelde replike edilmez. Bütün participant'lar en zayıf halkanın hızında çalışır; birinin prepare'i uzarsa herkes kilit tutar. In-doubt transaction'ların kurtarılması operasyonel bilgi ister. Ve en önemlisi, modern altyapının önemli bir kısmı XA desteklemez: RabbitMQ ve Kafka XA participant'ı olamaz; Kafka'nın kendi transaction'ları yalnız Kafka'nın içini kapsar.
Microservice'lerde Neden Kullanılmaz?
Microservice mimarisinde her service kendi verisinin sahibidir (database per service). 2PC'yi service'ler arasına koymak bu sahipliği deler: Order service'in transaction'ı Payment service'in veritabanında kilit tutar, bir service'in deployment'ı ya da yavaşlığı diğerinin transaction'larını bekletir. Availability de çarpılarak düşer: her biri yüzde 99,9 erişilebilir üç participant'ın hepsinin aynı anda erişilebilir olma olasılığı yaklaşık yüzde 99,7'dir ve coordinator'ı da eklediğinde bu daha da azalır. Üstüne platform kısıtı geliyor: .NET'te Linux container'larında distributed transaction yok, broker'lar XA konuşmuyor.
Bu yüzden microservice'lerde atomicity yerine iki şey tasarlanır. Bir service'in kendi veritabanı yazması ile yayımladığı event'in tutarlılığı için transactional outbox: event, iş verisiyle aynı yerel transaction'da bir tabloya yazılır ve ayrı bir süreç onu broker'a taşır. Birden fazla service'e yayılan akış için saga: her adım kendi yerel transaction'ıdır ve başarısız bir adım, önceki adımları compensating action'larla geri alır. Saga, 2PC'nin aksine isolation vermez: ara durumlar başkalarına görünür ve bunun tasarımı domain'in işidir. Ayrıntılar Messaging ve Event-Driven Architecture serisinin "Transactional Outbox ve Inbox: Dual Write Problemini Anlamak ve Doğru Tasarlamak" ve "Saga Pattern: Choreography mi Orchestration mı?" yazılarında.
Sık Düşülen Tuzaklar
TransactionScope'un içinde iki farklı bağlantı açmak. Aynı connection string ile bile, ilk bağlantı kapanmadan ikincisi açılırsa transaction escalate edebilir. Linux'ta bu bir exception, Windows'ta ise beklenmedik bir MSDTC bağımlılığı demektir.
Uzaktaki bir HTTP (Hypertext Transfer Protocol) çağrısını transaction'ın içinde yapmak. TransactionScope HTTP çağrısını kapsamaz; ödeme sağlayıcısı para çeker, sonra yerel transaction rollback olur ve ikisi tutarsız kalır. Yan etkiyi outbox ile transaction'dan sonraya taşı.
Prepared transaction'ları unutmak. PostgreSQL'de PREPARE TRANSACTION ile hazırlanıp hiç commit ya da rollback edilmeyen bir transaction, kilitlerini ve vacuum'u süresiz bloke eder. 2PC kullanıyorsan in-doubt transaction'ları izleyen bir alarm şart.
2PC'yi "exactly-once" sanmak. 2PC bir transaction'ın atomik olmasını sağlar; client'ın timeout alıp aynı transaction'ı yeniden başlatmasını engellemez. İlk yazıdaki idempotency ihtiyacı burada da geçerli.
Saga'yı 2PC'nin bedava hali sanmak. Saga blocking'den kurtarır ama isolation'ı da alır götürür. Compensating action'ları olmayan ya da geri alınamayan adımları sona koymayan bir saga, yarım kalmış işlemler üretir.
Küçük Bir Deneme
★☆☆ Checkout'ta scope.Complete() satırını kaldır. Participant'lara hangi çağrılar gidiyor? Prepare çağrılıyor mu?
★★☆ Coordinator.Run'a kararı bir dosyaya yazan bir adım ekle ve coordinator'ın bu adımdan sonra çöktüğü senaryoyu kur. Yeniden başlayan bir coordinator, dosyayı okuyarak participant'ları nasıl kurtarır? Dosya yazılmadan çöktüyse doğru karar ne olmalı (presumed abort)?
★★★ Paxos Commit fikrini sadeleştirerek simüle et: coordinator'ın kararını üç ayrı "karar node'una" yazdığını, çoğunluğa yazılınca kararın kesinleştiğini varsay. Bir karar node'u ve coordinator aynı anda çöktüğünde participant'lar kararı öğrenebiliyor mu? Blocking ne zaman yeniden ortaya çıkar?
2PC'de participant'lar kilit tutarak bekliyordu. Bir sonraki yazıda kilitlerin dağıtık dünyadaki haline bakıyoruz: distributed lock'lar, lease'ler ve kilidi elinde tuttuğunu sanan bir process'in GC (garbage collector) duraklaması yüzünden nasıl başkasının verisini ezdiği. Çözümün adı fencing token.