Milyonlarca Transaction Arasında Aynı İşlemi İki Kez Yapmamak
Yüksek hacimli ödeme sistemlerinde idempotency, concurrency, bakiye güvenliği ve performans mühendisliği
Ödeme sistemlerinde en pahalı hata sistemin çökmesi değildir. Çöken sistem fark edilir, alarm üretir, düzeltilir. Asıl pahalı hata, sistem çalışırken aynı parayı iki kez çekmesidir: log’lar temiz görünür, metrikler yeşildir, ama bir müşterinin hesabından fazladan para çıkmıştır ve bunu genellikle müşterinin kendisi fark eder.
Bu yazıda tek bir senaryoyu — timeout sonrası retry — baştan sona takip edeceğiz. Önce problemi doğru tanımlayacak, sonra veritabanı seviyesinde atomik garantileri, durum makinesini, bakiye korumasını, outbox desenini ve son olarak bunların milyonlarca kayıt altında nasıl performanslı kalacağını konuşacağız. Örnekler .NET 9 ve PostgreSQL üzerinden; prensipler ise teknolojiden bağımsız.
1. Bir ödeme neden iki kez gerçekleşir?
Müşteri 1.000 TL’lik bir ödeme başlatır. API isteği alır, bankaya tahsilat talebini iletir, banka parayı çeker ve başarılı cevabı döner. Tam bu noktada — cevap istemciye ulaşmadan — mobil bağlantı kopar, load balancer timeout’a düşer ya da pod yeniden başlatılır. İstemci yalnızca TIMEOUT görür ve makul olanı yapar: yeniden dener.

Şekil 1. Timeout sonrası retry: API ikinci isteği yeni bir ödeme sanarsa müşteri iki kez ödeme yapar.
Buradaki problem API’nin çalışmaması değil, dağıtık sistemin doğasındaki belirsizliktir. Ağ size iki şeyi asla söyleyemez: isteğin karşı tarafa ulaşıp ulaşmadığını ve cevabın neden gelmediğini. Bu belirsizlik zincirin üç ayrı halkasında ortaya çıkar:
- İstemci → API: Mobil uygulama, frontend ya da merchant entegrasyonu timeout sonrası tekrar dener.
- API → Provider: Banka veya ödeme kuruluşu çağrısı timeout’a düşer; para çekilmiş olabilir de olmayabilir de.
- Broker → Consumer: Mesaj kuyruğu, consumer ack göndermeden çökerse aynı mesajı yeniden teslim eder.
“Exactly-once delivery” dağıtık sistemlerde pratikte mümkün değildir. Ulaşılabilir hedef effectively-once’tır: mesajlar en az bir kez teslim edilir (at-least-once), işleme tarafı ise tekrarları tanıyıp etkisiz hâle getirir (idempotent).
Hedef duplicate request’i engellemek değil, aynı business operation’ın finansal etkisini yalnızca bir kez oluşturmaktır. Retry’lar meşrudur ve gelmeye devam edecektir; sistem bunlara rağmen doğru kalmalıdır.
2. İki ayrı sorun: mükerrer işlem ve bakiye yarışı
Bu konudaki en yaygın kavram karmaşası, iki farklı problemin tek bir problem sanılmasıdır. Aynı ödeme dört kez geliyorsa ihtiyacımız olan şey idempotency’dir. Dört farklı ödeme aynı hesaba eşzamanlı geliyorsa ihtiyacımız olan şey bakiye invariant’ıdır. Biri diğerinin yerine geçmez: mükemmel bir idempotency katmanı, dört farklı ödemenin 2.000 TL’lik bir hesaptan 4.000 TL çekmesini engellemez.

