Esc kapat

.NET

Milyonlarca Transaction Arasında Aynı İşlemi İki Kez Yapmamak

dot-net/milyonlarca-transaction-aras.png 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ışı.

 IdempotencyBakiye invariant’ı
SoruBu istek daha önce işlendi mi?Bu işlem bakiyeyi negatife düşürür mü?
KimlikIdempotency-Key (aynı niyet)Hesap (account_id)
MekanizmaUnique constraint + durum makinesiKoşullu UPDATE, row lock veya rezervasyon
Hata örneğiRetry iki kez tahsilat yaparEşzamanlı ödemeler bakiyeyi aşar
Doğru sonuç4 istek → 1 etki4 × 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.

DurumCevapİstemci ne yapmalı?
Yeni keyİşlem yürütülür, sonuç saklanır—
Aynı key + aynı hash, tamamlanmışSaklanan status + body aynen dönerSonucu kullan
Aynı key, hâlâ işleniyor409 Conflict + Retry-AfterBekleyip aynı key ile tekrar dene
Aynı key + farklı payload422 Unprocessable Contentİstemci hatası; yeni key üret
Key eksik (zorunlu endpoint)400 Bad RequestKey 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ğiStrateji
Idempotency key destekliyoroperation_id değerini provider’a key olarak ilet. Retry güvenlidir; provider aynı sonucu döner.
Sorgu (status inquiry) destekliyorRetry’dan önce operation_id / merchant reference ile durumu sorgula. Kayıt varsa yeniden gönderme.
İkisini de desteklemiyorOtomatik 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şımNe zaman?Dikkat
Koşullu UPDATETek satırlık, basit debit. Varsayılan tercih.Birden fazla tabloyu kapsayan kurallarda yetersiz kalabilir.
SELECT … FOR UPDATEKarar 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.
RezervasyonProvider ç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 hacimOrtalama TPSGerç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.

StratejiArtıEksi
Partition yok + batch DELETEGlobal 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 partitionEski partition’ı DROP etmek anlık ve ucuz.UNIQUE(key, created_at) farklı günlerdeki aynı key’i yakalamaz.
Key’e göre hash partitionUnique 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:

MetrikNeden önemli?
peak_tps, p95/p99_latencyOrtalama değil, en kötü anlardaki davranışı gösterir.
duplicate_ratioRetry davranışını ve Redis gibi bir katmanın gerekip gerekmediğini gösterir.
unique_conflict_rateAni artış, hatalı bir istemciyi ya da retry fırtınasını işaret eder.
db_lock_wait, hot_account_rateSı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_sizeAsenkron 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.

SenaryoNasıl simüle edilir?Beklenen sonuç
Aynı key, 100 sıralı istekDöngü içinde aynı key ile POSTTek finansal etki, 99 kez aynı cevap
Aynı key, 100 paralel istekTask.WhenAll / k6 ile eşzamanlıTek finansal etki; diğerleri 409 ya da aynı cevap
Aynı key, farklı payloadTutar değiştirilmiş ikinci istek422; ilk işlem etkilenmez
4 farklı key, 2.000 TL bakiye4 × 1.000 TL eşzamanlı debitEn fazla 2 başarı, bakiye ≥ 0
Commit sonrası worker crashProvider çağrısından önce process killRecovery lease’i devralır, tek tahsilat
Provider success sonrası timeoutToxiproxy ile cevabı düşürUnknown → reconcile; ikinci tahsilat yok
Broker mesajı 10 kez iletilirAynı mesajı tekrar publish etInbox 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

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir