• 26.08.2026 19:53:30
  • Admin Admin

Java backend geliştirme ekiplerinde istemci tekrar denemeleri çift tahsilata dönüşebilir. Bu rehber, Spring Boot'ta Idempotency-Key sözleşmesini PostgreSQL, Redis ve transaction sınırlarıyla dayanıklı biçimde kurar.

Spring Boot'ta Idempotency Key ile Güvenli Ödeme API Tasarımı

Java backend geliştirmede idempotency sözleşmesini netleştirin

Bir POST isteğini idempotent yapmak, her isteğe rastgele bir UUID eklemek değildir. Sunucu, aynı Idempotency-Key ile gelen iki isteğin aynı iş sonucunu üretmesini ve ilk başarılı HTTP cevabını tekrar döndürmesini garanti etmelidir. Anahtarı kullanıcı veya tenant kimliğiyle bileşik hale getirin: tenantId + ':' + idempotencyKey. Aksi halde iki farklı tenant'ın aynı UUID'si birbirinin cevabını görebilir. İstemci anahtarı üretmeli, gateway değil; çünkü mobil uygulama veya tarayıcı bağlantı koptuğunda aynı anahtarla retry yapabilmelidir.

POST /payments HTTP/1.1
Idempotency-Key: 5f6de8fd-919f-4a4a-a3dc-0a664c4767ab
Content-Type: application/json

{
  "orderId": "ord_481",
  "amount": 24990,
  "currency": "TRY"
}

Anahtar tekrar kullanılırken gövdenin değişmesini reddedin. Canonical JSON üzerinden SHA-256 hesaplamak, alan sırasından kaynaklanan sahte uyuşmazlıkları önler. Jackson ile SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS açıp sayısal değerlerin ölçeğini de iş kuralıyla normalize edin. Örneğin 10 ve 10.0 ödeme alanında aynı anlamdaysa ikisini de minor unit integer'a dönüştürün. Salt ham request byte'larını hashlemek bu edge case'te gereksiz 409 üretir.

ObjectMapper canonicalMapper = JsonMapper.builder()
    .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
    .build();

JsonNode body = canonicalMapper.readTree(requestBody);
String canonical = canonicalMapper.writeValueAsString(body);
String payloadHash = HexFormat.of().formatHex(
    MessageDigest.getInstance("SHA-256")
        .digest(canonical.getBytes(StandardCharsets.UTF_8)));

if (!storedHash.equals(payloadHash)) {
    throw new ResponseStatusException(HttpStatus.CONFLICT,
        "Idempotency-Key başka bir istek gövdesiyle kullanıldı");
}

Spring Framework transaction sınırında kalıcı cevap kaydı tutun

Redis tek başına doğruluk kaynağı olmamalıdır. Redis TTL'i dolabilir, failover sırasında veri kaybedebilir veya uygulama ilk işlemi tamamladıktan sonra cevabı yazamadan çökebilir. Tahsilat ve idempotency sonucu aynı PostgreSQL transaction'ında commit edilirse, benzersiz indeks eşzamanlı iki isteği veritabanı seviyesinde serileştirir. Spring Framework tarafında bunu TransactionTemplate ile açıkça göstermek, sadece servis metoduna eklenmiş geniş kapsamlı @Transactional anotasyonundan daha denetlenebilirdir.

create table idempotency_result (
  tenant_id uuid not null,
  idempotency_key varchar(128) not null,
  payload_hash char(64) not null,
  status_code integer not null,
  response_body jsonb not null,
  created_at timestamptz not null default now(),
  primary key (tenant_id, idempotency_key)
);

Uygulama önce aynı transaction içinde placeholder satırını eklemeyi dener. PostgreSQL'de INSERT ... ON CONFLICT DO NOTHING, rakip transaction henüz commit etmemiş olsa bile benzersiz indeks sonucunu bekler. İlk işlem commit ederse ikinci istek 0 satır etkilenmiş olarak geri döner ve saklanan cevabı okur; ilk işlem rollback ederse ikinci istek kendi insert'ini yaparak devam eder. Bu mekanizma uygulama içi synchronized kilidinden farklı olarak birden fazla pod üzerinde de geçerlidir.

int inserted = jdbcTemplate.update("""
  insert into idempotency_result
    (tenant_id, idempotency_key, payload_hash, status_code, response_body)
  values (?, ?, ?, 102, '{}'::jsonb)
  on conflict (tenant_id, idempotency_key) do nothing
  """, tenantId, key, payloadHash);

if (inserted == 0) {
  StoredResult prior = jdbcTemplate.queryForObject("""
    select payload_hash, status_code, response_body
    from idempotency_result
    where tenant_id = ? and idempotency_key = ?
    """, storedResultMapper, tenantId, key);
  verifySamePayload(prior.payloadHash(), payloadHash);
  return ResponseEntity.status(prior.statusCode()).body(prior.responseBody());
}