Şekil 2. Solda aynı niyetin tekrarı, sağda farklı niyetlerin aynı kaynak için yarışı.
| Idempotency | Bakiye invariant’ı | |
| Soru | Bu istek daha önce işlendi mi? | Bu işlem bakiyeyi negatife düşürür mü? |
| Kimlik | Idempotency-Key (aynı niyet) | Hesap (account_id) |
| Mekanizma | Unique constraint + durum makinesi | Koşullu UPDATE, row lock veya rezervasyon |
| Hata örneği | Retry iki kez tahsilat yapar | Eşzamanlı ödemeler bakiyeyi aşar |
| Doğru sonuç | 4 istek → 1 etki | 4 × 1.000 TL, 2.000 TL bakiye → en fazla 2 başarı |
3. “Aynı işlem” ne demektir?
Tutar tek başına kimlik değildir: aynı müşteri aynı gün aynı merchant’a iki ayrı 1.000 TL ödeme yapabilir. Tarih, tutar ve müşteri üçlüsünden “aynı işlem” çıkarmaya çalışmak, er ya da geç meşru bir ödemeyi reddetmekle sonuçlanır. Kimliği tahmin etmek yerine istemciden açıkça almak gerekir.
Bunun standart yolu Idempotency-Key header’ıdır. İstemci, işlemi başlatırken benzersiz bir key üretir ve bu key retry’lar boyunca değişmez. Key bir niyeti temsil eder: “bu ödemeyi yapmak istiyorum”. Yeni bir ödeme, yeni bir key demektir.
HTTP
POST /payments
Idempotency-Key: 01J8K2Z8M7Q4R5T6V7W8X9Y0Z1
Content-Type: application/json
{ “invoiceId”: 45982, “amount”: 1000.00, “currency”: “TRY” }
Key’in kapsamı ve payload doğrulaması
Key global olarak değil, istemci kapsamında benzersiz olmalıdır: iki farklı merchant tesadüfen aynı key’i üretebilir. Bu yüzden unique constraint (merchant_id, idempotency_key) üzerine kurulur. Ayrıca aynı key ile farklı bir payload gelmesi bir istemci hatasıdır ve sessizce eski sonucu döndürmek yerine açıkça reddedilmelidir. Bunu tespit etmek için isteğin kanonik bir hash’i saklanır:
C#
public static string CanonicalHash(PaymentRequest r)
{
// Alan sırası, boşluk ve sayı formatından bağımsız, deterministik temsil
var canonical = string.Join('|',
r.InvoiceId.ToString(CultureInfo.InvariantCulture),
r.Amount.ToString("F2", CultureInfo.InvariantCulture),
r.Currency.ToUpperInvariant());
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical)));
}
Hangi durumda ne döner?
IETF’in Idempotency-Key header taslağı ve Stripe gibi büyük sağlayıcıların pratiği, aşağıdaki davranışlar üzerinde büyük ölçüde uzlaşır. Önemli olan, istemcinin her durumda ne yapması gerektiğini cevaptan anlayabilmesidir.
| Durum | Cevap | İstemci ne yapmalı? |
| Yeni key | İşlem yürütülür, sonuç saklanır | — |
| Aynı key + aynı hash, tamamlanmış | Saklanan status + body aynen döner | Sonucu kullan |
| Aynı key, hâlâ işleniyor | 409 Conflict + Retry-After | Bekleyip aynı key ile tekrar dene |
| Aynı key + farklı payload | 422 Unprocessable Content | İstemci hatası; yeni key üret |
| Key eksik (zorunlu endpoint) | 400 Bad Request | Key ekle |
Idempotency key kısa vadeli bir retry kimliğidir. Bunun yanında uzun vadeli bir business identity de gerekir: örneğin UNIQUE (merchant_id, invoice_id, payment_type). Bu ikinci savunma hattı, her retry’da yeni key üreten hatalı bir istemciye karşı korur. Hangi kombinasyonun doğru olduğu teknik değil, domain kararıdır.
4. SELECT sonra INSERT neden yeterli değildir?
İlk akla gelen çözüm, key’in daha önce kullanılıp kullanılmadığını kontrol edip ardından kaydı oluşturmaktır. Tek instance’ta, düşük trafikte bu çalışıyor gibi görünür. Birden fazla instance ve eşzamanlı retry’lar devreye girdiğinde ise iki instance aynı anda “yok” cevabını alır ve ikisi de ödemeyi başlatır.

