İçeriğe Atla
Mustafa Erbay
Kariyer · 12 dk okuma · görüntülenme Read in English

Dağıtık Sistemlerde İdempotans Tasarımının 3 Pratik Yolu

Dağıtık mimarilerde mükerrer istekleri engellemek için uyguladığım 3 pratik idempotans stratejisini, production tecrübelerim ve kod örnekleriyle anlatıyorum.

100%

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.

Paylaş:

Bu yazı faydalı oldu mu?

Yükleniyor...

Bu yazı nasıldı?

Sıkça Sorulanlar

Bu makale ile ilgili okurların sorduğu yaygın sorular.

Dağıtık sistemlerde idempotans tasarımını uygulamaya başlarken nelere dikkat etmem gerekir?
Ben, dağıtık sistemlerde idempotans tasarımını uygulamaya başlarken, ağ katmanındaki paket kaybı veya gecikme gibi durumlara karşı sistemi hazırlıklı hale getirmek için, istemcinin aynı isteği tekrar tekrar gönderme ihtimaline karşı önlemler almayı önemsiyorum. Ayrıca, idempotansın sadece teknik bir sorun değil, aynı zamanda iş süreçlerini ve veritabanını etkileyen bir konu olduğunu da göz önünde bulunduruyorum.
İdempotans tasarımını uygularken hangi araçları kullanmalıyım?
Ben, idempotans tasarımını uygularken, HTTP protokolü standartlarını ve RFC 7231'i Referans olarak kullanıyorum. Ayrıca, istemci tarafında retry mekanizmaları ve sunucu tarafında ise idempotent işlemleri destekleyen framework'leri ve kütüphaneleri araştırıyorum. Örneğin, bazı casoslarda, token-based sistemleri veya uniquely identifiable transaction ID'leri kullanmak gibi yöntemler benim için faydalı olabiliyor.
İdempotans tasarımını uygularken olası hatalara karşı nasıl önlem alabilirim?
Ben, idempotans tasarımını uygularken, olası hatalara karşı önlem almak için, sistemin her bir bileşeninin idempotent olması gerektiğini dikkate alıyorum. Ayrıca, istemci tarafında retry mekanizmalarını ve sunucu tarafında ise işlem kayıtlarını tutmak için uygun bir veritabanı tasarımı yapıyorum. Örneğin, bir ödeme servisinde, aynı ödemeyi iki kez işlememek için, işlemin başarılı olup olmadığını kontrol etmek ve gerektiğinde istemciye geri bildirim sağlamak benim için önemli.
İdempotans tasarımını uygularken trade-off'lar nelerdir?
Ben, idempotans tasarımını uygularken, trade-off'lar olarak, istemci tarafında retry mekanizmaları kullanarak sistem performansı üzerinde olası etkileri, sunucu tarafında ise idempotent işlemleri desteklemek için ek yük getirebilecek tasarım quyếtimleri gibi konuları değerlendiriyorum. Örneğin, bazı casoslarda, idempotans tasarımını uygulamak için, sistem performansı üzerinde bir miktar fedakarlık yapmak gerekebiliyor, ancak bu, sistemin güvenilirliğini ve tutarlılığını sağlamak için gerekli bir adım olabiliyor.
ME

Mustafa Erbay

Sistem Mimarisi · Network Uzmanı · Altyapı, Güvenlik ve Yazılım

2006'dan bu yana sistem mimarisi, network, sunucu altyapıları, büyük yapıların kurulumu, yazılım ve sistem güvenliği ekseninde çalışıyorum. Bu blogda sahada karşılığı olan teknik deneyimlerimi paylaşıyorum.

Kişisel Notlar

Bu notlar sadece sizde saklanır. Tarayıcınızda yerel olarak tutulur.

Hazır 0 karakter

Yorumlar

Sunucu Taraflı AI Moderasyon

Yorumlar sunucuda yapay zeka ile denetlenir ve kalıcı olarak saklanır.

?
0/2000

Sunucu taraflı AI denetim

✉️ Ücretsiz · Spam yok · İstediğin an çık

Yeni yazılardan haberdar olun

Yeni içerikler ve teknik notlar e-postanıza gelsin.

  • 📌
    Haftanın en iyisi Sadece okumaya değer tek yazı
  • 🔧
    Alet çantası Bu hafta kullandığım araçlar
  • 🧠
    Perde arkası Blog'a girmeyen notlar

Spam yapmıyoruz. İstediğiniz zaman ayrılabilirsiniz. · Sadece Umami (self-hosted, Google yok) ile takip.

Okuma İstatistikleriniz

0

Yazı Okundu

0dk

Okuma Süresi

0

Gün Serisi

-

Favori Kategori

İlgili Yazılar