Modern yazılım mimarilerinde, ilişkisel (RDBMS) ve NoSQL veri tabanlarının bir arada kullanıldığı Polyglot Persistence yaklaşımları sıkça karşımıza çıkar. Ancak bu durum dağıtık sistemlerde veri bütünlüğünü yönetme noktasında ciddi teknik zorluklar doğurur.
Bir kurumsal mimaride, aynı iş mantığı çerçevesinde hem ACID uyumlu bir RDBMS (MSSQL gibi) hem de yüksek ölçeklenebilirlik odaklı bir NoSQL veri tabanına (MongoDB veya diğerleri gibi) eş zamanlı yazma ihtiyacı doğduğunda, sistemin atomik yapısını korumak kritik hale gelir. Uygulamanın tutarlı bir durumda kalması için her iki bağımsız veritabanına yapılan kayıtların ya tamamen işlenmesi ya da bir hata durumunda tamamen geri alınması (rollback) gerekir.
Örneğin SQL Server ACID garantileri sunduğu için merkezi ve tutarlı bir transaction altyapısına sahiptir. TransactionScope kullanılarak BEGIN TRANSACTION ile yönetilebilir ve yüksek tutarlılık sağlar. Oysa MongoDB veya Couchbase gibi veritabanları üzerinde System.Transactions entegrasyonu sağlamamıştır Bu durum C# tarafında her iki veritabanını tek bir transaction çatısı altında birleştirmeyi fiilen imkansız hale getirir.
Basit bir TransactionScope örneği gösterelim.

Temel sorunumuz şu, farklı mimarilere sahip SQL ve NoSQL veritabanları yerleşik olarak Distributed Transaction desteğine sahip değildir. .NET ekosisteminde sıklıkla başvurulan TransactionScope mekanizması, MSDTC (Microsoft Distributed Transaction Coordinator) gibi protokoller üzerinden iki aşamalı kilitleme (2PC) yöntemini kullanır.
Burada 2PC ve Saga Pattern’den ayrıca bahsetmiştim: https://recepserit.com/microservice-mimarisinde-transaction-yonetimi-acid-2pc-ve-saga-pattern
Modern NoSQL çözümlerinin çoğu performans ve yatay ölçekleme hedefleri doğrultusunda bu protokolleri desteklemez. Neden mi? Özellikle yüksek erişilebilirlik senaryolarında MongoDB gibi veritabanları kesin tutarlılık yerine sürekliliği tercih eder. NoSQL veritabanları genellikle BASE prensiplerini benimser. Bu sistemler kendi içlerinde (shards/replica sets) atomik işlemler sunsa da dış kaynaklarla System.Transactions üzerinden doğrudan entegre olabilecek bir altyapıya sahip değillerdir.
Aşağıdaki örnekte SQL Server (Entity Framework Core) ve bir NoSQL (Couchbase) veritabanı kullanılmıştır. SQL işlemi başarılı olsa bile NoSQL tarafında bir hata oluştuğunda SQL’in geri alınamadığı (veya tam tersi) anını inceleyelim.
using System;
using System.Transactions;
using Microsoft.EntityFrameworkCore;
using Couchbase;
public async Task CreateOrderAsync(Order order)
{
using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled))
{
try
{
// Bu işlem ACID garantisi altındadır.
_sqlContext.Orders.Add(order);
await _sqlContext.SaveChangesAsync();
var bucket = await _cluster.BucketAsync("order_logs");
var collection = bucket.DefaultCollection();
// NoSQL'in erişilemez olduğunu varsayalım:
throw new Exception("NoSQL connection problem");
await collection.InsertAsync(order.Id.ToString(), order);
scope.Complete();
}
catch (Exception ex)
{
// Rollback senaryosu...
Console.WriteLine($"Hata oluştu: {ex.Message}");
}
}
}Buradaki kritik sorun, “scope.Complete()” çağrılmadığı için SQL Server işlemi rollback edilir. Fakat NoSQL işlemi başarılı olup SQL hata verseydi, NoSQL tarafındaki işlem otomatik olarak geri alınamayacaktı. Çünkü NoSQL sağlayıcısı bu transaction scope’un bir parçası değildir. SQL Server bu örnekte TransactionScope ile gelen “Prepare” ve “Commit” sinyallerini anlar. Ancak NoSQL SDK’sı bu sinyalleri dinlemez. Siz scope.Dispose() ettiğinizde SQL Server değişikliği geri çekerken, NoSQL tarafında çoktan commit edilmiş veri olarak kalır. Bu da tutarsızlık oluşturur.
Sorunumuzun yeteri kadar anlaşıldığını düşünüyorum. Sonuç olarak C# tarafında hem SQL tabanlı bir repo hem de NoSQL bir repo tek bir transaction çatısı altında birleştirmek fiilen imkansızdır. Çalışacağını varsayarsanız ne olur? SQL’de “commit” çalışıp NoSQL sistemde hata alırsanız, SQL rollback yapamaz ve farklı bir sorun daha doğar.
Outbox Pattern
Outbox Pattern farklı veritabanları veya mikroservisler arasında veri tutarlılığını sağlamak için kullanılan en güvenilir tasarım kalıplarından biridir. Temel amacı “Dual-Write” problemini ortadan kaldırarak bir işlemin hem yerel veritabanında hem de harici bir sistemde (NoSQL, Message Broker, vb.) atomik olarak gerçekleşmesini garanti etmektir.
Bu pattern bize harici sisteme gönderilecek veriyi aynı veritabanı transaction içinde, aynı veritabanındaki özel bir tabloya (Outbox tablolarıdır) yazma prensibine dayanır.