Şekil 3. Check-then-act arasındaki boşluk race condition üretir; atomik INSERT tek bir kazanan belirler.
Kontrol ile eylem arasındaki her boşluk bir yarış penceresidir. Uygulama katmanındaki lock ifadeleri tek bir process içinde çalışır; birden fazla pod’da işe yaramaz. Benzersizlik veritabanı tarafından, atomik olarak korunmalıdır:
PostgreSQL
CREATE TABLE idempotency_record ( merchant_id bigint NOT NULL, idempotency_key text NOT NULL, request_hash char(64) NOT NULL, status text NOT NULL, -- Processing | Completed | Failed | Unknown operation_id uuid NOT NULL, response_status int, response_body jsonb, locked_until timestamptz, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (merchant_id, idempotency_key) ); INSERT INTO idempotency_record (merchant_id, idempotency_key, request_hash, status, operation_id, locked_until) VALUES (@merchantId, @key, @hash, 'Processing', @operationId, now() + interval '30 seconds') ON CONFLICT (merchant_id, idempotency_key) DO NOTHING RETURNING operation_id;
RETURNING bir satır döndürürse bu instance operasyonun sahibidir. Satır dönmezse kaybetmiştir: yeni ödeme başlatmaz, mevcut kaydı okur ve tablodaki duruma göre cevap verir. .NET tarafında akış şöyle görünür:
C#
public async Task<IResult> HandleAsync(PaymentRequest req, string key, long merchantId, CancellationToken ct)
{
var hash = CanonicalHash(req);
var operationId = Guid.CreateVersion7();
// 1) Sahipliği kısa, ayrı bir transaction'da al ve hemen commit et
var acquired = await _db.TryAcquireAsync(merchantId, key, hash, operationId, ct);
if (!acquired)
{
var existing = await _db.GetRecordAsync(merchantId, key, ct);
if (existing.RequestHash != hash) return Results.UnprocessableEntity();
return existing.Status switch
{
"Completed" => Results.Content(existing.ResponseBody, "application/json",
statusCode: existing.ResponseStatus),
_ => Results.Conflict(), // + Retry-After header
};
}
// 2) Sahibiz: business operation'ı yürüt (bkz. bölüm 5–8)
return await _paymentService.ExecuteAsync(operationId, req, ct);
}
Kritik detay: ON CONFLICT DO NOTHING, kazanan transaction henüz commit olmamışsa kaybedeni bekletir. Kazanan transaction içinde provider çağrısı yapılıyorsa, aynı key’e gelen tüm istekler saniyelerce kilitlenir. Bu yüzden Processing kaydı ayrı ve kısa bir transaction’da yazılıp hemen commit edilmelidir; dış dünya ile konuşma bundan sonra başlar.
5. Processing, Completed ve Unknown durumları
Idempotency kaydı basit bir “var/yok” bayrağı değil, bir durum makinesidir. Asıl zorluk mutlu yolda değil; işlemin ortasında process’in öldüğü, provider’ın cevap vermediği ve ikinci isteğin birincisi bitmeden geldiği durumlardadır.

