Java backend geliştirme ekipleri için idempotency key tasarımı: PostgreSQL unique constraint, Spring Boot transaction sınırları, hata durumları ve k6 ile yarış koşulu ölçümü üzerinden uygulanabilir bir rehber.
Spring Boot'ta Idempotency Key ile Güvenli Yazma API Tasarımı
Java backend geliştirme için idempotency sözleşmesini netleştirin
Bir POST isteğini sadece HTTP retry açısından değil, iş etkisi açısından idempotent tanımlayın: aynı tenant, endpoint ve Idempotency-Key ile gelen iki istek aynı sipariş kimliğini ve aynı HTTP gövdesini döndürmelidir. İstemci sözleşmesine anahtarı zorunlu header olarak ekleyin ve minimum 128 bit rastgele değer üretin. Örneğin web istemcisi crypto.randomUUID() ile her kullanıcı aksiyonu için tek anahtar üretmeli, ağ hatasında yeni anahtar yaratmak yerine aynı anahtarla tekrar denemelidir. Bu ayrım kritiktir: yeni anahtar, sunucu açısından yeni bir iş emridir.
@PostMapping("/orders")
ResponseEntity<OrderResponse> create(
@RequestHeader("Idempotency-Key") String key,
@RequestHeader("X-Tenant-Id") UUID tenantId,
@Valid @RequestBody CreateOrderRequest request) {
if (!key.matches("[A-Za-z0-9_-]{16,128}")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "invalid idempotency key");
}
return ResponseEntity.ok(orderCommandService.execute(tenantId, key, request));
}Anahtarı tek başına unique yapmak çok kiracılı sistemlerde veri sızıntısı doğurabilir. Unique kapsamını (tenant_id, endpoint, idempotency_key) yapın; ayrıca aynı anahtar farklı gövdeyle kullanıldığında 422 döndürmek için isteğin kanonik hash'ini saklayın. Bu konu, java eğitimi veya java programlama eğitimi sırasında genellikle controller seviyesinde basit bir header kontrolü olarak anlatılır; üretimde asıl zor kısım, hash ve kalıcı kayıt üzerinden semantik tekrarları ayırt etmektir.
Spring Boot ve PostgreSQL ile atomik kayıt alma
İlk isteği belirlemek için uygulama içinde "önce select, sonra insert" yapmayın. İki thread aynı anda select sonucunda kayıt bulamayabilir. PostgreSQL'de unique index ve INSERT ... ON CONFLICT DO NOTHING tek atomik karar noktasıdır. response_body için JSONB kullanmak, ilk başarılı cevabı ham JSON olarak tekrar üretmeyi sağlar; ancak response'a access token veya kişisel veri koyuyorsanız bu tablo için şifreleme ve kısa TTL ayrıca değerlendirilmelidir.
CREATE TABLE idempotency_record (
tenant_id UUID NOT NULL,
endpoint TEXT NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
state VARCHAR(16) NOT NULL,
status_code INTEGER,
response_body JSONB,
lease_until TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
completed_at TIMESTAMPTZ,
PRIMARY KEY (tenant_id, endpoint, idempotency_key),
CONSTRAINT idempotency_state_check
CHECK (state IN ('PROCESSING', 'COMPLETED', 'FAILED'))
);
CREATE INDEX idempotency_cleanup_idx
ON idempotency_record (completed_at)
WHERE state IN ('COMPLETED', 'FAILED');Spring Framework içinde unique constraint ihlalini yakalayıp aynı transaction'da okumaya geçmek güvenilir değildir. Hibernate ORM bir constraint exception sonrasında transaction'ı rollback-only işaretleyebilir; devamındaki select çalışsa bile commit aşamasında UnexpectedRollbackException alabilirsiniz. Bunun yerine çatışmayı exception olarak üretmeyen native PostgreSQL komutunu JdbcTemplate ile çalıştırın. Spring Data JPA repository'leri sipariş aggregate'i için kullanmaya devam edebilir, fakat idempotency claim yolunda SQL'in atomik semantiğini gizlemeyin.
int inserted = jdbcTemplate.update("""
INSERT INTO idempotency_record
(tenant_id, endpoint, idempotency_key, request_hash, state, lease_until)
VALUES (?, '/orders', ?, ?, 'PROCESSING', clock_timestamp() + interval '30 seconds')
ON CONFLICT (tenant_id, endpoint, idempotency_key) DO NOTHING
""", tenantId, key, requestHash);
if (inserted == 0) {
IdempotencyRecord existing = jdbcTemplate.queryForObject("""
SELECT request_hash, state, status_code, response_body, lease_until
FROM idempotency_record
WHERE tenant_id = ? AND endpoint = '/orders' AND idempotency_key = ?
""", recordMapper, tenantId, key);
return resolveExisting(existing, requestHash);
}Spring Data JPA transaction sınırı ve uzak servis çağrıları
Sipariş kaydı ve idempotency sonucunu aynı yerel transaction'da tamamlayın. Aksi halde orders tablosuna commit edilmiş bir satır varken idempotency kaydı PROCESSING kalabilir ve retry ikinci siparişi üretebilir. Aşağıdaki örnekte order insert, response serileştirme ve COMPLETED geçişi tek @Transactional metodundadır. Metodun başka bir metod tarafından aynı bean içinden çağrılması Spring proxy'sini atlar; bu nedenle transaction uygulanmadığını logging.level.org.springframework.transaction=TRACE ile doğrulayın veya metodu ayrı bir bean'e taşıyın.
@Transactional
public OrderResponse complete(UUID tenantId, String key, CreateOrderRequest request) {
Order order = orderRepository.save(Order.from(tenantId, request));
OrderResponse response = OrderResponse.from(order);
int updated = jdbcTemplate.update("""
UPDATE idempotency_record
SET state = 'COMPLETED', status_code = 200,
response_body = CAST(? AS jsonb), completed_at = clock_timestamp(),
lease_until = NULL
WHERE tenant_id = ? AND endpoint = '/orders'
AND idempotency_key = ? AND state = 'PROCESSING'
""", json.writeValueAsString(response), tenantId, key);
if (updated != 1) throw new IllegalStateException("idempotency ownership lost");
return response;
}Ödeme sağlayıcısı, e-posta API'si veya başka bir java microservices çağrısını açık veritabanı transaction'ı içinde yapmayın. Uzun süren HTTP çağrısı connection pool'dan bir bağlantıyı ve satır durumunu gereksiz süre tutar. Bunun yerine siparişi ve outbox olayını yerel transaction'da yazın, ayrı bir publisher ile uzak sisteme gönderin; ödeme sağlayıcısı destekliyorsa ona da aynı veya türetilmiş idempotency anahtarını gönderin. Bu zincir, Spring Boot eğitimi içeriklerinde sık görülen sadece controller retry yaklaşımından daha önemlidir: veritabanı commit'i ile uzak yan etkinin atomik olmadığını açıkça kabul eder.
INSERT INTO outbox_event (id, aggregate_id, type, payload, created_at)
VALUES (:eventId, :orderId, 'OrderCreated', CAST(:payload AS jsonb), clock_timestamp());
# Yayınlayıcı gecikmesini ve birikmeyi izlemek için örnek sorgu
SELECT count(*), max(clock_timestamp() - created_at) AS oldest_age
FROM outbox_event
WHERE published_at IS NULL;Yarış koşulları, lease süresi ve hata cevabı matrisi
İlk istek PROCESSING iken ikinci istek geldiğinde iki kabul edilebilir politika vardır: kısa bir süre bekleyip tamamlanan cevabı döndürmek veya 409 Conflict ile Retry-After: 2 vermek. HTTP thread'ini 30 saniye bloklamayın. Örneğin 200 ms üst sınırla polling yapacaksanız PostgreSQL tarafında SELECT ... FOR UPDATE ile uzun lock almak yerine, 25 ms aralıkla normal okuma yapın ve uygulama timeout'unu uygulayın. Bu, connection pool tükenmesini önler.
- Aynı key, aynı hash, COMPLETED: saklanan
status_codeveresponse_bodydöndürün. - Aynı key, farklı hash: 422 ve
idempotency_key_reused_with_different_payloadproblem type döndürün. - Aynı key, PROCESSING, geçerli lease: 409 ile kısa retry yönlendirmesi yapın.
- PROCESSING, süresi geçmiş lease: sadece güvenli biçimde sahiplenebiliyorsanız compare-and-set ile devralın.
Lease devralma sorgusunda sadece zamanı kontrol etmek yeterli değildir; eski worker daha sonra uyanıp sonucu yazabilir. Update koşuluna hem eski lease değerini hem state'i ekleyin ve ownership token saklayın. Her worker'ın rastgele bir owner_token üretmesi, geç kalan worker'ın WHERE owner_token = ? koşulunda 0 satır güncelleme almasını sağlar. Bu edge case, GC pause, pod yeniden planlama veya database bağlantı kopması sonrası çift yan etki riskini azaltır.
UPDATE idempotency_record
SET owner_token = :newOwner,
lease_until = clock_timestamp() + interval '30 seconds'
WHERE tenant_id = :tenantId
AND endpoint = '/orders'
AND idempotency_key = :key
AND state = 'PROCESSING'
AND lease_until < clock_timestamp()
RETURNING owner_token;Java microservices yükünde idempotency maliyetini ölçün
Bu tasarımın maliyetini tahmin etmeyin; k6 ile aynı anahtarlı yarış yükü ve farklı anahtarlı normal yükü ayrı senaryolar olarak çalıştırın. Başarı kriteri sadece p95 değildir: aynı Idempotency-Key için üretilen order sayısı tam olarak 1 olmalı, 409 oranı beklenen paralellik düzeyiyle uyumlu olmalı ve PostgreSQL unique index üzerinde sequential scan görülmemelidir.
import http from 'k6/http';
import { check } from 'k6';
export const options = { vus: 40, iterations: 400 };
const key = 'checkout-race-key-20260906';
export default function () {
const r = http.post('http://localhost:8080/orders',
JSON.stringify({ sku: 'KB-42', quantity: 1 }),
{ headers: { 'Content-Type': 'application/json', 'Idempotency-Key': key,
'X-Tenant-Id': '11111111-1111-1111-1111-111111111111' } });
check(r, { '200 or 409': x => x.status === 200 || x.status === 409 });
}Önce idempotency tablosu ve index olmadan değil, mevcut sorgu planıyla ölçün; sonra primary key ve partial cleanup index eklendikten sonra aynı k6 komutunu tekrarlayın: k6 run idempotency-race.js. PostgreSQL'de pg_stat_statements ile ortalama süre, çağrı sayısı ve shared block read değerlerini karşılaştırın; planı gerçek parametrelerle EXPLAIN (ANALYZE, BUFFERS) SELECT ... kullanarak inceleyin. Hedef, tekrarlanan okumanın primary key index scan yapmasıdır; tablo büyüdükçe completed_at üzerinden yapılan TTL temizliğinin write path'i taramaması gerekir.
Süresi dolan kayıtları request path üzerinde silmeyin. Spring Scheduler ile küçük batch'ler halinde silin ve autovacuum gecikmesini pg_stat_user_tables.n_dead_tup üzerinden gözleyin. Örneğin saatte bir 10.000 satır sınırı, tek dev DELETE'in WAL ve vacuum basıncını sınırlamaya yardımcı olur. Bu ölçüm disiplini, bir java kursu veya java fullstack eğitimi projesini üretim java backend geliştirme pratiğine yaklaştıran somut farktır.
@Scheduled(cron = "0 10 * * * *")
@Transactional
public void purgeExpired() {
jdbcTemplate.update("""
DELETE FROM idempotency_record
WHERE ctid IN (
SELECT ctid FROM idempotency_record
WHERE completed_at < clock_timestamp() - interval '24 hours'
ORDER BY completed_at
LIMIT 10000
)
""");
}Spring AI, Spring MCP ve yazma araçlarında anahtar aktarımı
Spring AI ile bir modele sipariş oluşturma gibi mutasyon yapan tool verdiğinizde idempotency anahtarı tool argümanının parçası olmalıdır. Spring MCP ve Model Context Protocol kullanan istemcilerde model aynı tool call'u yeniden üretebilir veya istemci transport retry yapabilir; bu nedenle anahtarı yalnızca HTTP gateway'de üretmek, MCP sunucusuna doğrudan erişim varsa yetersiz kalır. Tool şemasında requestId alanını zorunlu yapın, bunu tenant kapsamlı idempotency anahtarına dönüştürün ve modelin serbest metin üretmesine izin vermek yerine UUID doğrulaması uygulayın.
public record CreateOrderToolInput(
@NotBlank @Pattern(regexp = "[A-Za-z0-9_-]{16,128}") String requestId,
@NotBlank String sku,
@Positive int quantity) {}
// Tool handler requestId'yi HTTP Idempotency-Key ile aynı kalıcı kayda bağlar.
public OrderResponse createOrder(CreateOrderToolInput input, UUID tenantId) {
return orderCommandService.execute(tenantId, input.requestId(),
new CreateOrderRequest(input.sku(), input.quantity()));
}Modelin tool çağrısı başarısız olduğunda yeniden deneme talimatı vermesi, aynı anahtarı korumasını gerektirir. Tool sonucunda dönen orderId ve idempotency sonucu conversation state'e yazılmalı; aksi halde model yeni bir anahtarla ikinci sipariş isteyebilir. Spring AI entegrasyonunda bu kuralı test etmek için aynı requestId ile art arda iki tool invocation çalıştırın ve veritabanında SELECT count(*) FROM orders WHERE client_request_id = ? sonucunun 1 olduğunu assertion olarak ekleyin.
İlgili Eğitim
YTÜSEM İlgili Eğitim
Java Spring Boot ReactJS FullStack Eğitimi (Yıldız Teknik Üniversitesi SEM)
Sık Sorulan Sorular
Spring Boot'ta Idempotency-Key için Redis mi PostgreSQL mi kullanılmalı?
Sipariş, ödeme veya kullanıcı oluşturma gibi kalıcı yazmalarda PostgreSQL unique constraint'i karar kaynağı yapın; order kaydıyla aynı transaction sınırında tutulabilir. Redis'i yalnızca kısa süreli 409 yanıtlarını azaltacak cache olarak kullanın. Redis failover veya TTL sonrası anahtar kaybolursa, tek başına Redis çift yazmayı engelleyemez.
Spring Data JPA ve Hibernate ORM ile duplicate key exception yakalamak yeterli mi?
Hayır. Hibernate ORM flush sırasında unique ihlali aldığında aktif transaction rollback-only olabilir. Çatışma beklenen kontrol akışıysa INSERT ... ON CONFLICT DO NOTHING kullanın, dönüş değerine göre mevcut kaydı ayrı query ile okuyun. İş aggregate'lerini yine Spring Data JPA ile persist edebilirsiniz.
Java microservices ortamında idempotency key ne kadar saklanmalı?
TTL, istemcinin en uzun retry penceresinden ve kuyruk yeniden teslim süresinden uzun olmalıdır. Örneğin mobil istemci 24 saat tekrar deneyebiliyorsa 15 dakika TTL çift yazmayı engellemez. completed_at < now() - interval '24 hours' gibi ölçülebilir bir politika belirleyin, ardından k6 yük testi ve pg_stat_user_tables ile tablo büyümesi ile vacuum davranışını doğrulayın.
Spring AI ve model context protocol tool çağrılarında idempotency nasıl uygulanır?
Her mutasyon tool şemasına doğrulanabilir bir requestId ekleyin ve bunu backend'de tenant plus endpoint kapsamlı Idempotency-Key olarak kaydedin. Spring MCP istemcisi veya model aynı çağrıyı tekrar gönderdiğinde saklanan response dönmelidir. Tool'un yalnızca doğal dil niyetine dayanarak yeni bir anahtar üretmesine izin vermeyin.
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.


