Dağıtık Sistemler & Mesajlaşma Mimarisi 8 dk okuma 2 okunma

Idempotency ve Exactly-Once Semantiği

Dağıtık sistemlerde "tam olarak bir kez" (exactly-once) teslimat garantisi pratikte imkansızdır; bu nedenle "en az bir kez" (at-least-once) teslimat + idempotent işleme ile "etkili olarak tam olarak bir kez" (effectively-once) semantiği sağlanır. Bu yazıda, idempotency kavramı, idempotency key tasarımı, mesajlaşma sistemlerinde (Kafka, RabbitMQ, Azure Service Bus) exactly-once desteği, deduplication stratejileri ve .NET'te uygulama yöntemleri ele alınır.

Idempotency ve Exactly-Once Semantiği

Idempotency ve Exactly-Once Semantiği

Dağıtık sistemlerde en çok yanlış anlaşılan kavramlardan biri "exactly-once" (tam olarak bir kez) teslimat garantisidir. Gerçek şu ki, ağ hataları, düğüm çökmeleri ve zaman aşımları nedeniyle tam olarak bir kez teslimat pratikte imkansızdır. Bunun yerine, sistemler "at-least-once" (en az bir kez) teslimat yapar ve idempotency (yinelenebilirlik) ile "etkili olarak tam olarak bir kez" (effectively-once) semantiği sağlar. Bu yazıda, bu kavramları, aralarındaki ilişkiyi ve .NET'te nasıl uygulanacağını detaylıca ele alacağız.


1. Teslimat Garantileri: Üç Semantik

Mesajlaşma sistemlerinde üç temel teslimat garantisi vardır:

Garanti Açıklama Risk Kullanım Alanı
At-Most-Once Mesaj en fazla bir kez teslim edilir. Mesaj kaybolabilir. Log toplama, metrikler (kayıp tolere edilebilir).
At-Least-Once Mesaj en az bir kez teslim edilir. Mesaj tekrarlanabilir. Çoğu iş senaryosu (idempotency ile).
Exactly-Once Mesaj tam olarak bir kez teslim edilir. Teorik olarak ideal, pratikte imkansız. Finansal işlemler (idempotency ile yaklaşılır).

Gerçek: "Exactly-once" teslimat, dağıtık sistemlerde imkansızdır. Çünkü:

  • Bir mesaj gönderildikten sonra, onay (ack) kaybolabilir. Gönderen tekrar dener → çift teslimat.

  • Bir mesaj işlendikten sonra, işlemci çökebilir. Mesaj yeniden teslim edilir → çift işleme.

  • Ağ bölünmesi (network partition) durumunda, iki taraf da mesajın teslim edilmediğini düşünebilir.

Çözüm: At-least-once teslimat + idempotent işleme = "Effectively-Once" (Etkili Olarak Bir Kez).


2. Idempotency Nedir?

Idempotency (Yinelenebilirlik), bir işlemin aynı girdiyle birden fazla kez çağrıldığında, sistem durumunda istenmeyen bir değişiklik yaratmamasıdır. Yani, aynı işlemi 1 kez veya 10 kez yapmak, sistem üzerinde aynı etkiyi bırakır.

Idempotent İşlemler:

  • SET balance = 100 → Her çağrıda bakiye 100 olur. ✅

  • DELETE FROM orders WHERE id = 5 → İlk silmeden sonra hiçbir şey değişmez. ✅

  • INSERT INTO orders (id, ...) VALUES (5, ...) → İkinci çağrıda unique constraint hatası verir (veya yok sayılır). ⚠️

Non-Idempotent İşlemler:

  • UPDATE balance = balance + 100 → Her çağrıda bakiye 100 artar. ❌

  • INSERT INTO logs (...) VALUES (...) → Her çağrıda yeni kayıt eklenir. ❌

  • POST /api/orders → Her çağrıda yeni sipariş oluşturur. ❌

Idempotency'nin Önemi:

  • Retry mekanizmaları güvenli hale gelir.

  • Mesaj tekrarları zararsız olur.

  • Kullanıcı deneyimi iyileşir (çift tıklama, ağ hatası sonrası yeniden deneme).


3. Idempotency Key Tasarımı

Idempotency Key, bir işlemi benzersiz olarak tanımlayan ve tekrarlarını tespit etmek için kullanılan bir anahtardır.

Temel Akış:

text

1. İstemci, her yeni işlem için benzersiz bir anahtar üretir (UUID v4).
2. Anahtar, istek ile birlikte gönderilir (genellikle Idempotency-Key başlığında).
3. Sunucu, anahtarı bir depoda (Redis, veritabanı) arar.
4. Anahtar yoksa: İşlemi yap, sonucu anahtarla birlikte sakla.
5. Anahtar varsa: Saklanan sonucu döndür, işlemi tekrarlama.

