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.