Şekil 4. Idempotency kaydının durum makinesi. UNKNOWN asla otomatik olarak FAILED sayılmaz.
Provider’a da idempotency key iletin
1. bölümdeki senaryo aslında banka tarafında gerçekleşen bir çift tahsilattır. API seviyesinde ne kadar iyi idempotency kurarsanız kurun, worker provider’dan başarılı cevap alıp bunu kaydetmeden önce çökerse, recovery süreci aynı ödemeyi provider’a yeniden gönderebilir. Zincirin her halkası idempotent olmalıdır:
| Provider yeteneği | Strateji |
| Idempotency key destekliyor | operation_id değerini provider’a key olarak ilet. Retry güvenlidir; provider aynı sonucu döner. |
| Sorgu (status inquiry) destekliyor | Retry’dan önce operation_id / merchant reference ile durumu sorgula. Kayıt varsa yeniden gönderme. |
| İkisini de desteklemiyor | Otomatik retry yapma. İşlemi Unknown olarak işaretle, gün sonu mutabakatı (settlement dosyası) ile çöz. |
Lease ve recovery
Processing durumunda takılı kalan bir kayıt, sahibinin öldüğü anlamına gelebilir. Bunu ayırt etmek için kayda bir lease (locked_until) eklenir. Sahibi çalıştığı sürece lease’i uzatır; süresi dolmuş bir kaydı ise recovery worker devralır. Devralma da atomik olmalıdır, aksi hâlde iki recovery worker aynı kaydı alır:
PostgreSQL
UPDATE idempotency_record SET locked_until = now() + interval '30 seconds' WHERE merchant_id = @merchantId AND idempotency_key = @key AND status = 'Processing' AND locked_until < now() -- yalnızca süresi dolmuş lease RETURNING operation_id;
Devralan worker işlemi körlemesine tekrar etmez: önce provider’a operation_id ile sorar. Provider sonucu bilinmiyorsa kayıt Unknown olarak kalır ve mutabakat sürecine devredilir. Müşteriye “başarısız” demek yerine “işleminiz kontrol ediliyor” demek, yanlış bir “başarısız” mesajı yüzünden müşterinin tekrar ödeme yapmasından her zaman daha ucuzdur.
6. Bakiye kontrolünü atomik yapın
“Bakiyeyi oku → yeterliyse azalt” iki ayrı işlemdir ve aralarındaki boşluk, 4. bölümdeki race condition’ın birebir aynısıdır. 2.000 TL bakiyeli hesaba eşzamanlı gelen dört 1.000 TL’lik istek, dördü de 2.000 TL okuyup dördü de “yeterli” kararı verebilir. Çözüm, kontrol ile değişikliği tek bir koşullu SQL ifadesinde birleştirmektir:
PostgreSQL
UPDATE account SET balance = balance - @amount, updated_at = now() WHERE account_id = @accountId AND balance >= @amount RETURNING balance; -- 1 satır: başarılı (yeni bakiye döner) -- 0 satır: yetersiz bakiye veya hesap yok
PostgreSQL bu UPDATE sırasında satırı kilitler; eşzamanlı ikinci UPDATE ilkinin commit’ini bekler ve güncel değer üzerinden balance >= @amount koşulunu yeniden değerlendirir. EF Core 7+ ile aynı ifade, entity yüklemeden yazılabilir:
C# / EF Core
var affected = await db.Accounts .Where(a => a.Id == accountId && a.Balance >= amount) .ExecuteUpdateAsync(s => s .SetProperty(a => a.Balance, a => a.Balance - amount) .SetProperty(a => a.UpdatedAt, DateTime.UtcNow), ct); if (affected == 0) return DebitResult.InsufficientFunds;
Son savunma hattı olarak veritabanında bir CHECK (balance >= 0) kısıtı tanımlamak, uygulama kodundaki bir hatanın sessizce negatif bakiye üretmesini engeller. Alternatif yaklaşımların her birinin yeri vardır:
| Yaklaşım | Ne zaman? | Dikkat |
| Koşullu UPDATE | Tek satırlık, basit debit. Varsayılan tercih. | Birden fazla tabloyu kapsayan kurallarda yetersiz kalabilir. |
| SELECT … FOR UPDATE | Karar vermeden önce birden fazla alanı okumak gerekiyorsa. | Lock süresi = transaction süresi. Kısa tutun. |
| Optimistic (version / xmin) | Çakışmanın nadir olduğu, okuma ağırlıklı senaryolar. | Sıcak hesaplarda retry fırtınası üretir. |
| Rezervasyon | Provider çağrısı gibi uzun süren akışlar. | Release ve timeout süreçleri tasarlanmalı (bölüm 7). |
External API çağrısını asla row lock açıkken yapmayın. Provider’ın 5 saniyelik yavaşlığı, o hesaba gelen tüm işlemleri 5 saniye bekletir; connection pool tükenir ve sorun tek hesaptan tüm sisteme yayılır.
7. Ledger ve rezervasyonun rolü
account.balance alanı tek başına bir gerçeklik kaynağı değil, bir özettir. Finansal sistemlerde gerçeklik, değiştirilemez (immutable) ledger kayıtlarıdır: her hareket bir satırdır, hatalar silinerek ya da güncellenerek değil, ters kayıt (reversal) ile düzeltilir. Bu, hem denetim izi sağlar hem de bakiyenin herhangi bir anda ledger’dan yeniden hesaplanabilmesini mümkün kılar.
Provider çağrısı gibi saniyeler sürebilen akışlarda bakiyeyi hemen düşmek yerine rezervasyon yapmak daha güvenlidir. Hesap iki tutara ayrılır: kullanılabilir (Available) ve ayrılmış (Reserved).

