• 6.09.2026 21:03:44
  • Admin Admin

Spring Boot'ta Idempotency-Key tasarımıyla timeout sonrası yinelenen POST isteklerini PostgreSQL, Spring MVC ve Spring Security bağlamında güvenle ele alın.

Spring Boot'ta Idempotency-Key ile REST API Tekrarlarını Yönetmek

Spring REST API için idempotency sözleşmesini doğru kurmak

Bir ödeme, sipariş veya kaynak oluşturma endpoint'inde istemci timeout aldığında aynı POST'u yeniden gönderebilir. Idempotency-Key bu tekrarın HTTP katmanındaki işaretidir; tek başına veri tutarlılığı garantisi değildir. Spring REST API sözleşmesinde anahtarı zorunlu header yapın, anahtarı kimlik ve istek gövdesinin kanonik özetiyle ilişkilendirin. Aynı anahtar farklı gövdeyle gelirse eski yanıtı dönmek yerine 422 üretmek gerekir; aksi halde istemcinin programlama hatası sessizce gizlenir.

@RestController
@RequestMapping("/v1/orders")
class OrderController {
  private final IdempotentOrderService service;

  @PostMapping
  ResponseEntity<OrderResponse> create(
      @RequestHeader("Idempotency-Key") String key,
      @AuthenticationPrincipal Jwt jwt,
      @RequestBody @Valid CreateOrderRequest request) {
    if (!key.matches("[A-Za-z0-9_-]{32,128}")) {
      throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
          "Invalid Idempotency-Key");
    }
    return service.create(jwt.getSubject(), key, request);
  }
}

Anahtarı sadece UUID kabul edecek şekilde gereksizce daraltmayın; bazı mobil SDK'lar URL-safe rastgele dizeler üretir. Buna karşılık 8 karakterlik anahtarları kabul etmek, aynı kullanıcı için çakışma olasılığını artırır. İstek gövdesi için Jackson ile alan sırası sabit JSON üretip SHA-256 hesaplayın. Ham gövdeyi saklamak PII sızıntısı ve büyük tablo büyümesi yaratabileceğinden, denetim gerekmiyorsa hash saklamak daha kontrollü bir seçenektir. Bu ayrım, spring framework eğitimi sırasında çoğu zaman yalnızca controller örneğiyle geçilen HTTP tekrar semantiğini gerçek sistem sınırına taşır.

PostgreSQL unique index ile yarış durumunu atomik çözmek

Kontrol et, sonra ekle yaklaşımı iki eşzamanlı istek için hatalıdır: iki transaction da kaydı görmeyip iş kuralını iki kez çalıştırabilir. Çözüm, `(tenant_id, idempotency_key)` üzerinde unique index ve tek SQL ifadesiyle sahiplik almaktır. Tenant alanını eklemek kritiktir; yalnızca anahtarı unique yapmak, farklı müşterilerin aynı rastgele anahtar yüzünden birbirini etkilemesine neden olur.

create table idempotency_record (
  tenant_id uuid not null,
  idempotency_key varchar(128) not null,
  request_hash char(64) not null,
  state varchar(16) not null,
  response_status integer,
  response_body jsonb,
  lease_until timestamptz,
  created_at timestamptz not null default clock_timestamp(),
  primary key (tenant_id, idempotency_key),
  check (state in ('PENDING', 'COMPLETED', 'FAILED'))
);

create index idempotency_record_expiry_idx
  on idempotency_record (created_at)
  where state = 'COMPLETED';

Spring Data JPA'nin `save()` çağrısını sahiplik mekanizması gibi kullanmayın. Persistence context içindeki entity varlık kontrolü veritabanı izolasyonunda atomik değildir ve unique ihlali geç aşamada flush sırasında gelir. PostgreSQL'de `INSERT ... ON CONFLICT DO NOTHING RETURNING` kullanarak eklenen satır varsa bu isteğin sahibi olduğunu doğrudan anlayabilirsiniz.

@Repository
interface IdempotencyRepository extends Repository<IdempotencyRecord, UUID> {
  @Query(value = """
    insert into idempotency_record
      (tenant_id, idempotency_key, request_hash, state, lease_until)
    values (:tenant, :key, :hash, 'PENDING', clock_timestamp() + interval '30 seconds')
    on conflict (tenant_id, idempotency_key) do nothing
    returning state
    """, nativeQuery = true)
  String tryAcquire(UUID tenant, String key, String hash);

  @Query(value = "select * from idempotency_record " +
      "where tenant_id = :tenant and idempotency_key = :key for update",
      nativeQuery = true)
  IdempotencyRecord lockByKey(UUID tenant, String key);
}

İlk istek işlenirken ikinci istek `PENDING` kaydını görürse iki seçeneğiniz vardır: kısa bir üst sınırla sonucu beklemek veya `Retry-After: 2` ile 409/425 benzeri açık bir tekrar yanıtı vermek. HTTP thread'ini 30 saniye veritabanı kilidinde bekletmek Spring MVC servlet havuzunu tüketebilir. Üretim API'lerinde kısa bekleme yerine istemciye belirli bir retry protokolü vermek daha öngörülebilirdir.