Örneğin bir sipariş kayıt edeceğiz. Orders tablosuna bunu kayıt ederken aynı veritabanında “OutboxMessage” isimli farklı bir tabloya da bu veriyi ekletiyoruz. RDBMS’in ACID garantisi sayesinde bu ikisi ya beraber kaydedilir ya da hiç kaydedilmez. Sonra bir arka plan servisi “Outbox” tablosunu belirli aralıklarla tarar. Henüz işlenmemiş mesajları alır, diğer veritabanına bunu yazar ve işlem başarılı olduktan sonra mesajı “işlendi” olarak işaretler veya siler.
Şimdi bir sipariş örneğiyle 2 farklı tabloya aynı veriyi ekletelim.
public class OutboxMessage
{
public Guid Id { get; set; }
public string Type { get; set; }
public string Content { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? ProcessedAt { get; set; }
}
public async Task CreateOrderAsync(Order order)
{
using var transaction = await _sqlContext.Database.BeginTransactionAsync();
try
{
_sqlContext.Orders.Add(order);
var outboxEntry = new OutboxMessage
{
Id = Guid.NewGuid(),
Type = "OrderSync",
Content = JsonSerializer.Serialize(order),
CreatedAt = DateTime.UtcNow
};
_sqlContext.OutboxMessages.Add(outboxEntry);
await _sqlContext.SaveChangesAsync();
await transaction.CommitAsync();
}
catch (Exception)
{
await transaction.RollbackAsync();
throw;
}
}Şimdi bir arka planda çalışacak servis ile bu kuyruğu denetletelim.
public class OutboxProcessor : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var messages = await _sqlContext.OutboxMessages
.Where(m => m.ProcessedAt == null)
.ToListAsync();
foreach (var message in messages)
{
try
{
await _noSqlProvider.UpsertAsync(message.Id, message.Content);
message.ProcessedAt = DateTime.UtcNow;
await _sqlContext.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError("Try again...");
}
}
await Task.Delay(5000);
}
}
}Bu yapı sayesinde verinin hedef NoSQL veritabanına en az bir kez ulaşacağı garanti edilir. Veri bütünlüğü sağlar ve hata toleransını azaltır.
Bu modelde veriler NoSQL veritabanına nihai tutarlılık (eventually consistent) ilkesiyle ulaşır. Yani SQL’e yazıldığı milisaniyede NoSQL’de olmayabilir ancak saniyeler içinde oraya ulaşacağı garanti altındadır.
Event-Driven ve Outbox Pattern
Bu yapı özellikle microservice mimarilerinde altın standart olarak kabul edilir. Eğer projenizde veri kaybına tahammülü olmayan kritik bir sistemse, microservice portları arasında asenkron iletişim kuruyorsanız Outbox tasarımını Event-Driven ile de kurabilirsiniz.
public class OutboxPublisherWorker : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var pendingEvents = await _db.OutboxEvents
.Where(e => !e.IsProcessed)
.Take(20).ToListAsync();
foreach (var ev in pendingEvents)
{
// RabbitMQ'ya gönderiliyor
_messageBroker.Publish(ev.Type, ev.Payload);
ev.IsProcessed = true;
ev.ProcessedOn = DateTime.UtcNow;
}
await _db.SaveChangesAsync();
await Task.Delay(1000);
}
}
}
Sonuç
Event-driven ve Outbox pattern buradaki “Dual-Write” problemini çözmek için sektördeki en olgun cevaptır. SQL Server güvenilir bir kayıt defteriniz olurken, NoSQL veritabanınız bu güvenilir kaynaktan asenkron olarak beslenecektir.
SQL ve NoSQL veritabanları arasında yerleşik destek eksikliği nedeniyle tek distributed transaction gerçekleştirmek zor olsa da pratik çözümleri uygulayabiliriz. Hangisini tercih etmeniz gerektiğini projenizin ihtiyaçları belirleyecektir. Doğru yaklaşımın seçilmesi uygulamanızın geçici tutarsızlıklara olan toleransına ve yüksek high availability ihtiyacına bağlıdır.



Yorum bırakın