Idempotent API Tasarımı: Çift Sipariş ve Ödemeyi Önlemek
Zaman aşımı, mobil yeniden deneme ve gateway retry'ı aynı isteği iki kez gönderir; sonucu çift sipariş ve çift ödemedir. Idempotency-Key deseninin sunucu tarafında nasıl uygulandığını, iş anahtarlarıyla doğal idempotency'yi, ETag ile koşullu güncellemeyi ve mesaj tüketicilerindeki karşılığını Spring Boot örnekleriyle anlatıyoruz.
Bir mobil uygulama "Siparişi Onayla" düğmesine basıldığında isteği gönderir, yanıt 30 saniye gelmeyince yeniden dener. API gateway 502 aldığında otomatik retry yapar. Kullanıcı sabırsızlanıp ikinci kez tıklar. Bu üç senaryonun ortak sonucu aynıdır: aynı istek sunucuya iki kez ulaşır ve iki sipariş, iki ödeme, iki fatura oluşur. Yeniden deneme dağıtık sistemlerde kaçınılmazdır; sorun yeniden denemede değil, API'nin buna hazır olmamasındadır.
Idempotent bir işlem, kaç kez tekrarlanırsa tekrarlansın tek bir etki üretir. Bu yazıda HTTP'de neyin doğal olarak idempotent olduğunu, Idempotency-Key deseninin sunucu tarafında nasıl uygulandığını, iş anahtarlarıyla doğal idempotency'yi, ETag ile koşullu güncellemeyi ve mesaj tüketicilerindeki karşılığını Spring Boot örnekleriyle anlatıyoruz.
HTTP'de doğal idempotency
Metot | Idempotent mi? | Not |
|---|---|---|
GET, HEAD | Evet | Yan etkisiz olmalı; "GET ile sil" tasarımı bu garantiyi bozar |
PUT | Evet | Kaynağın tamamını verilen duruma getirir; ikinci PUT aynı durumu tekrar yazar |
DELETE | Evet | İkinci silme 404 dönebilir, ama etki tektir |
POST | Hayır | Her çağrı yeni kaynak yaratır; idempotency açıkça tasarlanmalı |
PATCH | Genellikle hayır | "stok += 5" gibi göreli güncellemeler tekrarda birikir |
Kural: yaratma işlemleri (POST) ve göreli güncellemeler idempotency garantisi gerektirir. Diğerleri tasarım bozulmadıkça güvenlidir.
Idempotency-Key deseni
İstemci her yaratma isteğine benzersiz bir anahtar (UUID) koyar; sunucu bu anahtarla istek özetini ve üretilen yanıtı saklar. Aynı anahtar ikinci kez gelirse iş mantığı çalıştırılmaz, saklanan yanıt aynen döner.
POST /api/siparisler HTTP/1.1
Idempotency-Key: 7f3c9e2a-5b1d-4c8e-9a0f-2d6b8e4c1a77
Content-Type: application/json
{ "musteriId": "M-1042", "kalemler": [ { "urun": "10001", "miktar": 3 } ] }Durum | Sunucu davranışı | HTTP yanıtı |
|---|---|---|
Anahtar ilk kez görülüyor | İşi çalıştır, yanıtı anahtarla sakla | 201 Created |
Aynı anahtar, aynı istek gövdesi | Saklanan yanıtı döndür; iş mantığı çalışmaz | Orijinalle aynı (201) |
Aynı anahtar, farklı gövde | Reddet; istemci anahtarı yanlış kullanıyor | 422 Unprocessable Content |
Aynı anahtar, ilk istek hâlâ işleniyor | Bekletme ya da reddet | 409 Conflict (Retry-After ile) |
Anahtar yok | Politikaya göre: reddet ya da normal işle | 400 (zorunluysa) |
Sunucu tarafı uygulama
Kritik nokta anahtarı atomik olarak sahiplenmektir; iki eşzamanlı istek aynı anahtarı aynı anda "ilk kez" görmemelidir. Bunun en güvenilir yolu veritabanındaki unique kısıttır:
CREATE TABLE idempotency_keys (
key VARCHAR(64) PRIMARY KEY,
scope VARCHAR(64) NOT NULL, -- kullanıcı / kiracı; anahtar kapsam içinde benzersiz
request_hash CHAR(64) NOT NULL, -- gövdenin SHA-256'sı
status VARCHAR(16) NOT NULL, -- IN_PROGRESS | DONE
response_code INT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);@Component
public class IdempotentYurutucu {
private final IdempotencyRepository repo;
private final TransactionTemplate tx;
public <T> Sonuc<T> calistir(String anahtar, String kapsam, String govdeHash, Supplier<Sonuc<T>> is) {
// 1) Anahtarı sahiplen: INSERT ... ON CONFLICT DO NOTHING → 0 satır = daha önce görülmüş
boolean sahiplenildi = tx.execute(s -> repo.sahiplen(anahtar, kapsam, govdeHash));
if (!sahiplenildi) {
IdempotencyKaydi k = repo.bul(anahtar, kapsam);
if (!k.requestHash().equals(govdeHash)) throw new AnahtarUyusmazligi(); // 422
if (k.status() == Status.IN_PROGRESS) throw new HalaIsleniyor(); // 409
return Sonuc.of(k.responseCode(), k.responseBody()); // saklanan yanıt
}
// 2) İşi çalıştır ve yanıtı anahtarla birlikte tamamla
try {
Sonuc<T> sonuc = is.get();
tx.executeWithoutResult(s -> repo.tamamla(anahtar, kapsam, sonuc.code(), sonuc.bodyJson()));
return sonuc;
} catch (RuntimeException e) {
tx.executeWithoutResult(s -> repo.sil(anahtar, kapsam)); // başarısız denemede anahtar serbest kalır
throw e;
}
}
}@PostMapping("/api/siparisler")
ResponseEntity<SiparisYaniti> olustur(@RequestHeader("Idempotency-Key") String anahtar,
@AuthenticationPrincipal Kullanici k,
@Valid @RequestBody SiparisIstegi istek) {
Sonuc<SiparisYaniti> sonuc = yurutucu.calistir(anahtar, k.kiraciId(), Hash.sha256(istek),
() -> Sonuc.created(siparisServisi.olustur(istek.toKomut())));
return ResponseEntity.status(sonuc.code()).body(sonuc.body());
}İki ayrıntı önemlidir. Anahtar kapsam içinde benzersizdir (kullanıcı ya da kiracı); aksi hâlde bir istemcinin ürettiği UUID başkasının yanıtını döndürebilir. Ve iş mantığının kendi transaction'ı ile anahtar tamamlama tek transaction'da olmalıdır; aksi hâlde "sipariş oluştu, anahtar DONE olmadı" durumunda tekrar istek ikinci siparişi üretir. Yukarıdaki örnekte bunu is.get() içindeki servis transaction'ına repo.tamamla'yı dahil ederek sağlarsınız; alternatif olarak dış transaction sınırı kullanılır.
Doğal idempotency: anahtarı iş verisinden almak
Her yerde Idempotency-Key gerekmez. Çoğu kurumsal senaryoda iş verisinin kendisi benzersiz bir anahtar taşır:
İstemci tarafında üretilen kaynak kimliği: Sipariş numarasını istemci üretir ve
PUT /api/siparisler/{no}ile gönderir; ikinci PUT aynı kaynağı yeniden yazar, tablo üzerindeki unique kısıt çift kaydı engeller.Dış referans: ERP'ye aktarılan siparişte müşteri referansı, e-faturada belge numarası; karşı sistem "bu referans zaten var" der.
Upsert:
INSERT ... ON CONFLICT (musteri_id, tarih) DO UPDATEile günlük özet kayıtları kaç kez yazılırsa yazılsın tek satırdır.
Güncellemelerde: ETag ve koşullu istek
Güncellemenin sorunu tekrar değil, kayıp güncellemedir: iki kullanıcı aynı kaydı okur, ikisi de yazar, biri diğerini ezer. Çözüm optimistic locking'in HTTP karşılığıdır:
GET /api/musteriler/M-1042 → 200, ETag: "v7"
PUT /api/musteriler/M-1042
If-Match: "v7" → sürüm hâlâ 7 ise 200; değiştiyse 412 Precondition FailedSpring'de @Version alanı ETag olarak dışa verilir; If-Match eşleşmezse ObjectOptimisticLockingFailureException 412'ye çevrilir. İstemci kaydı yeniden okur ve kullanıcıya çakışmayı gösterir.
Mesaj tüketicilerinde idempotency
HTTP'de yeniden deneme neyse Kafka ve kuyruklarda yeniden teslim odur; en az bir kez teslim garantisi, aynı mesajın iki kez işlenebileceği anlamına gelir. Karşılık aynıdır: tüketici, işlediği mesaj kimliklerini kendi transaction'ında bir tabloya yazar ve tekrar gelen kimliği atlar. Bu deseni outbox pattern yazısında ayrıntılı ele aldık.
Ödeme senaryosu: sonucu bilinmeyen istek
En zor durum, ödeme sağlayıcısına gönderilen isteğin zaman aşımına uğramasıdır: para çekildi mi, çekilmedi mi? Yeniden denemek çift çekim riskidir; denememek kayıp satıştır. Doğru sıra: (1) kendi idempotency anahtarınızı sağlayıcının anahtar mekanizmasına iletin (çoğu ödeme API'si destekler), (2) zaman aşımında önce sağlayıcıdan işlemin durumunu sorgulayın, (3) sonuç bilinmiyorsa aynı anahtarla yeniden deneyin. Anahtarsız yeniden deneme ödeme sistemlerinde kabul edilemez.
Uygulama ayrıntıları
Saklama süresi: Anahtarlar 24–72 saat tutulur, sonra silinir; istemciler yeniden denemeyi bu pencerede yapar.
Gövde özeti: Aynı anahtarla farklı gövdeyi yakalamak için SHA-256; alan sırasından etkilenmemek için kanonik JSON.
Yanıt saklama: Yalnızca durum kodu ve gövde; büyük yanıtlarda kaynak kimliğini saklayıp yeniden üretmek daha ucuzdur.
Gözlemlenebilirlik: "Tekrar isteğin yakalanma oranı" metriği hem istemci hatalarını hem ağ sorunlarını erken gösterir.
Dokümantasyon: OpenAPI tanımında başlığı zorunlu parametre olarak yazın; istemci ekipleri desenin varlığını bilmezse uygulamaz.
Tuzaklar
Anahtarı bellek içi önbellekte tutmak. Uygulama yeniden başlayınca ya da ikinci örnek devreye girince garanti kaybolur; paylaşılan veritabanı ya da Redis gerekir.
Anahtarı kontrol edip sonra yazmak. "SELECT varsa dön, yoksa INSERT" iki eşzamanlı istekte ikisini de geçirir; atomik INSERT şarttır.
Başarısız denemenin anahtarını kilitli bırakmak. İş mantığı hata verdiyse anahtar serbest kalmalı; aksi hâlde istemci düzeltip yeniden deneyemez.
Kapsamsız anahtar. Kiracılar arası anahtar çakışması veri sızıntısıdır.
Sık sorulan sorular
Idempotency-Key'i istemci mi sunucu mu üretmeli?
İstemci; çünkü yeniden denemeyi istemci yapar ve aynı anahtarı göndermesi gerekir. Sunucunun ürettiği anahtar ancak "önce anahtar al, sonra gönder" biçiminde iki adımlı akışla çalışır ve ek gecikme getirir.
Redis mi veritabanı mı?
Yanıt hızı kritik ve anahtar ömrü kısaysa Redis (SET NX EX ile atomik sahiplenme) uygundur. Ödeme gibi kaybı kabul edilemeyen işlemlerde iş verisiyle aynı veritabanı, tek transaction garantisi verdiği için daha güvenlidir.
Tüm POST uçlarına uygulamalı mıyız?
Etkisi geri alınamayan ya da maliyetli olanlara: sipariş, ödeme, fatura, e-posta/SMS gönderimi, dış sisteme yazma. Arama ve rapor gibi yan etkisiz POST'lar için gerekmez.
Sonuç
Yeniden deneme dağıtık sistemlerin doğasıdır; API'nin görevi bunu güvenli kılmaktır. Yaratma işlemlerinde Idempotency-Key ya da iş anahtarı, güncellemelerde ETag/If-Match, tüketicilerde işlenen mesaj tablosu — bu üçlü, çift sipariş ve çift ödeme sınıfındaki hataları tasarım düzeyinde ortadan kaldırır.
Mevcut API'lerinizi tekrar eden isteklere karşı güvenli hale getirmek için entegrasyon ve otomasyon ve web ve e-ticaret geliştirme hizmetlerimize göz atabilirsiniz.