Bir e-fatura entegrasyonunda numara çakışmasını çözmek
Üretimde iki worker aynı fatura numarasını üretti. Race condition ile yasal 'gap' arasındaki ikilemi gerçek bir projede nasıl çözdüğümün günlüğü.
Geçtiğimiz aylarda bir e-fatura entegrasyonunda, prodüksiyonda kısa ama can sıkıcı bir hata mesajıyla karşılaştım: resmi entegratör Duplicate / Already Exists döndürüyordu. Log’lara baktığımda tablo netti — iki ayrı worker, aynı saniyede aynı fatura numarasını üretmiş, ikisi de aynı numarayla göndermiş, biri reddedilmişti. O seride fatura kesimi tıkanmıştı ve arkadaki bütün işler bekliyordu.
Bu hata aslında bir mimari göçün en sonunda çıktı. Aylar boyunca faturalandırmayı, kullanıcının butona basıp sonucu ekranda beklediği senkron bir yapıdan; farklı proje ve framework’lerin ortak bir RabbitMQ kuyruğuna yazdığı, ayrı bir consumer/worker mikroservisinin de kuyruğu sürekli dinleyip tükettiği asenkron bir yapıya taşımıştım. Göç tam istediğim gibi gitti — ölçeklenme ve dayanıklılık yerine oturdu. Bu numara çakışması ise o sürecin en sonunda, gözden kaçacak kadar küçük bir pürüz olarak ortaya çıktı: meğer senkron modelde kullanıcının beklemesi işi kazara sıraya sokuyormuş; consumer eşzamanlı çalışınca o sıra kalktı ve yıllardır orada olan ama hiç görünmeyen bir race condition yüzeye vurdu.
Bu yazı o günün ve sonrasındaki kararların günlüğü. Derin mimari tarafını ayrı yazdım (aşağıda bağ var); burada “gerçek bir projede bu nasıl bir his ve hangi tuzaklara bastım” tarafını anlatmak istiyorum.
Sorun aslında iki sorundu
İlk bakışta klasik bir race condition’dı. Numara gönderim anında SELECT MAX(invoice_no) + 1 ile üretiliyordu. Tek worker’la yıllarca sorunsuz çalışan bu yöntem, paralel worker’lar aynı seriyi aynı anda işlemeye başlayınca çöküyor: ikisi de MAX()’tan aynı değeri okuyor, ikisi de aynı numarayla dış API’ye gidiyor.
İlk refleksim çoğu geliştiriciyle aynı oldu: “numarayı en başta, mesajı kuyruğa atarken üretelim, çakışma biter.” Üretirken fark ettim ki bu, race’i çözerken daha kötü bir problem doğuruyor. Çünkü e-faturada numara yalnızca mükerrersiz değil, aynı zamanda boşluksuz olmak zorunda — yasal seride atlanan numara olamaz. Numarayı baştan üretirsem, kuyrukta bekleyen bir fatura entegratörde hata alıp düştüğünde ya da kullanıcı iptal ettiğinde, seride hesabı verilemeyen bir boşluk (gap) kalıyor.
Yani elimde iki ucu keskin bir bıçak vardı: numarayı geç üretirsem race, erken üretirsem gap. Mesele bir yöntemi diğeriyle değiştirmek değil, ikisini birden tutmaktı.
Çözümün özü: numarayı tam zamanında rezerve etmek
Çıkış noktası şu oldu: numarayı ne çok erken ne çok geç, dış API çağrısından tam bir an önce üret ve kalıcılaştır. Faturaya bir reserved_no kolonu ekledim; numara bir kez rezerve edilince, dış çağrı başarısız olsa bile o numara silinmiyor, faturanın üstünde asılı kalıyor ve aynı numarayla yeniden deneniyor. Race tarafını ise merkezi, atomik bir sayaç hallediyor.
Kilidi yalnızca rezervasyon için tutup hemen commit etmek — yani dış çağrıyı transaction’ın dışında bırakmak — performansın da kilidiydi. Bunu yanlış yapıp dış çağrıyı kilidin içinde tutsaydım, sistem bir bottleneck’e dönüşürdü.
Bu kalıbın derinliğine — kilit penceresi, erken commit, hibrit tetikleme, dual-write problemi ve orphan recovery — bu günlükte girmiyorum; o tarafı kod ve diyagramlarıyla ayrı bir yazıda topladım:
Ardışık Numara Üretiminde Race Condition ve Gap: JIT Rezervasyon → (sade.dev)
Günlüğe not ettiğim üç şey
Bu işten kalan ve bir sonraki benzer probleme taşıyacağım çıkarımlar, koddan çok karar tarafında:
Yasal bir kısıt, bir mimari kısıttır. “Boşluksuz numara” başta bir muhasebe detayı gibi görünüyordu; oysa sistemin tüm tasarımını belirleyen asıl kuvvet oydu. Race condition’ı tek başına çözen onlarca yöntem var — ama “gap de olmayacak” kısıtını ekleyince çözüm kümesi bir anda daralıyor. Bir gereksinimi “iş kuralı” diye kenara almadan önce, mimariyi nasıl kısıtladığını sormak gerekiyor.
Dış dünyaya bağlı her adım başarısız olabilir. Entegratör çağrısı başarılı olup yerel yazma anında network çökmesi gibi senaryolar “olmaz” değil, “ne zaman” meselesi. Tasarımı “istek tekrar gelebilir, çağrı yarıda kalabilir” varsayımı üzerine kurmak, daha önce API’de idempotency üzerine yazarken anlattığım aynı disiplinin başka bir yüzü. Numarayı çağrıdan önce mühürlemek, sonradan “bu fatura gerçekten gitti mi?” diye sormayı mümkün kılan şeydi.
Önce ölç, sonra varsay. “Numara çakışıyor, demek ki kilitleme lazım” kestirmesi yarı doğruydu. Asıl soru kilidin nerede ve ne kadar tutulacağıydı; bunu log ve davranışı ölçmeden tasarlasaydım, çalışan ama tıkanan bir sistem kurardım.
Sonuçta sıralı, boşluksuz numara üretmek bir kilit sorunundan çok bir zamanlama sorunu çıktı: state’i dış dünyadan tam bir an önce mühürlemek. Geri kalan her şey o tek kararın etrafına dizildi.