PaymentResponse response = paymentService.charge(command);
jdbcTemplate.update("""
  update idempotency_result
  set status_code = ?, response_body = cast(? as jsonb)
  where tenant_id = ? and idempotency_key = ?
  """, 201, objectMapper.writeValueAsString(response), tenantId, key);
return ResponseEntity.status(201).body(response);

Buradaki kritik sınır şudur: banka veya PSP HTTP çağrısını uzun süren veritabanı transaction'ı içinde tutmayın. Bu, connection pool'daki bağlantıyı ağ gecikmesi boyunca işgal eder ve transaction rollback olduğunda dış sistemdeki tahsilatı geri almaz. Sağlayıcı kendi idempotency anahtarını destekliyorsa aynı anahtarı iletin; desteklemiyorsa önce yerel payment intent kaydını commit edip sağlayıcı çağrısını ayrı bir worker'da yürütün.

Redis ile in-flight istekleri sınırlayın, kalıcılığı Redis'e bırakmayın

Redis burada doğruluk için değil, aynı anahtarla gelen yüksek sayıda eşzamanlı isteğin PostgreSQL benzersiz indeksinde kuyruk oluşturmasını azaltmak için kullanılır. Lettuce veya Spring Data Redis ile SET key token NX PX kullanın. Kilit değeri mutlaka rastgele token olmalıdır; yalnızca anahtara göre DEL yapmak, lease'i biten eski worker'ın yeni worker kilidini silmesine yol açar.

String lockKey = "idem:lock:" + tenantId + ":" + key;
String token = UUID.randomUUID().toString();
Boolean acquired = redisTemplate.opsForValue()
    .setIfAbsent(lockKey, token, Duration.ofSeconds(15));

if (Boolean.FALSE.equals(acquired)) {
  return ResponseEntity.status(409)
      .header("Retry-After", "2")
      .body(Map.of("code", "REQUEST_IN_PROGRESS"));
}

try {
  return transactionTemplate.execute(status -> paymentCommand.execute(...));
} finally {
  redisTemplate.execute(new DefaultRedisScript<Long>("""
    if redis.call('get', KEYS[1]) == ARGV[1] then
      return redis.call('del', KEYS[1])
    end
    return 0
    """, Long.class), List.of(lockKey), token);
}

15 saniyelik lease'i körlemesine seçmeyin. OpenTelemetry histogramından payment.command.duration p99 değerini ölçün ve lease'i p99 plus makul bir jitter payı olarak belirleyin. Örneğin p99 1.8 saniye iken 15 saniye çoğu durumda yeterlidir, fakat PSP timeout değeri 30 saniyeyse lease'in erken bitmesi iki worker'ın aynı dış çağrıyı başlatmasına neden olur. Dış sağlayıcının idempotency garantisi yoksa lease yenileme yerine kalıcı payment intent state machine'i gerekir.

Spring Boot Actuator ile /actuator/metrics üzerinden http.server.requests değil, özel sayaçları izleyin: idempotency.replay, idempotency.in_flight_conflict ve idempotency.hash_mismatch. Hash mismatch artışı genellikle istemcinin retry sırasında sepeti yeniden oluşturduğunu veya proxy'nin request gövdesini değiştirdiğini gösterir; bu durum 5xx retry metriklerinden ayırt edilmelidir.

Hibernate ORM ve Spring Data JPA ile yanlış kilit stratejilerinden kaçının

Hibernate ORM ile idempotency tablosunu entity olarak modellemek mümkündür, ancak bu yolun sık hatası önce findById, sonra save yapmaktır. İki transaction da boş sonucu okuyabilir ve yarış ancak flush aşamasında görünür. Bu nedenle idempotency edinimini Spring Data JPA'nın sorgu türetme mekanizmasına bırakmak yerine, benzersiz constraint'i önceleyen native insert ile yapın. Constraint ihlalini business exception'a çevirmek gerekiyorsa DataIntegrityViolationException yakalandıktan sonra transaction'ı yeniden kullanmayın; Spring transaction'ı rollback-only işaretlemiş olabilir.

@Repository
interface IdempotencyRepository extends JpaRepository<IdempotencyResult, IdempotencyId> {
  @Modifying
  @Query(value = """
    insert into idempotency_result
      (tenant_id, idempotency_key, payload_hash, status_code, response_body)
    values (:tenantId, :key, :hash, 102, '{}'::jsonb)
    on conflict (tenant_id, idempotency_key) do nothing
    """, nativeQuery = true)
  int tryCreate(UUID tenantId, String key, String hash);
}

Spring Data JPA kullanırken @Version optimistic locking'i idempotency edinim problemi için tek çözüm sanmayın. Version kontrolü mevcut satır güncellemesinde işe yarar, satır henüz yokken iki insert yarışını çözmez. Ayrıca response JSON'u entity alanında lazy ilişkilerden türetmeyin; transaction dışında serialize edildiğinde LazyInitializationException veya beklenmeyen ek sorgular oluşabilir. Cevap DTO'sunu transaction içindeyken JSON olarak saklamak daha öngörülebilirdir.