Örnek (Stripe Tarzı):

http

POST /api/payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{
  "amount": 1000,
  "currency": "TRY",
  "customerId": "cust_123"
}

Kritik Tasarım Kararları:

Karar Seçenek Öneri
Anahtar Üretimi İstemci / Sunucu İstemci üretmeli (UUID v4)
Anahtar Kapsamı Global / Kullanıcı bazlı / Endpoint bazlı (kullanıcı, endpoint, anahtar) bazlı
TTL (Yaşam Süresi) 1 saat / 24 saat / 7 gün İşlemin doğasına göre 24-72 saat
Depolama Redis / Veritabanı / Her ikisi Redis (hızlı) + Veritabanı (kalıcı)
Çakışma Yönetimi First-Wins / Last-Wins First-Wins (ilk istek kazanır)

4. Exactly-Once Semantiği: Mesajlaşma Sistemlerinde

A. Kafka'da Exactly-Once

Kafka, idempotent producer ve transactional messaging ile exactly-once semantiğini destekler.

Idempotent Producer:

csharp

var config = new ProducerConfig
{
    BootstrapServers = "localhost:9092",
    EnableIdempotence = true,          // Idempotent producer aktif
    Acks = Acks.All,                   // Tüm replikalar onaylasın
    MaxInFlight = 5,                   // Idempotency için maksimum 5
    MessageSendMaxRetries = int.MaxValue
};

using var producer = new ProducerBuilder<string, string>(config).Build();

Kafka, her mesaja bir sequence number atar ve producer ID ile birlikte saklar. Aynı sequence number ile gelen mesaj tekrar işlenmez.

Transactional Messaging:

csharp

producer.InitTransactions(TimeSpan.FromSeconds(60));
producer.BeginTransaction();

try
{
    await producer.ProduceAsync("topic1", new Message<string, string> { Key = "k1", Value = "v1" });
    await producer.ProduceAsync("topic2", new Message<string, string> { Key = "k2", Value = "v2" });
    
    producer.CommitTransaction();
}
catch
{
    producer.AbortTransaction();
}

Kafka'nın Sınırı: Exactly-once, yalnızca Kafka içinde (Kafka → Kafka) geçerlidir. Dış sistemlere (veritabanı, HTTP API) yazma işlemleri için hala idempotency gerekir.

B. RabbitMQ'da Exactly-Once

RabbitMQ, native exactly-once desteği sunmaz. At-least-once teslimat yapar. Bu nedenle, consumer tarafında idempotency uygulanmalıdır.

RabbitMQ'da Idempotent Consumer:

csharp

public class OrderConsumer : IConsumer<OrderCreated>
{
    private readonly AppDbContext _context;
    private readonly IDistributedCache _cache;

    public async Task Consume(ConsumeContext<OrderCreated> context)
    {
        var messageId = context.MessageId.ToString();
        var cacheKey = $"processed:{messageId}";

        // 1. Daha önce işlenmiş mi?
        var cached = await _cache.GetStringAsync(cacheKey);
        if (cached != null)
        {
            _logger.LogInformation("Mesaj zaten işlenmiş: {MessageId}", messageId);
            return;
        }

        // 2. İşlemi yap (aynı transaction içinde)
        using var transaction = await _context.Database.BeginTransactionAsync();
        try
        {
            var order = new Order { Id = context.Message.OrderId, ... };
            _context.Orders.Add(order);

            // 3. İşlendi olarak işaretle
            _context.ProcessedMessages.Add(new ProcessedMessage
            {
                MessageId = messageId,
                ProcessedAt = DateTime.UtcNow
            });

            await _context.SaveChangesAsync();
            await transaction.CommitAsync();

            // 4. Cache'e ekle (TTL ile)
            await _cache.SetStringAsync(cacheKey, "1", 
                new DistributedCacheEntryOptions 
                { 
                    AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24) 
                });
        }
        catch
        {
            await transaction.RollbackAsync();
            throw;
        }
    }
}

C. Azure Service Bus'ta Exactly-Once

Azure Service Bus, duplicate detection özelliği sunar. Bir mesaj MessageId ile gönderilir ve belirli bir süre (duplicate detection window) içinde aynı MessageId ile gelen mesajlar otomatik olarak reddedilir.

csharp

var client = new ServiceBusClient(connectionString);
var sender = client.CreateSender("orders", new ServiceBusSenderOptions
{
    // Duplicate detection penceresi
});

var message = new ServiceBusMessage(BinaryData.FromString(json))
{
    MessageId = Guid.NewGuid().ToString(),  // Benzersiz mesaj ID
    SessionId = orderId.ToString()          // FIFO garantisi için
};

