Dağıtık bir mimaride çalışıyorsanız, ağın her an kopabileceğini, paketlerin kaybolabileceğini ve istemcilerin aynı isteği tekrar tekrar göndereceğini peşinen kabul etmeniz gerekir. Ağdaki kısa süreli bir dalgalanma yüzünden API ağ geçidinden dönen timeout cevabı, istemcinin (veya bir kuyruk tüketicisinin) aynı ödeme tetikleme isteğini ikinci kez göndermesine yol açar. Eğer sisteminiz bu duruma hazırlıklı değilse, kullanıcının kartından iki kez para çekilir veya depodan aynı ürün için iki ayrı sevkiyat emri oluşturulur.
Saha tecrübemde, bu tarz mükerrer işlemler yüzünden batan iş süreçlerini, veritabanında biriken mutabakat hatalarını bizzat temizlemek zorunda kaldım. Bu yazıda, üretim ortamındaki ERP ve yüksek trafikli arka uç servislerinde uyguladığım, dağıtık sistemlerde idempotans tasarımının 3 pratik yolu konusunu tüm teknik detayları, kod blokları ve trade-off analizleriyle masaya yatırıyorum.
Dağıtık Sistemlerde İdempotans Tasarımının 3 Pratik Yolu: Neden Önemli?
İdempotans, matematiksel ve bilgisayar bilimi terimi olarak, bir işlemin ilk uygulanmasından sonraki tekrarlarında durumun değişmemesi ve her seferinde aynı sonucun üretilmesi anlamına gelir. HTTP protokolü standartlarında (RFC 7231) GET, PUT ve DELETE metotlarının idempotent olması beklenirken, veri yazan veya kaynak oluşturan POST istekleri doğası gereği idempotent değildir. Dağıtık sistemlerde ağ katmanındaki bir paket kaybı veya gecikme, istemciye “işlem başarılı” cevabının ulaşmasını engellediğinde, istemci haklı olarak isteği yeniden dener (retry).
Aşağıdaki basit log akışı, bir ödeme servisinin arka arkaya aldığı mükerrer isteklerin yarattığı tipik bir sorunu gösteriyor. İkinci istek, ilkinin cevabı henüz yoldayken veya işlenirken sisteme girdiğinde yarış durumuna (race condition) yol açar:
# Zaman damgaları/gecikmeler ortama göre değişir; akış örnektir
[INFO] POST /api/v1/charges - Payload: {"order_id": "ord_99812", "amount": 1500} - Client_IP: 192.168.12.44
[WARN] HTTP Gateway Timeout (504) returned to client due to internal database latency.
[INFO] POST /api/v1/charges - Payload: {"order_id": "ord_99812", "amount": 1500} - Client_IP: 192.168.12.44 (RETRY)
[ERROR] Double-charge detected! Order ord_99812 already processed. Transaction ID: tx_88129-2
Eğer sisteminiz bu iki isteği birbirinden ayırt edemezse, iki ayrı finansal kayıt oluşturulur. Dağıtık mimarilerde kuyruk mekanizmaları (Message Brokers) genellikle “At-Least-Once” (En Az Bir Kere) teslimat garantisi sunduğu için, mesajların mükerrer tüketilmesi kaçınılmaz bir gerçektir. Bu yüzden, idempotans bir lüks değil, veri bütünlüğünü korumak için zorunlu bir mimari katmandır.
Bir üretim ERP’sinin tedarik zinciri entegrasyonunda, benzer bir ağ paket kaybı yüzünden aynı hammadde siparişinin tedarikçiye birden fazla kez geçildiğini görmüştüm. Bu tarz büyük ölçekli organizasyonel akışlarda hatayı düzeltmek ciddi zaman alan telefon trafiğine ve itibar kaybına yol açıyor.
Yöntem 1: Benzersiz İstek Anahtarı (Unique Request Key) ve Veritabanı Benzersizlik Kısıtları
En kararlı ve “bize bir şey olmaz” diyebileceğimiz yöntem, veritabanı motorunun kendi ACID yeteneklerine güvenmektir. İstemci, her işlem için benzersiz bir UUID (örneğin idempotency_key) üretir ve bunu HTTP header alanında veya payload içinde gönderir. Biz de veritabanı seviyesinde bu anahtarı benzersiz (UNIQUE) bir kısıt ile koruma altına alırız.
PostgreSQL 14+ üzerinde tasarladığım bir sipariş tablosunda bu yapıyı kurarken, sadece benzersiz index tanımlamak yetmez; aynı zamanda çakışma durumunda veritabanının hata fırlatmasını veya mevcut kaydı dönmesini (UPSERT) zarifçe yönetmemiz gerekir.
-- PostgreSQL üzerinde idempotans tablosu ve benzersiz index tasarımı
CREATE TABLE idempotency_keys (
key_hash VARCHAR(64) PRIMARY KEY,
response_code SMALLINT NOT NULL,
response_body JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- İşlem tablosuyla ilişkilendirilmiş transactional yazma senaryosu
CREATE OR REPLACE FUNCTION process_payment(
p_idempotency_key VARCHAR,
p_order_id VARCHAR,
p_amount NUMERIC
) RETURNS JSON AS $$
DECLARE
v_response JSON;
BEGIN
-- Önce idempotency tablosunu kontrol et ve kilitle
INSERT INTO idempotency_keys (key_hash, response_code, response_body)
VALUES (p_idempotency_key, 201, json_build_object('status', 'success', 'order_id', p_order_id, 'amount', p_amount))
ON CONFLICT (key_hash) DO NOTHING;
-- Eğer kayıt eklenemediyse (çakışma varsa), eski cevabı oku
IF NOT FOUND THEN
SELECT response_body INTO v_response FROM idempotency_keys WHERE key_hash = p_idempotency_key;
RETURN v_response;
END IF;
-- Gerçek iş mantığı (Ödeme kaydı oluşturma vb.) buraya gelir
-- ...
RETURN json_build_object('status', 'success', 'order_id', p_order_id, 'amount', p_amount);
END;
$$ LANGUAGE plpgsql;
Bu yöntemin en büyük avantajı, ek bir stateful katmana (Redis, Memcached vb.) ihtiyaç duymamasıdır. Veritabanınız ayakta olduğu sürece idempotans garantisi altındadır. Ancak, her avantajın bir bedeli var.
Yüksek yazma trafiği alan sistemlerde, her istek için veritabanına gidip benzersizlik kontrolü yapmak ve B-tree index’i üzerinde yazma yükü oluşturmak disk I/O limitlerini zorlayabilir. Ayrıca, uzun süren işlemlerde (örneğin dış bir banka API’sine istek atılırken) veritabanı transaction’ını açık tutmak, connection pool’un hızla tükenmesine (starvation) neden olur.
Yöntem 2: Dağıtık Kilit (Distributed Lock) ve Redis Üzerinde Token Tabanlı Doğrulama
Eğer veritabanınız üzerindeki yükü azaltmak ve milisaniyeler mertebesinde cevap dönmek istiyorsanız, bellek içi (in-memory) bir dağıtık kilit mekanizması kurmanız gerekir. Bu yöntemde Redis’in atomik komutlarından ve TTL (Time-To-Live) özelliğinden faydalanırız.
İstemciden gelen Idempotency-Key değerini Redis üzerinde SET key value NX PX milliseconds komutuyla kilitleriz. NX parametresi anahtarın sadece yoksa oluşturulmasını sağlar, PX ise anahtara milisaniye cinsinden bir ömür biçer. Bu sayede, aynı anahtarla gelen ikinci bir istek, kilit henüz açılmadığı için reddedilir.
FastAPI backend mimarilerimde sıklıkla kullandığım, Redis tabanlı ve hata toleranslı idempotans middleware yapısının basitleştirilmiş bir örneği aşağıdadır:
import redis
import time
from fastapi import FastAPI, Request, HTTPException, status
app = FastAPI()
# Redis bağlantı havuzu ayarları (max_connections=50, timeout limitleri set edilmiş)
redis_pool = redis.ConnectionPool(host='127.0.0.1', port=6379, db=0, max_connections=50)
r = redis.Redis(connection_pool=redis_pool)
def acquire_idempotency_lock(key: str, ttl_ms: int = 5000) -> bool:
"""
Redis üzerinde atomik lock alır.
Eğer anahtar zaten varsa False döner.
"""
# PX parametresi milisaniye cinsinden TTL belirler, NX ise sadece yoksa yazar
return bool(r.set(f"idemp:{key}", "PROCESSING", px=ttl_ms, nx=True))
def set_idempotency_response(key: str, response_data: str, ttl_sec: int = 86400):
"""İşlem bittiğinde cevabı Redis'e kaydeder (1 gün saklanır)"""
r.setex(f"idemp_resp:{key}", ttl_sec, response_data)
def get_idempotency_response(key: str) -> str:
"""Eski cevabı Redis'ten hızlıca çeker"""
val = r.get(f"idemp_resp:{key}")
return val.decode('utf-8') if val else None
@app.post("/api/v1/orders")
async def create_order(request: Request):
idemp_key = request.headers.get("X-Idempotency-Key")
if not idemp_key:
raise HTTPException(status_code=400, detail="X-Idempotency-Key header is missing")
# 1. Aşama: Eski bir başarılı işlem cevabı var mı kontrol et
cached_response = get_idempotency_response(idemp_key)
if cached_response:
return {"source": "cache", "data": cached_response}
# 2. Aşama: Dağıtık kilit edinmeye çalış
if not acquire_idempotency_lock(idemp_key, ttl_ms=10000): # 10 saniye limit
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Another request with the same idempotency key is already in progress."
)
try:
# Gerçek iş mantığı (ERP stok rezervasyonu, fatura kesimi vb.)
# Ağır işlem simülasyonu (süre ortama göre değişir)
time.sleep(0.15)
result_payload = f"Order created successfully for key {idemp_key}"
# 3. Aşama: Başarılı cevabı kaydet
set_idempotency_response(idemp_key, result_payload)
return {"source": "engine", "data": result_payload}
finally:
# Kilidi temizle (Hata durumunda sonraki retry'lar önünü açsın)
r.delete(f"idemp:{idemp_key}")
Bu kodun en kritik noktası finally bloğudur. Eğer iş mantığınız çalışırken kod hata fırlatırsa (örneğin veritabanı bağlantısı koptu), kilidi hemen silmeliyiz ki istemci 3 saniye sonra tekrar denediğinde “kilitli” hatası almasın, işlemi baştan başlatabilsin.
Ancak bu yöntemin de zayıf bir karnı var: Redis bellek limitine (OOM) ulaştığında ne olacak? Eğer Redis üzerinde allkeys-lru veya başka bir tahliye (eviction) politikası aktifse, aktif kilitleriniz veya eski işlem cevaplarınız bellekten silinebilir. Bu durumda sisteminiz sessizce idempotans özelliğini kaybeder ve mükerrer kayıtlar oluşmaya başlar.
Yöntem 3: State Machine (Durum Makinesi) ve Sürüm Kontrolü (Optimistic Locking)
Özellikle kuyruk tabanlı (Kafka/RabbitMQ) ve event-driven (olay güdümlü) mimarilerde, idempotans sağlamanın en zarif yolu nesnelerin kendi yaşam döngülerini (State Machine) ve sürüm numaralarını (Version Column) kullanmaktır. Bu yaklaşıma İyimser Kilitleme (Optimistic Locking) de denir.
Her veritabanı kaydının bir state (durum) ve version (sürüm) alanı bulunur. Durum geçişleri sadece belirli kurallara göre yapılabilir. Örneğin, bir sipariş PENDING durumundan ancak PROCESSING durumuna geçebilir; COMPLETED durumundaki bir sipariş tekrar PROCESSING durumuna çekilemez.
[PENDING, v1] --> (Süreç Başlat) --> [PROCESSING, v2] --> (Ödeme Alındı) --> [COMPLETED, v3]
| | |
+------------ (Mükerrer İstek) ----------+------------ (Mükerrer İstek) ----------+
| |
[Reddet/Yoksay] [Reddet/Yoksay]
Bu akışı SQL seviyesinde optimize etmek, uygulamanın bellek yükünü sıfıra indirir. Aşağıdaki sorgu, bir siparişin durumunu güncellerken sürüm kontrolünü nasıl yapacağımızı gösteriyor:
-- PostgreSQL üzerinde sürüm kontrolü ile durum güncelleme
-- Eğer başka bir worker thread bu kaydı zaten güncellediyse, affected rows 0 dönecektir.
UPDATE orders
SET
status = 'COMPLETED',
version = version + 1,
updated_at = NOW()
WHERE
id = 'ord_10293'
AND status = 'PROCESSING'
AND version = 4;
Eğer bu sorgu sonucunda etkilenen satır sayısı 0 ise, anlarız ki ya bu işlem daha önce yapılmış (sürüm zaten 5 olmuş) ya da kayıt şu an beklediğimiz durumda değil. Bu durumda worker hata fırlatmaz, işlemi “zaten tamamlanmış” kabul ederek mesajı kuyruktan başarılı bir şekilde siler (ACK döner).
Bu yöntem, ek bir kilit mekanizması gerektirmediği için son derece performanslıdır. Ancak, iş akışınızın katı bir durum makinesine uygun tasarlanmış olması gerekir. Durum geçişlerinin net olmadığı karmaşık akışlarda bu yapıyı kurmak ciddi bir analiz maliyeti getirir.
Ağ Seviyesinde Retry Mekanizmaları ve Idempotency-Key HTTP Header Standartları
Uygulama kodunun dışında, ağ geçitlerinin (API Gateway) ve ters vekil sunucuların (Nginx, HAProxy) retry politikaları da idempotans üzerinde doğrudan etkilidir. Örneğin Nginx, varsayılan olarak bir upstream sunucusundan timeout aldığında (ve proxy_next_upstream konfigürasyonu aktifse) isteği otomatik olarak diğer bir sunucuya iletir. Eğer ilk sunucu işlemi veritabanında başlatmış ama cevabı dönememişse, Nginx’in bu davranışı sisteminizde çift işlem tetikler.
Bu sorunun önüne geçmek için Nginx konfigürasyonunuzda non_idempotent metodunu dikkatli yönetmeniz gerekir. Nginx loglarında mükerrer isteklerin nasıl göründüğünü anlamak için aşağıdaki log formatını inceleyelim:
# Nginx access.log - İstemcinin timeout sonrası retry davranışı
# Zaman damgaları ve upstream_response_time değerleri ortama göre değişir
192.168.12.44 - - "POST /api/v1/charges HTTP/1.1" 504 "Go-http-client/1.1" "X-Idempotency-Key: id_9921_abc"
192.168.12.44 - - "POST /api/v1/charges HTTP/1.1" 200 "Go-http-client/1.1" "X-Idempotency-Key: id_9921_abc"
İkinci istekte upstream_response_time değerinin belirgin biçimde kısaldığını görürsünüz. Çünkü sistem ilk yöntemi (veya Redis önbelleğini) kullanarak veritabanına gitmeden doğrudan eski cevabı dönmüştür.
VPS göç süreçlerinde, ağ yönlendirmelerinin oturması sırasında bu tarz çok sayıda mükerrer isteğin API katmanına hücum ettiğine şahit oldum. Eğer ağ geçidinizde ve kodunuzda IETF standardı olan Idempotency-Key başlığını tanıyıp işleyen bir katman yoksa, sisteminizi ayakta tutmanız imkansız hale gelir.
Hatalardan Çıkarılan Dersler ve Production Canlısında Yaşadığım Edge Case Senaryoları
Production ortamında teori her zaman pratiğe uymaz. Kağıt üzerinde mükemmel çalışan sistemler, canlı trafiğin vahşi doğasında garip şekillerde patlayabilir. İşte bizzat tecrübe ettiğim iki kritik hata senaryosu ve çıkardığım dersler:
1. Saat Sapması (Clock Skew) ve Redis TTL Faciası
Bir senaryoda, iki farklı API sunucusunun sistem saatleri arasında belirgin bir sapma (clock skew) vardı. Sunucu A, Redis üzerindeki idempotency kilidini oluştururken kendi saatini baz alarak bir TTL belirledi. Sunucu B ise saat farkı yüzünden bu kilidi “süresi dolmuş” görerek aynı anahtarla gelen ikinci isteği işleme aldı.
Çözüm: Zaman hassasiyeti olan dağıtık kilitlerde asla sunucuların yerel saatlerini (local time) kullanmayın. Redis’in kendi iç saatini (TIME komutu) veya UNIX epoch timestamp değerlerini baz alan, milisaniye hassasiyetli bağımsız zaman sunucularını (NTP) senkronize tutun.
2. Redis OOM (Out Of Memory) Durumunda Sessiz Çöküş
Redis belleği dolduğunda maxmemory-policy volatile-lru gibi bir kural devreye girerse, Redis bellek açmak için henüz süresi dolmamış olan bazı idempotency cevaplarını (metadata) silebilir. Bu sırada gelen retry istekleri, sistemde eski kayıt bulunamadığı için sıfırdan çalışır ve mükerrer işlemler yapılır.
Çözüm: Idempotency ve cache verilerini aynı Redis instance üzerinde tutmayın. Idempotency verisi kritik veri sınıfındadır. Eğer Redis kullanıyorsanız, eviction politikasını noeviction yapın. Bellek dolduğunda Redis hata fırlatsın (yazma yapamasın), bu sayede sistem sessizce yanlış işlem yapacağına güvenli bir şekilde hata moduna (fail-safe) geçer.
Aşağıdaki tablo, bu yazıda incelediğimiz 3 farklı yöntemin kritik metrikler açısından karşılaştırmasını sunmaktadır:
| Kriter | Yöntem 1: DB Unique Constraint | Yöntem 2: Redis Distributed Lock | Yöntem 3: State Machine & Version |
|---|---|---|---|
| Gecikme (Latency) | Yüksek (Disk I/O bağımlı) | Çok Düşük (Bellek içi) | Düşük (Sadece tek bir UPDATE) |
| Karmaşıklık Derecesi | Kolay | Orta (Lock yönetimi gerekir) | Yüksek (Model tasarımı gerekir) |
| Veri Güvenilirliği | Maksimum (ACID garantisi) | Orta (OOM veya Redis çökmesi riski) | Maksimum (DB seviyesinde kontrol) |
| Dış Servis Uyumluğu | Kötü (Uzun transaction riski) | Mükemmel (İşlem öncesi kilit) | Orta (State güncellemesi öncesi) |
| Altyapı Maliyeti | Yok (Mevcut DB kullanılır) | Ekstra (Redis Cluster/Sentinel) | Yok (Mevcut DB kullanılır) |
Sonuç
Dağıtık sistemlerde idempotans tasarlarken tek bir gümüş kurşun yoktur. Eğer finansal veya kritik bir veri işliyorsanız, Yöntem 1 (DB Unique Constraint) en güvenli limanınızdır. Ancak yüksek ölçekli, milisaniyelerin yarıştığı bir e-ticaret veya ödeme geçidi yönetiyorsanız, Yöntem 2 (Redis Lock) ile önbellekleme katmanını kurmak zorundasınız. Event-driven bir mimaride kuyruk tüketiyorsanız da, Yöntem 3 (State Machine) size en temiz ve sürdürülebilir kod yapısını sunar.
Kendi projelerimde genellikle hibrit bir yaklaşım tercih ediyorum: İstekleri API Gateway seviyesinde Redis ile kilitleyip, son yazma aşamasında veritabanındaki benzersiz kısıtlarla (Unique Constraints) kendimi garantiye alıyorum. Bu sayede hem hızı hem de veri bütünlüğünü aynı anda korumak mümkün oluyor.
Sonraki adım: Dağıtık sistemlerde hata toleransını artırmak için Transactional Outbox Pattern makaleme göz atabilirsiniz.