Saklama süresini iş gereksiniminden türetin. Kart ağ geçidi 24 saatlik tekrar penceresi kullanıyorsa created_at < now() - interval '30 days' ile saatlik silme yapılabilir, ancak hemen hard delete yerine tarih bazlı partition kullanmak autovacuum yükünü azaltır. PostgreSQL'de aylık partition ve eski partition'ı DROP TABLE idempotency_result_2026_06 ile kaldırmak, milyonlarca satır için tek tek DELETE çalıştırmaktan belirgin biçimde daha kontrollüdür.

Java microservices ve Spring AI akışlarında idempotency sınırları

Java microservices mimarisinde HTTP katmanındaki anahtarı Kafka mesaj kimliği yerine geçirmeyin. Bir ödeme komutu kabul edildiğinde domain kaydında idempotencyKey saklanır; daha sonra üretilen event, ayrı ve değişmez bir eventId taşır. Tüketici tarafında processed_event(event_id primary key) tablosuna insert-first yaklaşımı uygulanır. Böylece at-least-once teslimatta aynı event tekrar geldiğinde tüketici yeniden e-posta göndermek veya ledger satırı eklemek zorunda kalmaz.

Bu konu bir java eğitimi veya java programlama eğitimi içinde yalnızca HTTP retry örneğiyle sınırlanmamalı. İyi bir java kursu, Spring Boot eğitimi ve java fullstack eğitimi akışında tarayıcının fetch retry davranışını, mobil ağ kopmalarını ve backend'in kalıcı sonucunu birlikte test ettirmelidir. Testcontainers PostgreSQL ve Redis ile iki paralel request gönderip yalnızca bir payment satırı oluştuğunu doğrulayın.

@Test
void sameKeyCreatesOnePayment() throws Exception {
  String key = "test-key-42";
  ExecutorService pool = Executors.newFixedThreadPool(2);
  List<Future<Integer>> statuses = pool.invokeAll(List.of(
      () -> postPayment(key),
      () -> postPayment(key)
  ));

  assertThat(statuses.stream().map(Future::get)).containsOnly(201);
  assertThat(jdbcTemplate.queryForObject(
      "select count(*) from payment where order_id = 'ord_481'", Integer.class))
      .isEqualTo(1);
}

Spring AI ile bir modelin araç çağrısı ödeme, rezervasyon veya ticket açma işlemi başlatıyorsa modelin ürettiği doğal dil talimatını anahtar olarak kullanmayın. Spring MCP veya doğrudan model context protocol entegrasyonunda tool input'a sunucunun doğruladığı operationId alanını ekleyin ve aynı operationId'yi idempotency key olarak map edin. Model yeniden deneme yapabilir, araç çağrısını paralel üretebilir veya timeout sonrası sonucu bilmeden tekrar gönderebilir. Bu nedenle Spring AI tool handler'ı, normal REST endpoint'iyle aynı kalıcı idempotency servisinden geçmelidir.

Sık Sorulan Sorular

Spring Boot eğitiminde Idempotency-Key için Redis yeterli mi?

Hayır. Redis, in-flight tekrarları azaltmak için SET NX PX ile kullanılabilir; başarılı iş sonucu PostgreSQL gibi kalıcı bir veritabanında saklanmalıdır. Redis TTL veya failover sonrası aynı isteğin tekrar işlenmesini engelleyemez.

Spring Data JPA ile idempotency kaydında @Version kullanmalı mıyım?

@Version mevcut satır güncelleme çakışmalarını algılar, ancak iki isteğin aynı anda olmayan bir satırı insert etmesini tek başına engellemez. tenant_id ve idempotency_key üzerinde primary key veya unique constraint tanımlayın, edinim için INSERT ON CONFLICT DO NOTHING çalıştırın.

Java microservices ortamında Idempotency-Key Kafka event id ile aynı olabilir mi?

Genellikle olmamalıdır. Idempotency-Key istemci komutunun tekrarını temsil eder; eventId ise yayımlanan olayın kimliğidir. Tüketici, processed_event tablosunda eventId ile deduplikasyon yapmalı; komut kaydı kendi idempotency anahtarını korumalıdır.

Spring AI ve Spring MCP araç çağrılarında model context protocol idempotency nasıl uygulanır?

Tool input şemasına doğrulanabilir operationId ekleyin, tenant kimliğiyle bileşik anahtar üretin ve tool handler'ı REST endpoint'iyle aynı idempotency servisinden geçirin. Modelin tekrar ürettiği metni anahtar yapmak yerine, istemci veya orkestratörün ürettiği UUID kullanın.

AI / LLM Discovery

Bu makale Opendart Akademi Java 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