Resmi entegratörden dönen hata kısaydı: Duplicate / Already Exists. İki worker aynı saniyede aynı fatura numarasını üretmiş, ikisi de aynı numarayla göndermiş, biri reddedilmişti. O seride fatura kesimi tıkandı ve arkadaki her iş onun gerisinde bekledi.

Bu kalıbı yasal belge numaralandıran birkaç sistemde aynı şekilde kurdum; her seferinde aynı iki kısıt çatışır ve akla ilk gelen çözümlerin hepsi kırılır. Aşağıda hem neyin kırıldığını hem de bugün kullandığım çözümü — JIT rezervasyon — anlatıyorum.

İki kısıt aynı anda

Yasal belge numaralandırmasını (e-fatura, makbuz, irsaliye) zorlaştıran şey, iki kısıtın birlikte sağlanma zorunluluğudur:

  • Mükerrersiz — aynı numara iki kez kullanılamaz.
  • Boşluksuz — seride atlanan numara olamaz. Denetimde 1454 yoksa, “nereye gitti” sorusunun cevabı verilmek zorundadır.

Her kısıt tek başına kolaydır. AUTO_INCREMENT mükerrersizliği halleder; bir sayaç boşluksuzluğu halleder. Ama numaranın başarısız olabilen bir dış sisteme (entegratör, GİB) gönderildiği an, ikisini birden tutmak bir kilit değil zamanlama problemine döner.

Sistemin şekli — ve onu açığa çıkaran göç

Bu problem aslında bir mimari göçün kuyruğunda çıktı. Eskiden akış senkrondu: kullanıcı panelde “Faturamı oluştur” butonuna basar, fatura o request içinde, kullanıcı sonucu ekranda beklerken kesilirdi. O modelde numaralandırma yıllarca “çalıştı” — çünkü her istek, kullanıcının bloke beklemesi boyunca fiilen seri işleniyordu; gerçek bir eşzamanlılık hiç oluşmuyordu.

Sonra akışı ayrıştırdım. Bugün farklı proje ve framework’ler faturalarını ortak bir RabbitMQ kuyruğuna yazıyor; kuyruğu sürekli dinleyen ayrı bir consumer/worker mikroservisi mesajları tüketiyor, e-fatura formatına çeviriyor ve entegratöre iletiyor.

Proje A (framework X) ──┐
Proje B (framework Y) ──┤
Scheduler (cron)      ──┼──►  [ ortak RabbitMQ ]  ──►  Consumer mikroservisi (N worker)  ──►  Entegratör API ──► GİB
Kullanıcı aksiyonu    ──┘

Butonu artık senkron çağrı yapıp orada faturalandırmıyorum; HTTP ile kuyruk arasındaki sınır tam buradan geçer: dış API çağrısı saniyeler sürebilir, onu bir request-response döngüsünde tutmak istemedim. Sonuç event-driven bir yapı — ölçeklenebilir, dayanıklı ve üreticilerden bağımsız.

Ama bu ayrıştırma, yıllardır gizli kalan bir şeyi açığa çıkardı. Senkron model numaralandırmayı kazara seri işliyordu; consumer’ı eşzamanlı çalıştırır çalıştırmaz o kaza ortadan kalktı ve altta hep var olan race condition yüzeye vurdu. Bug küçük ve gözden kaçacak cinstendi — tam da yalnızca eşzamanlılık gerçek olunca ortaya çıktığı için. Buradan sonra tüm tasarım kararı tek bir soruda düğümlenir: numara nereden ve ne zaman gelir?

Bir göç eşzamanlılık hatası yaratmaz; seri bir yürütme modelinin sakladığı hatayı görünür kılar.

Naif çözüm 1 — gönderim anında MAX() (race condition)

İlk kurduğumda numarayı gönderim anında ürettim:

SELECT MAX(invoice_no) + 1 FROM outgoing_invoices WHERE series = 'A';

Tek worker’la kusursuz çalıştı. İkinci worker’ı açtığım gün çöktü:

Worker-1: SELECT MAX(...) → 1453
Worker-2: SELECT MAX(...) → 1453   (Worker-1 henüz INSERT etmedi)
Worker-1: API'ye 1454 ile gider  ✓
Worker-2: API'ye 1454 ile gider  ✗ Duplicate

Klasik read-modify-write race condition’ı. MAX() okuması ile yazma arasındaki pencere, iki worker’ın aynı değeri görmesine yetecek kadar geniş. Hacim arttıkça pencere genişler — yani sorun yük altında, en kötü zamanda büyür.

Naif çözüm 2 — numarayı baştan üret (gap)

İkinci refleksim çoğu geliştiriciyle aynıydı: numarayı mümkün olan en erken yerde, kuyruğa mesaj atarken üret ve mühürle. Mükerrerlik bitti, ama gap doğdu:

  • 1454 numaralı fatura kuyrukta beklerken işlenir.
  • Entegratörde kalıcı bir hata alır (geçersiz VKN, timeout, iş kuralı reddi) ya da kullanıcı kuyruktayken iptal eder.
  • O fatura düşer; arkadan gelen 1455 ile yola devam eder.
  • Seride 1454 boşluğu kalır — denetimde hesabı verilemeyecek bir gap.

İkilem buydu: numarayı geç üretirsem race, erken üretirsem gap. Mesele bir yöntemi diğeriyle değiştirmek değildi; ikisini birden tutmaktı.

YaklaşımMükerrersizBoşluksuzKırılma
Gönderimde MAX()+1Paralel worker’da çakışma
Enqueue’da üretBaşarısız/iptal → gap
JIT rezervasyon— (maliyeti aşağıda)

Neden hazır bir sequence yetmez

“Sayaç boşluksuzluğu halleder” dedim; ama veritabanının kendi sequence’ı (SEQUENCE, AUTO_INCREMENT) bunu yapmaz — tersine, gap üretmek üzere tasarlanmıştır. Sequence performans için transaction’dan bağımsız ilerler: nextval çağıran transaction rollback olsa bile tüketilen değer geri gelmez. PostgreSQL’de başarısız bir INSERT’in id’si kalıcı olarak atlanır; MySQL AUTO_INCREMENT aynı şekilde davranır. Bu çoğu sistemde istenen davranıştır — birincil anahtarda boşluk kimsenin umurunda değil. Bizim problemimizde ise tam olarak kaçındığımız şey. Bu yüzden boşluksuzluğu uygulama katmanında, açık bir sayaç tablosuyla yönetiyorum.

İkinci itiraz: “tek satırlık sayaç tablosu kendi başına bottleneck olmaz mı?” Olmaz — çünkü o satırın kilidi yalnızca UPDATE ... SET next_no = next_no + 1 süresince, yani mikrosaniyeler boyunca tutulur; dış çağrı boyunca değil. Yine de yük çok yüksekse seriyi bölmek (A, B, C… ayrı sayaçlar) contention’ı doğrusal düşürür; her serinin kendi boşluksuz dizisi olur ve seriler birbirini beklemez.

JIT rezervasyon: state’i dış çağrıdan bir ms önce mühürlemek

Bende çözüm şu oldu: numarayı ne çok erken ne çok geç, dış API çağrısından tam bir an önce üretip kalıcılaştırmak. İki parça ekledim:

  1. Atomik bir sekans sayacı (invoice_sequences) — seriyi tek kaynaktan ilerletir.
  2. Taslak fatura üzerinde bir rezervasyon kolonu (reserved_no) — üretilen numaranın, dış çağrı başarısız olsa bile korunduğu yer.

Kilidi yalnızca rezervasyon için tutuyorum; dış çağrı kilidin dışında:

│── TX başlat ─────────────────│
│  invoice'u lockForUpdate     │
│  reserved_no boşsa:          │   ← satır kilidi yalnızca burada (tek haneli ms)
│    sequence++ → reserved_no  │
│    status = SENDING          │
│── COMMIT ────────────────────│
                                └──► Entegratör API çağrısı (200ms–2sn, kilit YOK)
                                      ├─ başarılı → outgoing_invoices'a INSERT
                                      └─ başarısız → reserved_no DURUR, status = ERROR