Spring MVC transaction sınırı ve yanıtın yeniden oynatılması

İş kaydını ve idempotency sonucunu aynı yerel transaction içinde tamamlayın. Sipariş oluşturulup idempotency kaydı tamamlanmazsa sonraki tekrar siparişi yeniden üretmeye çalışır. Tersi durumda ise istemci başarılı görünen fakat işlenmemiş bir işlem alabilir. Aşağıdaki servis, sahiplik alındıktan sonra domain yazımını ve saklanacak HTTP sonucunu tek `@Transactional` sınırında tutar.

@Service
class IdempotentOrderService {
  @Transactional
  ResponseEntity<OrderResponse> create(String subject, String key,
      CreateOrderRequest request) {
    UUID tenant = tenantFrom(subject);
    String hash = requestHash(request);
    String acquired = repository.tryAcquire(tenant, key, hash);

    if (acquired == null) {
      IdempotencyRecord old = repository.lockByKey(tenant, key);
      if (!old.getRequestHash().equals(hash)) {
        throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY,
            "Idempotency-Key belongs to another request");
      }
      if ("COMPLETED".equals(old.getState())) {
        return ResponseEntity.status(old.getResponseStatus())
            .body(deserialize(old.getResponseBody()));
      }
      throw new ResponseStatusException(HttpStatus.CONFLICT,
          "Request is still being processed");
    }

    Order order = orderRepository.save(Order.from(request, tenant));
    OrderResponse body = OrderResponse.from(order);
    repository.complete(tenant, key, 201, serialize(body));
    return ResponseEntity.status(HttpStatus.CREATED).body(body);
  }
}

Controller katmanında `ResponseBodyAdvice` veya servlet filter ile her yanıtı otomatik saklamak caziptir, fakat streaming yanıtlar, dosya indirmeleri ve exception resolver sıralaması bu yaklaşımı kırılgan yapar. İlk iterasyonda idempotency'yi yalnızca komut endpoint'lerinde açık servis çağrısı olarak uygulayın. Spring MVC endpoint'lerinin tamamına filter eklemek yerine, `POST /orders` ve `POST /payments` gibi yan etkili rotaları envanterden seçmek test kapsamını da görünür kılar.

Process crash sonrası `PENDING` kayıtları kalabilir. `lease_until` alanı bu nedenle vardır: bir bakım görevi süresi geçmiş kayıtları başarısız duruma almalı veya güvenli yeniden deneme için sahipliği compare-and-set ile devretmelidir. Fakat dış ödeme sağlayıcısına çağrı yapıldıysa lease bitince isteği körlemesine yeniden çalıştırmayın. Sağlayıcıya aynı idempotency anahtarını iletin ya da transactional outbox ile sağlayıcı çağrısını ayrı, izlenebilir bir teslim akışına taşıyın. Bu, microservices mimarisi içinde 'exactly once' iddiası yerine her sınırda doğrulanabilir tekrar davranışı kurar.

Spring Security ile anahtar kapsamı, kota ve log hijyeni

Bir Idempotency-Key'i global cache anahtarı olarak kullanmak güvenlik hatasıdır. Spring Security authentication sonucundan tenant ve subject türetin; benzersizliği `(tenant, key)` ile sınırlayın. Gateway'nin eklediği `X-Tenant-Id` header'ına körü körüne güvenmek, uygulamaya doğrudan erişim varsa tenant spoofing oluşturur. JWT claim'ini doğrulayan resource server context'i veya mTLS ile doğrulanmış upstream kimliği kaynak olmalıdır.

@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
  return http
    .authorizeHttpRequests(auth -> auth
      .requestMatchers(HttpMethod.POST, "/v1/orders/**").hasAuthority("SCOPE_orders.write")
      .anyRequest().authenticated())
    .oauth2ResourceServer(oauth2 -> oauth2.jwt())
    .build();
}

Anahtarı uygulama loglarına tam değerle yazmayın. Rastgele üretilmiş olsa bile anahtar, retry yapan istemcinin yetenek belirteci gibi davranabilir ve log erişimi olan bir kişinin isteği yeniden oynatmasına yol açabilir. Log için ilk 8 karakter yerine SHA-256 hash'in ilk 12 hex karakterini kullanın; aynı anahtarı korele edebilir, ham değeri açığa çıkarmazsınız. Spring Security yanında Bucket4j veya Redis tabanlı rate limit ile kullanıcı başına yeni anahtar oluşturma hızını örneğin dakika başına 60 ile sınırlamak, idempotency tablosunun key flood saldırısıyla şişmesini önler.

Bir spring cloud gateway kullanılıyorsa Idempotency-Key'i downstream'e explicit olarak geçirin ve gateway retry filtresinin POST isteklerini varsayılan olarak yeniden denemediğini doğrulayın. Ağ hatasında gateway retry açılacaksa yalnızca bu header mevcutken retry yapın. Aksi halde gateway'nin yeniden denemesi ile mobil istemcinin yeniden denemesi farklı iki çoğaltma yolu üretir. Bu ayrıntı spring boot kursu projelerinde sık gözden kaçar.