await sender.SendMessageAsync(message);

Duplicate Detection Window: 10 dakika ile 7 gün arasında ayarlanabilir. Bu pencere içinde aynı MessageId ile gelen mesajlar reddedilir.


5. Deduplication Stratejileri

A. Veritabanı Tabanlı (En Güvenilir)

Her mesajın ID'sini bir tabloda saklayın ve aynı transaction içinde işleyin.

csharp

public async Task<bool> IsAlreadyProcessedAsync(string messageId)
{
    return await _context.ProcessedMessages
        .AnyAsync(pm => pm.MessageId == messageId);
}

public async Task MarkAsProcessedAsync(string messageId)
{
    _context.ProcessedMessages.Add(new ProcessedMessage
    {
        MessageId = messageId,
        ProcessedAt = DateTime.UtcNow
    });
    await _context.SaveChangesAsync();
}

Artıları: ACID garantisi, kalıcı.
Eksileri: Her mesaj için ek sorgu, veritabanı yükü.

B. Redis Tabanlı (Hızlı)

İşlenen mesaj ID'lerini Redis'te saklayın.

csharp

public async Task<bool> IsAlreadyProcessedAsync(string messageId)
{
    return await _redis.KeyExistsAsync($"processed:{messageId}");
}

public async Task MarkAsProcessedAsync(string messageId)
{
    await _redis.StringSetAsync($"processed:{messageId}", "1", TimeSpan.FromHours(24));
}

Artıları: Çok hızlı, dağıtık.
Eksileri: Kalıcı değil (Redis çökerse kaybolur), TTL yönetimi gerekir.

C. Hibrit Yaklaşım (En İyi Pratik)

  • Hızlı Kontrol: Redis (ilk kontrol)

  • Kalıcı Kayıt: Veritabanı (asıl kayıt)

csharp

public async Task<bool> IsAlreadyProcessedAsync(string messageId)
{
    // 1. Hızlı kontrol: Redis
    if (await _redis.KeyExistsAsync($"processed:{messageId}"))
        return true;

    // 2. Kalıcı kontrol: Veritabanı
    return await _context.ProcessedMessages
        .AnyAsync(pm => pm.MessageId == messageId);
}

D. Doğal Idempotency (En Zarif)

İşlemi doğası gereği idempotent yapın. Örneğin:

  • INSERT ... ON CONFLICT DO NOTHING (PostgreSQL)

  • MERGE (SQL Server)

  • UPSERT (MongoDB)

sql

-- PostgreSQL: Aynı ID ile gelen kayıt varsa hiçbir şey yapma
INSERT INTO orders (id, customer_id, total, created_at)
VALUES (@Id, @CustomerId, @Total, @CreatedAt)
ON CONFLICT (id) DO NOTHING;

6. .NET'te Idempotent Consumer Middleware

MassTransit, idempotency için bir middleware sunar.

csharp

// Program.cs
builder.Services.AddMassTransit(x =>
{
    x.AddConsumer<OrderConsumer>();
    
    x.UsingRabbitMq((context, cfg) =>
    {
        cfg.Host("localhost", "/", h => { ... });

        cfg.ReceiveEndpoint("order-queue", e =>
        {
            // Idempotency middleware
            e.UseMessageRetry(r => r.Exponential(5, 
                TimeSpan.FromSeconds(1), 
                TimeSpan.FromSeconds(30), 
                TimeSpan.FromSeconds(2)));
            
            e.UseEntityFrameworkOutbox<AppDbContext>(context);
            
            e.ConfigureConsumer<OrderConsumer>(context);
        });
    });
});

Outbox Pattern ile Exactly-Once:

Outbox Pattern, veritabanı işlemi ile mesaj göndermeyi atomik hale getirir. Bu, "etkili olarak tam olarak bir kez" semantiğinin en güvenilir yoludur.

csharp