Sayacı satır kilidiyle atomik ilerletiyorum:

BEGIN;
  SELECT next_no FROM invoice_sequences WHERE series = 'A' FOR UPDATE;
  UPDATE invoice_sequences SET next_no = next_no + 1 WHERE series = 'A';
COMMIT;  -- kilit ms içinde açılır; bir sonraki worker beklemeden devralır

Worker tarafında rezervasyonu idempotent kılan tek bir kontrol var:

DB::transaction(function () use ($invoiceId) {
    $invoice = Invoice::whereKey($invoiceId)->lockForUpdate()->first();

    // Retry'da numara KORUNUR — yeniden üretilmez.
    if ($invoice->reserved_no === null) {
        $invoice->reserved_no = $this->sequence->next('A'); // FOR UPDATE'li sayaç
        $invoice->status      = 'SENDING';
        $invoice->save();
    }
}); // TX burada commit olur; kilit kalkar

$this->integrator->send($invoice); // saniyeler sürebilir — kilit tutmuyoruz

Kritik satır if ($invoice->reserved_no === null). Bir fatura hata alıp retry edildiğinde alan doludur; yeni numara üretmem, eldekini korurum. Aynı fatura kaç kez denenirse denensin tek bir numara tüketir. Gap’i kapatan yarı budur: başarısız fatura numarasını kaybetmez, üzerinde asılı tutar ve aynısıyla yeniden dener.

Erken commit neden pazarlık konusu değil

Kalıbın kalbi üçüncü adımdaki erken commit. Yanlış yaparsanız tüm kalıp bir bottleneck’e döner.

Naif refleks, “atomik olsun” diye tüm işi tek transaction’a sarmaktır: kilidi al, numarayı üret, dış API’yi çağır, sonra commit et. Bu felakettir. Entegratör çağrısı 200ms ile 2 saniye arası sürer; o süre boyunca satır kilidi tutulursa aynı seriyi bekleyen her worker sıraya girer. Throughput, tek bir dış çağrının gecikmesine iner — saniyede onlarca fatura kesmesi gereken sistem, saniyede bire düşer.

Bu yüzden kilidi yalnızca rezervasyon için tutup hemen commit ediyorum. Kilit tek haneli milisaniyede açılır; paralel worker’lar 1454, 1455, 1456... rezerve etmeye devam eder. Dış çağrının yavaşlığı artık kimseyi bloklamaz.

Kural: kilit DB state’i için tutulur, dış I/O için değil. Bir transaction içinde network çağrısı varsa, kilit süreniz o network’ün insafına kalmıştır.

Birden çok üreticiyi güvende tutmak

Birden çok üretici (scheduler, kullanıcı butonu, farklı projeler) aynı seriyi beslediği için, numara tek bir noktadan rezerve edilmek zorunda. Hepsi consumer aşamasında aynı reserved_no kontrolünden geçer. Üretici kim olursa olsun rezervasyon kaynağı tektir; numara üretimi üreticiye değil, consumer’daki tek koda bağlıdır. Numarayı üreticide (enqueue anında) üretmeyi denemek, bizi yukarıdaki “erken üret → gap” tuzağına geri götürürdü; o yüzden rezervasyon tek bir yerde, consumer’da yaşar.

İkinci mesele queue starvation’ı. Scheduler’ın toplu bastığı binlerce mesaj, canlıdaki bir kullanıcının anlık isteğini ezmemeli — yoksa kullanıcı, arkasında 4.000 toplu işin olduğu bir kuyruğun sonunu bekler. RabbitMQ’da priority queue ile kullanıcı isteklerini yüksek öncelikle araya sokuyorum; toplu iş arka planda akarken buton tetikli iş loading ekranını saniyelere değil milisaniyelere indirir. Öncelik doğruluğu değiştirmez — numara yine tek kaynaktan gelir — yalnızca adalet ekler. (Kuyruğun neden ve nasıl tıkandığı ayrı bir konu: Laravel queue production’da neden yavaşlar.)