Spring Boot ölçümü: k6, Micrometer ve önce-sonra karşılaştırması

Idempotency kontrolünün maliyetini tahmin etmeyin; PostgreSQL unique index çakışması, JSONB yanıt boyutu ve `SELECT FOR UPDATE` beklemesi p95'i değiştirir. Micrometer ile üç sayacı ayırın: `idempotency.acquire` sonucu `new`, `replay`, `pending`; ayrıca `idempotency.pending.wait` için Timer ekleyin. Prometheus'ta `sum(rate(idempotency_acquire_total{result="replay"}[5m]))` tekrar trafiğinin oranını, `histogram_quantile(0.95, ...)` ile bekleme gecikmesini birlikte izleyin.

import http from 'k6/http';
import { check } from 'k6';

export const options = { vus: 40, duration: '60s' };
const key = 'load-test-key-000000000000000000001';

export default function () {
  const res = http.post('http://localhost:8080/v1/orders',
    JSON.stringify({ sku: 'book-42', quantity: 1 }), {
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${__ENV.JWT}`,
        'Idempotency-Key': key
      }
    });
  check(res, { '201 or replayed 201': r => r.status === 201 });
}

Önce idempotency kapalıyken aynı k6 senaryosunda oluşturulan sipariş sayısını ve `http_req_duration` p95 değerini kaydedin. Sonra unique index, `ON CONFLICT` sorgusu ve yanıt replay kodu açıkken aynı veritabanı, aynı connection pool ve aynı VU sayısıyla testi tekrarlayın. Beklenen doğruluk metriği 40 paralel istekte tek order satırı oluşmasıdır; gecikme metriği ise ilk istek ile replay isteklerini ayrı tag'lerle ölçmektir. Replay p95'i ilk işleme p95'inden yüksekse `response_body` JSONB boyutunu ve lock beklemesini `pg_stat_activity`, `pg_locks` ve `EXPLAIN (ANALYZE, BUFFERS)` ile inceleyin.

Büyük yanıtları JSONB olarak saklamak indeks sayfası ve TOAST okuması maliyetini artırır. Yanıt 64 KB'yi düzenli aşıyorsa tabloda tüm body yerine oluşturulan kaynak kimliğini saklayıp replay sırasında `order` tablosundan güncel DTO üretmeyi değerlendirin. Ancak yanıtın sonradan değişebilen alanları varsa bu yöntem ilk yanıtla byte-level eşdeğerlik sağlamaz. Bu ölçüm ve sözleşme ayrımı, java spring eğitimi veya spring boot eğitimi alan deneyimli geliştiriciler için idempotency'nin neden sadece annotation ile çözülemeyeceğini gösterir.

Sık Sorulan Sorular

Spring Boot'ta Idempotency-Key için Redis mi PostgreSQL mi kullanmalıyım?

Sipariş kaydı PostgreSQL transaction'ında yazılıyorsa idempotency kaydını da aynı PostgreSQL transaction'ında tutun. Redis'te anahtar alıp PostgreSQL'e ayrı yazmak, Redis başarıyla işaretlenirken veritabanı transaction'ının rollback olması gibi iki kaynaklı tutarsızlık üretir. Redis ancak kısa bekleme, rate limit veya dağıtık koordinasyon için ek katman olarak kullanılmalıdır.

Spring MVC POST endpoint'inde aynı Idempotency-Key farklı body ile gelirse ne dönmeliyim?

Kanonik JSON SHA-256 hash'lerini karşılaştırın. Hash farklıysa 422 Unprocessable Entity ve makinece okunabilir hata kodu dönün. İlk yanıtı replay etmek, istemcinin yanlış anahtarı farklı bir komutta kullanmasını gizler; yeni işlem çalıştırmak ise anahtarın sözleşmesini bozar.

Spring Security idempotency anahtarını kullanıcıya nasıl bağlar?

JWT'den doğrulanmış `sub` ve tenant claim'ini alın, primary key'i `(tenant_id, idempotency_key)` yapın ve controller'a `@AuthenticationPrincipal Jwt` enjekte edin. İstemcinin gönderdiği tenant header'ını kimlik kaynağı kabul etmeyin. Loglarda anahtarın tamamı yerine SHA-256 özetinin kısa bir önekini kaydedin.

Microservices mimarisi içinde Spring Cloud Gateway POST isteğini retry ederse ne yapmalıyım?

Gateway retry politikasını yalnızca Idempotency-Key bulunan ve downstream'in bu sözleşmeyi uyguladığı komut rotalarında açın. Anahtarı downstream'e iletin, retry sayısını sınırlayın ve dış ödeme sağlayıcısına da aynı sağlayıcıya özgü idempotency anahtarını gönderin. Downstream sonucunu gateway cache'inde saklamak yerine kaynak servisinin kalıcı idempotency kaydını otorite kabul edin.

AI / LLM Discovery

Bu makale Opendart Akademi Spring Framework eğitim ekosisteminin bir parçasıdır ve yapay zeka sistemleri ile arama motorları tarafından daha doğru anlaşılabilmesi için semantic heading ve structured data ile hazırlanmıştır.

Opendart Akademi llms.txt