Şekil 5. Rezervasyon yaşam döngüsü: provider sonucuna göre finalize, release veya reconcile.
PostgreSQL
CREATE TABLE balance_reservation ( reservation_id uuid PRIMARY KEY, operation_id uuid NOT NULL UNIQUE, -- aynı operasyon iki kez rezerve edemez account_id bigint NOT NULL, amount numeric(18,2) NOT NULL CHECK (amount > 0), status text NOT NULL, -- Active | Finalized | Released expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now() );
Rezervasyonun kendisi de idempotent olmalıdır: operation_id üzerindeki unique constraint, recovery sırasında aynı operasyonun iki kez rezerve edilmesini engeller. Unknown durumundaki bir ödemenin rezervasyonu serbest bırakılmaz; aksi hâlde provider tarafında gerçekleşmiş bir tahsilatın karşılığı olan bakiye tekrar harcanabilir hâle gelir.
8. Outbox ve idempotent consumer
Veritabanına yazıp ardından broker’a mesaj göndermek, iki ayrı sisteme yapılan iki ayrı yazmadır. Veritabanı commit olur ama mesaj gönderilemezse ödeme asla işlenmez; mesaj gönderilir ama commit başarısız olursa var olmayan bir ödeme işlenir. Transactional outbox deseni, mesajı da aynı veritabanı transaction’ında bir tabloya yazarak bu ikilemi çözer.