Dual-write problemi ve orphan recovery

Geriye en sinsi edge case kalır. API çağrısı başarılı olur ama yanıtını yerel DB’ye yazarken worker çöker ya da network kopar. Entegratörde fatura yasal olarak kesilmiştir; sizin tarafınızda ise hâlâ SENDING statüsünde, numarası üzerinde asılı durur. Bu klasik dual-write problemidir: iki ayrı sistemi tek bir atomik işlemde tutamazsınız. Worker’ın deploy ya da ölçeklenme sırasında yarıda kapanması da aynı izi bırakır — graceful shutdown bunu azaltır ama sıfırlamaz; tasarım yine de bu izi temizleyebilmeli.

Çözüm, asılı kalanı tespit eden bir orphan recovery servisi. SENDING statüsünde 5 dakikadan uzun bekleyen faturaları tarar ve kör bir retry atmaz — önce entegratörde bir idempotency check yapar:

  • Bu numarayla fatura entegratöre gitmiş mi?
  • Gittiyse: yerel DB’yi onarır (outgoing_invoices’a yazar, statüyü kapatır). Yeni numara üretmez.
  • Gitmemişse: aynı reserved_no ile güvenle yeniden dener.

Bunu mümkün kılan şey, numaranın çağrıdan önce mühürlenmiş olması. Numara elde olduğu için sorgu deterministiktir: “şu numara orada mı?” Numara çağrı sırasında üretilseydi, neyi soracağınızı bile bilemezdiniz. JIT rezervasyon, recovery’yi tahmin işi olmaktan çıkarıp bir lookup’a indirir.

Bir not: entegratör kalıcı olarak hata veriyorsa (geçici değil), orphan servisi sonsuza kadar denememeli. Dış servise yüklenmeyi kesmek için araya bir circuit breaker koyuyorum; numara korunur, fatura ERROR’da bekler, ama entegratöre saniyede yüzlerce boş istek gitmez.

Ne zaman bu kalıbı bırakırsınız?

Bu makine ucuz değil — bir rezervasyon kolonunun yaşam döngüsü, atomik bir sayaç, bir orphan servisi ve entegratörde idempotency check zorunluluğu getirir. Karşılığında neyi satın aldığınızı bilmiyorsanız ödemeyin. Şu durumlarda kurmuyorum:

  • Gap serbestse. Numaranız yasal olarak boşluksuz olmak zorunda değilse (iç referans, sipariş no, log id), düz bir DB sequence ya da AUTO_INCREMENT yeter. Gap’i kovalamak için bu mekanizmayı kurmak, olmayan bir problemi çözmektir.
  • Dış çağrı yoksa. Numara üretimi ile kalıcılaştırma aynı transaction’da kalabiliyorsa (araya başarısız olabilen bir I/O girmiyorsa), rezervasyon kolonuna gerek yok; tek bir FOR UPDATE sayacı problemi bitirir.
  • Tek worker, düşük hacim. Paralellik yoksa race condition da yoktur. Bir global advisory lock ya da tek tüketicili kuyruk, JIT makinesinden çok daha basit ve yeterlidir.

Kalıba ihtiyaç, üç koşul birlikte doğduğunda başlar: boşluksuzluk yasal zorunluluk, üretim paralel, ve son adım başarısız olabilen bir dış çağrı. Üçü birden yoksa daha sade bir çözüm vardır — onu seçin.


Sıralı, boşluksuz numara üretmek bir kilit sorunundan çok bir zamanlama sorunu. Numarayı dış dünyaya gitmeden tam bir an önce mühürlersiniz: race’i engelleyecek kadar geç, gap’i engelleyecek kadar erken. Geri kalan her şey — erken commit, idempotent retry, orphan recovery — o tek mührün etrafına dizilir.