Dağıtık Sistemler & Mesajlaşma Mimarisi 6 dk okuma 3 okunma

Idempotency ve Exactly-Once Semantiği

Dağıtık sistemlerde "tam olarak bir kez" (exactly-once) teslimat 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. Idempotency Key, deduplication stratejileri (veritabanı, Redis, hibrit) ve Outbox Pattern ile mesaj tekrarları zararsız hale getirilir.

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.


1. Teslimat Garantileri: Üç Semantik

Garanti Açıklama Risk Kullanım Alanı
At-Most-Once Mesaj en fazla bir kez teslim edilir Mesaj kaybolabilir Log toplama, metrikler
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.

Çözüm: At-least-once teslimat + idempotent işleme = "Effectively-Once".


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.

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. ✅

Non-Idempotent İşlemler:

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

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


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 (Idempotency-Key başlığında).
3. Sunucu, anahtarı bir depoda (Redis, veritabanı) arar.
4. Anahtar yoksa: İşlemi yap, sonucu anahtarla sakla.
5. Anahtar varsa: Saklanan sonucu döndür, işlemi tekrarlama.

Kritik Tasarım Kararları:

Karar Öneri
Anahtar Üretimi İstemci üretmeli (UUID v4)
Anahtar Kapsamı (kullanıcı, endpoint, anahtar) bazlı
TTL 24-72 saat
Depolama Redis (hızlı) + Veritabanı (kalıcı)
Çakışma Yönetimi 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.

csharp

var config = new ProducerConfig
{
    BootstrapServers = "localhost:9092",
    EnableIdempotence = true,
    Acks = Acks.All,
    MaxInFlight = 5
};

Kafka'nın Sınırı: Exactly-once, yalnızca Kafka içinde (Kafka → Kafka) geçerlidir. Dış sistemlere 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.

csharp

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

        var cached = await _cache.GetStringAsync(cacheKey);
        if (cached != null)
            return; // Zaten işlenmiş

        using var transaction = await _context.Database.BeginTransactionAsync();
        try
        {
            _context.Orders.Add(new Order { Id = context.Message.OrderId });
            _context.ProcessedMessages.Add(new ProcessedMessage
            {
                MessageId = messageId,
                ProcessedAt = DateTime.UtcNow
            });
            await _context.SaveChangesAsync();
            await transaction.CommitAsync();

            await _cache.SetStringAsync(cacheKey, "1", 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 içinde aynı MessageId ile gelen mesajlar otomatik olarak reddedilir.

csharp

var message = new ServiceBusMessage(BinaryData.FromString(json))
{
    MessageId = Guid.NewGuid().ToString(),
    SessionId = orderId.ToString()
};
await sender.SendMessageAsync(message);

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);
}

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

B. Redis Tabanlı (Hızlı)

csharp

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

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

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

  • Hızlı kontrol: Redis

  • Kalıcı kayıt: Veritabanı

D. Doğal Idempotency (En Zarif)

İşlemi doğası gereği idempotent yapın:

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

  • MERGE (SQL Server)

  • UPSERT (MongoDB)

sql

INSERT INTO orders (id, customer_id, total)
VALUES (@Id, @CustomerId, @Total)
ON CONFLICT (id) DO NOTHING;

6. .NET'te Idempotent Consumer Middleware

MassTransit, idempotency için bir middleware sunar.

csharp

builder.Services.AddMassTransit(x =>
{
    x.AddConsumer<OrderConsumer>();
    
    x.UsingRabbitMq((context, cfg) =>
    {
        cfg.ReceiveEndpoint("order-queue", e =>
        {
            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.

csharp

using var transaction = await _context.Database.BeginTransactionAsync();

_context.Orders.Add(order);
_context.OutboxMessages.Add(new OutboxMessage
{
    Id = Guid.NewGuid(),
    Type = "OrderCreated",
    Content = JsonSerializer.Serialize(new OrderCreatedEvent(order.Id)),
    OccurredOnUtc = DateTime.UtcNow
});

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

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

8. En İyi Pratikler

Pratik Açıklama
Idempotency Key kullanın Her işlem için benzersiz anahtar üretin
Anahtar kapsamını doğru belirleyin (kullanıcı, endpoint, anahtar) bazlı benzersizlik
TTL belirleyin İşlenen mesaj kayıtlarını 24-72 saat saklayın
Aynı transaction'da kaydedin İş verisi + processed message kaydı aynı transaction'da
Hibrit depolama kullanın Redis (hızlı) + Veritabanı (kalıcı)
Doğal idempotency'yi tercih edin UPSERT, MERGE, ON CONFLICT DO NOTHING
Outbox Pattern kullanın Veritabanı + mesaj gönderimini atomik hale getirin
Idempotency'yi test edin Aynı mesajı birden fazla kez göndererek test edin

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.

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

csharp

public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request, string idempotencyKey)
{
    var existingResult = await _store.GetAsync<PaymentResult>(idempotencyKey);
    if (existingResult != null)
        return existingResult;

    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>();
    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