Şekil 6. Yerel state ve outbox mesajı tek transaction’da; dış dünya ile iletişim transaction dışında.
C#
await using var tx = await conn.BeginTransactionAsync(ct); await repo.MarkIdempotencyAsync(merchantId, key, operationId, tx, ct); await repo.CreatePaymentAsync(payment, tx, ct); await repo.ReserveBalanceAsync(operationId, accountId, amount, tx, ct); await repo.InsertOutboxAsync(new OutboxMessage( MessageId: Guid.CreateVersion7(), OperationId: operationId, Type: "PaymentRequested", Payload: JsonSerializer.Serialize(payment)), tx, ct); await tx.CommitAsync(ct); // ya hepsi ya hiçbiri
Outbox worker mesajları broker’a iletir; broker ise at-least-once teslimat garantisi verir. Yani consumer aynı mesajı birden fazla kez görebilir ve bunu tolere etmek zorundadır. Bunun için consumer tarafında bir inbox tablosu tutulur ve mesajın işlendiği bilgisi, işin kendisiyle aynı transaction’da yazılır:
PostgreSQL
INSERT INTO processed_message (consumer, message_id)
VALUES ('payment-worker', @messageId)
ON CONFLICT DO NOTHING;
-- 0 satır → mesaj daha önce işlenmiş: ack gönder, başka bir şey yapma
-- 1 satır → işi yap, aynı transaction'da commit et
9. Peki milyonlarca işlem performans sorunu çıkarmaz mı?
Evet, yanlış tasarlanırsa çıkarır. Ancak “milyonlarca satır” ile “her istekte milyonlarca satır taramak” aynı şey değildir. B-tree unique index, logaritmik derinliği sayesinde 100 milyon satırda bile birkaç sayfa okumasıyla ilgili anahtara ulaşır. Kapasiteyi belirleyen şey satır sayısı değil; peak TPS, index yazma maliyeti, sıcak key’ler, sıcak hesaplar, lock bekleme süreleri, WAL hacmi ve disk I/O’dur.
| Günlük hacim | Ortalama TPS | Gerçekçi peak (×20–50) |
| 1.000.000 işlem/gün | ≈ 11,6 TPS | ≈ 230 – 580 TPS |
| 10.000.000 işlem/gün | ≈ 115,7 TPS | ≈ 2.300 – 5.800 TPS |
| Kampanya / maaş günü | — | Birkaç dakikalık 10.000+ TPS burst |
Ortalamalar yanıltıcıdır. Sistemi zorlayan şey, maaş günü sabahı ya da bir kampanyanın ilk beş dakikasıdır. Kapasite planlaması ortalamaya değil, peak’e ve aynı kaynağa yığılmaya göre yapılmalıdır.
Farklı key ile aynı key aynı yük değildir
Farklı key’lere gelen istekler B-tree’nin farklı noktalarına yazılır ve büyük ölçüde paralel ilerler. Aynı idempotency key’e aynı anda gelen yüzlerce istek ise aynı index girdisi üzerinde sıraya girer. Bu bekleme bir performans hatası değil, doğruluğun bedelidir. Optimizasyon beklemeyi ortadan kaldırmak değil, onu kısaltmaktır: Processing kaydını hızlıca commit etmek, duplicate’leri erken 409 ile geri çevirmek ve uzun transaction tutmamak.
Key formatı index performansını belirler
Rastgele UUIDv4 key’ler, her insert’i B-tree’nin rastgele bir yaprak sayfasına gönderir. Tablo büyüdükçe bu sayfaların çoğu bellekte olmaz; her insert bir disk okuması, sık page split ve daha fazla WAL (özellikle checkpoint sonrası full-page write’lar) üretir. Zaman sıralı key’ler (UUIDv7, ULID) ise insert’leri index’in sağ ucunda toplar.

Şekil 7. Rastgele ve zaman sıralı key’lerin B-tree üzerindeki yazma deseni.
.NET 9 ile gelen Guid.CreateVersion7() ve PostgreSQL 18’deki yerleşik uuidv7() fonksiyonu, bu tercihi ek bir kütüphane gerektirmeden mümkün kılar. İstemcinin ürettiği key’in formatını kontrol edemiyorsanız bile, kendi ürettiğiniz operation_id, message_id ve ledger kimliklerinde zaman sıralı ID kullanmak, yüksek hacimde ölçülebilir fark yaratır.
Redis: hız katmanı, gerçeklik kaynağı değil

