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.