---
title: "Ardışık Numara Üretiminde Race Condition ve Gap"
description: "Yasal olarak ardışık ve boşluksuz numaraları paralel worker'larla üretmek: race condition ile gap'i birlikte çözen JIT rezervasyon kalıbı."
url: https://sade.dev/tr/systems/ardisik-numara-uretiminde-race-condition-ve-gap/
lang: tr
author: "Muhammet Şafak"
published: 2026-06-03
updated: 2026-09-06
section: System
tags: ["architecture","production","concurrency","queue","idempotency"]
summary: "Aynı anda iki worker aynı MAX değerini okuyup mükerrer numara üretir; numarayı baştan üretmek ise gap doğurur. İki ucu da kapatan JIT rezervasyon kalıbı ve erken commit disiplini."
---

# Ardışık Numara Üretiminde Race Condition ve Gap

> Aynı anda iki worker aynı MAX değerini okuyup mükerrer numara üretir; numarayı baştan üretmek ise gap doğurur. İki ucu da kapatan JIT rezervasyon kalıbı ve erken commit disiplini.

Resmî 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    ──┘
```

Buton artık senkron çağrı yapıp orada faturalandırmıyor; [HTTP ile kuyruk arasındaki sınır](/tr/notes/senkron-mu-asenkron-mu) 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](/tr/notes/event-driven-mimari-ne-zaman) 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:

```sql
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, 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şım | Mükerrersiz | Boşluksuz | Kırılma |
| --- | --- | --- | --- |
| Gönderimde `MAX()+1` | ✗ | ✓ | Paralel worker'da çakışma |
| Enqueue'da üret | ✓ | ✗ | Baş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` deyimini içeren kısa transaction commit olana kadar, yani tek haneli milisaniyeler 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:

```sql
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:

```php
$invoice = 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();
    }

    return $invoice;
}); // 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 **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](/tr/notes/laravel-queue-production-yavaslama).)

## 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 problemi**dir: 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](/tr/notes/circuit-breaker-dis-servis-entegrasyonu) 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.

## Sık sorulanlar

**Hazır bir sequence ya da AUTO_INCREMENT boşluksuz numara verir mi?**

Hayır. Sequence transaction'dan bağımsız ilerler; geri alınan bir transaction'ın tükettiği değer geri gelmez — PostgreSQL, boşluksuz atama gerektiğinde sequence kullanılamayacağını açıkça yazar ve AUTO_INCREMENT de aynı biçimde davranır. Boşluksuzluk uygulama katmanında, açık bir sayaç tablosuyla yönetilir.

**Dış çağrı neden numarayı rezerve eden transaction içinde duramaz?**

Çünkü satır kilidi o zaman entegratör çağrısının tamamı boyunca, 200 ms ile 2 saniye arası tutulur ve aynı serideki her worker onun arkasında sıraya girer. Throughput tek bir dış çağrının gecikmesine iner. Bu yüzden rezervasyon erken commit edilir, kilit tek haneli milisaniyede açılır ve çağrı kilidin dışında yapılır.


## Kaynaklar

- [CREATE SEQUENCE: nextval is never rolled back, so gapless assignment is impossible](https://www.postgresql.org/docs/current/sql-createsequence.html) — PostgreSQL
- [SELECT: the locking clause, FOR UPDATE](https://www.postgresql.org/docs/current/sql-select.html) — PostgreSQL
- [InnoDB locking reads: SELECT ... FOR UPDATE](https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html) — MySQL
- [Classic and quorum queue priorities](https://www.rabbitmq.com/docs/priority) — RabbitMQ
