Ana sayfa → Teknik
Race Condition: Stok Neden Eksiye Düşer?
Kampanya sabahı. Stokta 1 adet kalan üründen 3 sipariş geçmiş. Kod incelendi, hata yok. Testler yeşil. Ve kimse tekrar üretemiyor.
- Suçlu neredeyse her zaman aynı kalıp: önce kontrol et, sonra yap. Aradaki boşlukta başka biri araya giriyor.
- Test ortamında görünmez, çünkü orada eşzamanlılık yok. İlk kez kampanya günü görürsün.
- Çözümün ilk adresi veritabanı. Kilit yazmadan önce koşulu sorgunun içine koymayı dene.
- Dağıtık kilit son çaredir, ilk çare değil. Çoğu vakada hiç gerekmiyor.
Nedir bu race condition?
Tanımı sıkıcı: iki işlem aynı veriye aynı anda dokunuyor ve sonuç, hangisinin önce bittiğine göre değişiyor. Ama sahada hep aynı kalıpla karşımıza çıkıyor:
urun = SELECT * FROM urunler WHERE id = 42;
if (urun.stok > 0) { // ← kontrol
// ... burada milisaniyeler geçiyor ...
UPDATE urunler SET stok = urun.stok - 1 WHERE id = 42; // ← yap
siparis_olustur();
}
Bu kod tek kullanıcıyla kusursuz çalışır. İki kullanıcıyla ne olduğuna bakalım:
| Zaman | İstek A | İstek B | Veritabanı |
|---|---|---|---|
| t0 | SELECT → stok = 1 | stok = 1 | |
| t1 | kontrol: 1 > 0 ✓ | SELECT → stok = 1 | stok = 1 |
| t2 | UPDATE stok = 0 | kontrol: 1 > 0 ✓ | stok = 0 |
| t3 | sipariş oluştu | UPDATE stok = 0 | stok = 0 |
| t4 | sipariş oluştu | 2 sipariş, 1 ürün |
Dikkat et: B, A’nın okuduğu eski değeri kullanarak yazdı. Buna “kayıp güncelleme” deniyor ve stok bazen eksiye bile düşmüyor — sadece bir sipariş fazladan oluşuyor, ki bunu fark etmek daha zor.
Aynı hatanın dört farklı kılığı
1. Tek kullanımlık kupon iki kez kullanıldı
“Kupon kullanılmış mı?” diye bakılır, kullanılmamışsa indirim uygulanır ve kullanıldı işaretlenir. Kullanıcı sabırsızlanıp butona iki kez basarsa iki istek neredeyse aynı anda gider. Sonuç: bir kupon, iki indirim.
2. Bakiye ikiye bölündü
Cüzdanda 100 TL var. İki paralel çekim isteği geliyor, ikisi de 80 TL. Her ikisi de “bakiye yeterli” kontrolünü geçiyor. Sonuç: bakiye −60 TL. Bu, kurumsal hayatta insanı en çok terleten hata sınıfıdır.
3. Ödeme bildirimi iki kez geldi
Ödeme sağlayıcısı, cevabı zamanında alamadığında bildirimi tekrar gönderir — bu bir hata değil, tasarım. Sen “bu ödeme kaydı var mı?” diye bakıp yoksa ekliyorsan, iki bildirim aynı anda geldiğinde iki kayıt oluşur. Müşteri iki kez ürün alır ya da iki kez ücretlendirilir.
4. Aynı işi iki worker aldı
Kuyruktan iş çeken iki süreç, “bekleyen işler” sorgusunu aynı anda çalıştırırsa aynı satırı ikisi de alabilir. Aynı e-posta iki kez gider. Bu konunun devamı worker sharding yazısında.
Neden test ortamında hiç görmedin?
Çünkü testte tek kullanıcı, tek istek, tek sunucu var. Hata ise iki isteğin milisaniyeler içinde çakışmasını gerektiriyor. Üstelik:
- Canlıda birden fazla kopya çalışıyor. Uygulama içindeki bir kilit (mutex) tek makinede iş görür, ikinci kopyada hiçbir şey ifade etmez.
- Trafik düz değil, kümelenir. Kampanya başlangıcı, bildirim gönderimi, saat başı işler — hepsi aynı saniyeye yığılır.
- Yavaşlık pencereyi büyütür. Sistem yük altında yavaşladıkça “kontrol” ile “yap” arasındaki boşluk genişler ve hata olasılığı artar. Yani en kötü anda daha sık olur.
Çözümler: en ucuzdan en pahalıya
1. Kararı veritabanına verdir (ilk denenecek)
En ucuz ve en sağlam çözüm bu ve şaşırtıcı derecede az kullanılıyor.
Koşulu if’ten çıkarıp sorgunun içine koy:
UPDATE urunler
SET stok = stok - 1
WHERE id = 42
AND stok > 0;
-- etkilenen satır sayısı 1 ise: stok düştü, siparişi oluştur
-- 0 ise: stok bitmiş, kullaniciya "tükendi" de
İki şey birden değişti. Birincisi, stok = stok - 1 yazdık;
uygulamadan gelen eski değeri değil, veritabanının o anki değerini kullanıyor.
İkincisi, koşul da aynı ifadenin içinde, yani kontrol ile güncelleme arasında
hiç boşluk yok.
Kritik nokta: dönüş değerini kontrol etmelisin. Etkilenen satır 0 ise işlem olmamıştır ve siparişi oluşturmamalısın. Bu satırı atlamak, çözümü uygulayıp hatayı yaşamaya devam etmenin en yaygın yolu.
2. Tekil kısıt (unique constraint)
Kupon örneği için en temiz çözüm. “Kullanıldı mı” diye sormak yerine, kullanımı ayrı bir tabloya yazmayı dene:
CREATE UNIQUE INDEX ux_kupon_kullanim ON kupon_kullanim (kupon_id);
-- uygulamada:
INSERT INTO kupon_kullanim (kupon_id, siparis_id) VALUES (7, 991);
-- basarili -> indirimi uygula
-- unique ihlali -> "bu kupon zaten kullanilmis"
Buradaki fikir güzel: hatayı önlemeye çalışmak yerine, imkânsız durumu veritabanında imkânsız kılıyorsun. Uygulamada kaç kopya çalışırsa çalışsın, kural tek yerde ve zorunlu.
3. Sürüm alanı: iyimser kilit
Uzun süren düzenlemelerde işe yarıyor — iki kişi aynı kaydı açıp kaydettiğinde.
Kayda bir version alanı eklenir:
UPDATE siparisler
SET durum = 'onaylandi', version = version + 1
WHERE id = 991
AND version = 3; -- okurken gördüğüm sürüm
-- 0 satır etkilendiyse: baskasi benden once degistirmis
-- kullaniciya "kayit guncellendi, tekrar bakin" de
Adı “iyimser”, çünkü çakışma olmayacağını varsayıyor ve olursa fark ediyor. Çakışmanın nadir olduğu yerlerde en performanslı yöntem; sık olduğu yerlerde kullanıcıyı sürekli “tekrar deneyin” ekranına düşürür.
4. Satır kilidi: kötümser kilit
Birden fazla tabloya dokunan, kısa ama bölünemez işlemler için:
BEGIN;
SELECT bakiye FROM cuzdanlar WHERE id = 5 FOR UPDATE; -- satiri kilitle
-- burada baska islem bu satiri okuyamaz
UPDATE cuzdanlar SET bakiye = bakiye - 80 WHERE id = 5;
INSERT INTO hareketler (...) VALUES (...);
COMMIT; -- kilit birakildi
- Kilidi kısa tut. İşlem içinde dış servise istek atma. Bir HTTP çağrısı beklerken satır kilitli kalır, arkada kuyruk büyür ve zincirleme yavaşlama başlar.
- Kilit sırasını sabitle. A işlemi 5 sonra 9, B işlemi 9 sonra 5 kilitliyorsa günün birinde ikisi birbirini bekler (deadlock). Kuralı yaz: satırlar her zaman id sırasıyla kilitlenir.
5. Idempotency anahtarı
Tekrar gönderilen isteklere karşı doğru cevap bu. İstemci (veya ödeme sağlayıcı) her mantıksal işlem için sabit bir anahtar üretir; sen onu tekil kısıtlı bir tabloya yazarsın. Aynı anahtar ikinci kez geldiğinde işlemi tekrar yapmaz, ilk sonucu dönersin.
Bu, üçüncü örnekteki çift ödeme kaydını komple ortadan kaldırır ve ağ hatalarında “acaba gitti mi” sorusunu da bitirir.
6. Dağıtık kilit
En son çare. Yukarıdakilerin hiçbiri işe yaramıyorsa — örneğin korunacak şey tek bir veritabanı satırı değilse, ya da işlem birden fazla sistemi kapsıyorsa. Kendi tuzakları var ve ayrı bir yazıyı hak ediyor.
Nasıl fark edersin? (canlıdayken)
Race condition hata mesajı üretmez; imkânsız veri üretir. O yüzden aramak yerine, imkânsız durumları raporlayan sorgular kur:
-- negatif olmamasi gereken alanlar
SELECT * FROM urunler WHERE stok < 0;
SELECT * FROM cuzdanlar WHERE bakiye < 0;
-- tekil olmasi gereken kayitlar
SELECT odeme_ref, COUNT(*) FROM odemeler
GROUP BY odeme_ref HAVING COUNT(*) > 1;
-- ayni saniyede ayni kullanicidan iki kayit
SELECT kullanici_id, COUNT(*) FROM siparisler
WHERE olusma > now() - interval '7 days'
GROUP BY kullanici_id, date_trunc('second', olusma)
HAVING COUNT(*) > 1;
Bu üç sorguyu bir yere asıp haftada bir bakmak, aylarca sessizce veri bozan bir hatayı yakalamanın en ucuz yolu.
Tekrar üretmek: hatayı görmeden düzeltme
Düzeltmeden önce hatayı bir kez gör. En basit yolu paralel istek atmak:
seq 20 | xargs -P 20 -I{} \
curl -s -X POST https://api.local/siparis \
-d '{"urunId":42,"adet":1}' -o /dev/null
# sonra: SELECT stok FROM urunler WHERE id = 42;
# stok 1 iken 20 istek attin. Kac siparis olustu?
Düzeltmeden önce bir kez çalıştır, düzelttikten sonra bir kez daha. İkinci turda tek sipariş görüyorsan iş bitmiştir. Bu adımı atlayıp “galiba düzeldi” demek, aynı hatayı üç ay sonra tekrar yaşamanın klasik yolu.
Kontrol listesi
- Kodda “önce SELECT, sonra if, sonra UPDATE” kalıbı var mı?
- UPDATE, uygulamadan gelen eski değeri mi yazıyor, yoksa
alan = alan - 1mi? - Atomik UPDATE’in etkilenen satır sayısı kontrol ediliyor mu?
- Tekil olması gereken bir şey için veritabanında unique index var mı?
- Dışarıdan gelen bildirimlerde idempotency anahtarı var mı?
- Transaction içinde dış servis çağrısı yapılıyor mu? (Yapılmamalı.)
- Uygulama içi kilit kullanılıyorsa: kaç kopya çalışıyor? (Birden fazlaysa o kilit işe yaramıyor.)
Sonuç
Race condition’lar zeki hatalar değil; hep aynı kalıptan çıkıyorlar. Kodda o kalıbı tanımayı öğrendiğinde çoğunu yazılmadan önce yakalıyorsun.
Ve akılda kalacak tek cümle şu olsun: bir koşulu uygulamada kontrol edip veritabanında uyguluyorsan, arada bir boşluk var demektir. O boşluğu kapatmanın en ucuz yolu da koşulu sorgunun içine taşımak.