Şekil 8. Redis yalnızca doğrulanmış, tamamlanmış sonuçlar için erken cevap verir; karar veritabanındadır.
Redis’i idempotency’nin önüne koymak cazip gelir, ama sınırları net çizilmelidir. Redis cache hit, yalnızca güvenilir ve tamamlanmış bir sonuç için erken cevap vermelidir. Redis MISS “işlem yenidir” anlamına gelmez: key expire olmuş, failover sırasında kaybolmuş ya da henüz yazılmamış olabilir. Redis’e yalnızca veritabanı commit’inden sonra yazılır ve finansal doğruluğun tek garantisi asla Redis olmaz. Cache eklemek, ölçülmüş bir duplicate oranı ve gerçek bir veritabanı yükü ile gerekçelendirilmelidir; çoğu sistemde PostgreSQL unique index’i tek başına yeterlidir.
Sıcak hesaplar (hot account)
Bir merchant’ın tahsilat hesabı ya da bir sistem hesabı, saniyede yüzlerce işlemin hedefi olabilir. Tek bir satır üzerindeki koşullu UPDATE’ler sıraya girer ve hesabın throughput’u, tek bir satır kilidinin ne kadar hızlı el değiştirebildiğiyle sınırlanır. Yaygın çözümler:
- Alt hesaplara bölme: Bakiye N adet alt satıra dağıtılır; her işlem rastgele ya da hash ile bir alt satıra yönlendirilir. Toplam bakiye, alt satırların toplamıdır.
- Append-only ledger + periyodik özet: Alacak (credit) yönlü hareketler için bakiye satırı güncellenmez; ledger’a satır eklenir ve bakiye kısa aralıklarla toplanır. Borç (debit) yönünde bu yaklaşım ancak overdraft toleransı varsa uygulanabilir.
- Batching: Aynı hesaba gelen işlemler milisaniyelik pencerelerde gruplanıp tek bir UPDATE ile uygulanır.
Tablo yaşam döngüsü ve partitioning
Teknik idempotency kayıtlarının ömrü, tanımlı retry penceresi kadardır (tipik olarak 24–72 saat). Bu süreden sonra silinebilirler; kalıcı business uniqueness ve finansal audit kayıtları ise ayrı tablolarda, ayrı yaşam döngüsüyle tutulur. Silme stratejisi seçilirken PostgreSQL’in önemli bir kısıtı akılda tutulmalıdır: partitioned bir tablodaki unique constraint, partition key’i içermek zorundadır.
| Strateji | Artı | Eksi |
| Partition yok + batch DELETE | Global unique doğal olarak çalışır; en basit model. | Büyük DELETE’ler bloat ve vacuum yükü üretir; küçük batch’lerle silin. |
| Tarihe göre range partition | Eski partition’ı DROP etmek anlık ve ucuz. | UNIQUE(key, created_at) farklı günlerdeki aynı key’i yakalamaz. |
| Key’e göre hash partition | Unique constraint doğru çalışır, yük dağılır. | Partition drop ile temizlik yapılamaz; yine DELETE gerekir. |
Tarih bazlı partitioning tercih ediliyorsa bir çözüm, partition anahtarını key’in kendisinden türetmektir: UUIDv7 ve ULID, ilk 48 bitinde zaman damgası taşır. Partition sütunu bu zaman damgasından deterministik olarak hesaplanırsa UNIQUE(key, key_ts) fiilen UNIQUE(key) ile eşdeğer olur. Bu durumda istemci saatine güvenilmemeli; zaman damgası kabul penceresinin dışındaki key’ler reddedilmelidir.
Ölçmeden optimize etmeyin
Yukarıdaki optimizasyonların hiçbiri körlemesine uygulanmamalıdır. Önce ölçün; aşağıdaki metrikler hem kapasite planlaması hem de üretimdeki anomalileri erken yakalamak için temel setidir:
| Metrik | Neden önemli? |
| peak_tps, p95/p99_latency | Ortalama değil, en kötü anlardaki davranışı gösterir. |
| duplicate_ratio | Retry davranışını ve Redis gibi bir katmanın gerekip gerekmediğini gösterir. |
| unique_conflict_rate | Ani artış, hatalı bir istemciyi ya da retry fırtınasını işaret eder. |
| db_lock_wait, hot_account_rate | Sıcak key ve sıcak hesap problemlerinin erken uyarısıdır. |
| unknown_count, reconcile_age | Çözülmemiş belirsiz işlemler doğrudan finansal risktir. |
| outbox_backlog, index_size | Asenkron hattın ve depolamanın sağlığını gösterir. |
10. Failure-first test planı
Idempotency mutlu yolda test edilemez; mutlu yolda zaten her şey çalışır. Testler, hatanın tam olarak en kötü anda gerçekleştiği senaryolardan başlamalıdır. Testcontainers ile gerçek bir PostgreSQL, Toxiproxy ile ağ kesintisi ve gecikme, k6 ile eşzamanlı yük üretmek bu senaryoları otomatikleştirmeyi kolaylaştırır.
| Senaryo | Nasıl simüle edilir? | Beklenen sonuç |
| Aynı key, 100 sıralı istek | Döngü içinde aynı key ile POST | Tek finansal etki, 99 kez aynı cevap |
| Aynı key, 100 paralel istek | Task.WhenAll / k6 ile eşzamanlı | Tek finansal etki; diğerleri 409 ya da aynı cevap |
| Aynı key, farklı payload | Tutar değiştirilmiş ikinci istek | 422; ilk işlem etkilenmez |
| 4 farklı key, 2.000 TL bakiye | 4 × 1.000 TL eşzamanlı debit | En fazla 2 başarı, bakiye ≥ 0 |
| Commit sonrası worker crash | Provider çağrısından önce process kill | Recovery lease’i devralır, tek tahsilat |
| Provider success sonrası timeout | Toxiproxy ile cevabı düşür | Unknown → reconcile; ikinci tahsilat yok |
| Broker mesajı 10 kez iletilir | Aynı mesajı tekrar publish et | Inbox tekrarları yutar; tek işlem |
C# / xUnit
[Fact]
public async Task Same_key_in_parallel_produces_single_charge()
{
var key = Guid.CreateVersion7().ToString();
var request = new PaymentRequest(InvoiceId: 45982, Amount: 1000m, Currency: "TRY");
var responses = await Task.WhenAll(Enumerable.Range(0, 100)
.Select(_ => _client.PostPaymentAsync(request, idempotencyKey: key)));
Assert.All(responses, r => Assert.Contains(r.StatusCode,
new[] { HttpStatusCode.OK, HttpStatusCode.Created, HttpStatusCode.Conflict }));
Assert.Equal(1, await _provider.ChargeCountAsync(invoiceId: 45982));
Assert.Equal(1, await _db.LedgerEntryCountAsync(invoiceId: 45982));
}
11. Mimari kararların kısa özeti
- Idempotency key: kısa vadeli retry kimliği; istemci kapsamında benzersiz, payload hash’i ile doğrulanır.
- Business unique key: uzun vadeli domain invariant’ı; hatalı istemcilere karşı ikinci savunma hattı.
- Unique index: eşzamanlı insert yarışının son savunması; Processing kaydı kısa transaction’da commit edilir.
- Durum makinesi + lease: crash ve timeout sonrası güvenli devralma; Unknown asla Failed sayılmaz.
- Provider’a key iletimi: zincirin her halkası idempotent olmalı; dış dünyadaki belirsizlik sorgu ve mutabakatla çözülür.
- Atomic debit / reservation: bakiye invariant’ı; dış çağrı sırasında asla row lock tutulmaz.
- Immutable ledger: gerçeklik kaynağı; düzeltmeler reversal ile yapılır.
- Outbox + inbox: yerel state ile event’in tutarlılığı ve at-least-once teslimata dayanıklılık.
- Redis: yalnızca ölçülen ihtiyaca yönelik opsiyonel hızlandırma; asla tek garanti değil.
Duplicate prevention yalnızca bir veritabanı problemi değildir. Önce “aynı business operation” kavramını doğru tanımlama, sonra bu tanımı zincirin her halkasında — istemci, API, veritabanı, broker ve provider — tutarlı biçimde koruma problemidir.
Kaynaklar ve ileri okuma
- AWS Builders’ Library — Making retries safe with idempotent APIs — https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/
- Brandur Leach — Implementing Stripe-like Idempotency Keys in Postgres — https://brandur.org/idempotency-keys
- Stripe — Designing robust and predictable APIs with idempotency — https://stripe.com/blog/idempotency
- IETF — The Idempotency-Key HTTP Header Field (draft) — https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
- microservices.io — Transactional Outbox pattern — https://microservices.io/patterns/data/transactional-outbox.html
- PostgreSQL — INSERT / ON CONFLICT — https://www.postgresql.org/docs/current/sql-insert.html
- PostgreSQL — Explicit Locking — https://www.postgresql.org/docs/current/explicit-locking.html
- PostgreSQL — Table Partitioning — https://www.postgresql.org/docs/current/ddl-partitioning.html
- PostgreSQL — UUID Functions — https://www.postgresql.org/docs/current/functions-uuid.html
- Microsoft — Guid.CreateVersion7 — https://learn.microsoft.com/en-us/dotnet/api/system.guid.createversion7
- Microsoft — EF Core Concurrency — https://learn.microsoft.com/en-us/ef/core/saving/concurrency