public async Task CreateOrderAsync(CreateOrderCommand command)
{
    using var transaction = await _context.Database.BeginTransactionAsync();
    try
    {
        // 1. İş verisini kaydet
        var order = new Order { ... };
        _context.Orders.Add(order);

        // 2. Outbox'a mesajı kaydet (aynı transaction)
        var outboxMessage = new OutboxMessage
        {
            Id = Guid.NewGuid(),
            Type = "OrderCreated",
            Content = JsonSerializer.Serialize(new OrderCreatedEvent(order.Id)),
            OccurredOnUtc = DateTime.UtcNow
        };
        _context.OutboxMessages.Add(outboxMessage);

        // 3. Tek transaction'da kaydet
        await _context.SaveChangesAsync();
        await transaction.CommitAsync();
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}

Arka planda çalışan bir Outbox Processor, Outbox tablosundaki mesajları okuyup Kafka/RabbitMQ'ya gönderir ve işlendi olarak işaretler.


7. Exactly-Once vs. Effectively-Once

Kavram Açıklama Pratikte
Exactly-Once Mesaj tam olarak bir kez teslim edilir ve işlenir. Teorik olarak imkansız.
Effectively-Once Mesaj birden fazla kez teslim edilebilir, ancak işleme etkisi tam olarak bir kez olur. At-least-once + Idempotency ile sağlanır.

Effectively-Once'ın Sağladıkları:

  • Mesaj kaybolmaz (at-least-once).

  • Mesaj tekrarı zararsızdır (idempotency).

  • Sonuç: İşlem, tam olarak bir kez yapılmış gibi görünür.


8. En İyi Pratikler

Pratik Açıklama
Idempotency Key Kullanın Her işlem için benzersiz bir anahtar üretin.
Anahtar Kapsamını Doğru Belirleyin (kullanıcı, endpoint, anahtar) bazlı benzersizlik sağlayın.
TTL Belirleyin İşlenen mesaj kayıtlarını sonsuza kadar saklamayın (24-72 saat).
Aynı Transaction'da Kaydedin İş verisi + processed message kaydı aynı transaction'da olmalı.
Hibrit Depolama Kullanın Redis (hızlı) + Veritabanı (kalıcı).
Doğal Idempotency'yi Tercih Edin UPSERT, MERGE, ON CONFLICT DO NOTHING kullanın.
Outbox Pattern Kullanın Veritabanı + mesaj gönderimini atomik hale getirin.
MessageId'yi Kullanın Mesajlaşma sistemlerinin sağladığı benzersiz ID'yi kullanın.
Idempotency'yi Test Edin Aynı mesajı birden fazla kez göndererek test edin.
Monitoring Kurun Tekrar işlenen mesajları izleyin; yüksekse sorunu araştırın.

9. Gerçek Dünya Senaryosu

Senaryo: Bir ödeme sistemi, banka API'sine ödeme isteği gönderiyor. Ağ hatası nedeniyle istek başarısız oluyor ve sistem retry yapıyor. Ancak ilk istek aslında bankaya ulaşmış ve işlenmiş olabilir.

Problem: Retry, çift ödeme yapılmasına yol açabilir.

Çözüm: Idempotency Key + Idempotent İşleme.

csharp

public class PaymentService
{
    private readonly HttpClient _httpClient;
    private readonly IIdempotencyStore _store;

    public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request, string idempotencyKey)
    {
        // 1. Daha önce işlenmiş mi?
        var existingResult = await _store.GetAsync<PaymentResult>(idempotencyKey);
        if (existingResult != null)
        {
            _logger.LogInformation("Ödeme zaten işlenmiş: {Key}", idempotencyKey);
            return existingResult;
        }

        // 2. Banka API'sine gönder (Idempotency-Key başlığı ile)
        var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/payments")
        {
            Content = JsonContent.Create(request)
        };
        httpRequest.Headers.Add("Idempotency-Key", idempotencyKey);

        var response = await _httpClient.SendAsync(httpRequest);
        response.EnsureSuccessStatusCode();

        var result = await response.Content.ReadFromJsonAsync<PaymentResult>();

        // 3. Sonucu sakla
        await _store.SetAsync(idempotencyKey, result, TimeSpan.FromHours(24));

        return result;
    }
}

Banka Tarafı: Banka da kendi tarafında Idempotency-Key'i kontrol eder ve aynı anahtarla gelen ikinci isteği işlemez, ilk sonucu döner.


Sonuç

Dağıtık sistemlerde "exactly-once" teslimat pratikte imkansızdır. Bunun yerine, at-least-once teslimat + idempotent işleme = effectively-once semantiği sağlanır. Idempotency Key, deduplication stratejileri (veritabanı, Redis, hibrit), Outbox Pattern ve doğal idempotency teknikleri ile mesaj tekrarlarını zararsız hale getirebilirsiniz. Kafka, idempotent producer ve transactional messaging ile Kafka içinde exactly-once sağlar; ancak dış sistemlere yazma işlemleri için hala idempotency gerekir. RabbitMQ ve Azure Service Bus gibi sistemlerde ise consumer tarafında idempotency uygulanmalıdır. Unutmayın: Güvenilir bir dağıtık sistem, "hiç hata olmayacak" varsayımıyla değil, "hata olacak ve tekrarlar olacak" varsayımıyla tasarlanır. Idempotency, bu gerçeği yönetmenin anahtarıdır.

Tüm yazılar Soru ya da yorumun mu var? Yaz