Tüm yazılar Arka Uç

"İlk Bulan Kazanır" Aslında Bir Yarış Koşulu Problemi

Ürün tarafında tek cümlelik bir kural — ilk bulan kazanır — arka uçta bir yarış koşulu problemine dönüşüyor. Mysterious Treasure'da tek kazanan garantisini nasıl kurduğumu anlatıyorum.

DT
Demir Taşdemir Mobil Uygulama & Web Geliştirici
— dk okuma

Mysterious Treasure'da ürün kuralı tek cümleydi: bir hazineyi ilk bulan kazanır. Not defterime yazması beş saniye sürdü. Arka uçta karşılığının bir yarış koşulu problemi olduğunu ise ilk sürümü yazarken fark ettim.

Uygulama konum tabanlı bir hazine avıydı. Haritaya hazineler bırakılıyor, kullanıcı fiziksel olarak o noktaya yaklaşınca toplama düğmesi açılıyordu. Hazine tekti, kazanan tekti. Sorun da tam burada başlıyor: “tek” demek kolay, garanti etmek başka bir iş.

Naif çözüm neden çalışıyormuş gibi görünür

İlk yazdığım akış şuydu: hazine dokümanını oku, kazananId boşsa kazananı yaz. Tek başıma test ederken kusursuz çalışıyor. İki telefonla aynı hazineye aynı anda basmayı deneyene kadar da öyle kaldı.

// Yanlış: okuma ile yazma arasında bir boşluk var
const snap = await hazineRef.get();

if (snap.data().kazananId == null) {
  // İki cihaz da bu satıra girebilir
  await hazineRef.update({ kazananId: kullaniciId });
}

Buradaki hata koşulda değil, koşul ile yazma arasındaki zaman aralığında. O aralık milisaniyelerle ölçülüyor ama sıfır değil. Sıfır olmadığı sürece iki isteğin oraya birlikte girmesi mümkün. Bu tür hatalar sakin günlerde saklanır, kalabalık günlerde ortaya çıkar.

İki cihazın zaman çizelgesi

Problemi anlatmanın en net yolu, iki isteğin adımlarını iç içe yazmak. A ve B aynı hazineye yaklaşmış iki kullanıcı olsun:

SıraCihaz ACihaz BSunucudaki kazanan
1Hazineyi okur: boşboş
2Hazineyi okur: boşboş
3Kazandın ekranını açarboş
4Yazar: kazanan = AA
5Kazandın ekranını açarA
6Yazar: kazanan = BB

Sonuç: iki kullanıcı da kazandığını gördü, veritabanında ise B yazıyor. A'nın ekranında ödül var, kaydında yok. Bu tür bir tutarsızlık destek kutusuna “ödülüm kayboldu” diye düşüyor ve kullanıcı haklı.

Kararı istemci veremez

İlk sürümde hem mesafe kontrolü hem “bu hazine hâlâ boş mu” kontrolü telefondaydı. İkisi de aslında birer tahmin. Telefonun söyleyebileceği tek şey şu: bana göre bu hazine biraz önce boştu.

Bu bir karar değil, bir talep. Kararı verebilecek tek yer, herkesin aynı anda dokunduğu tek satır: hazine dokümanının kendisi. İstemcinin işi talebi göndermek ve cevabı beklemek.

Telefondaki kontrolleri tamamen silmedim; kullanıcıya anında geri bildirim vermek için hâlâ oradalar. Ama artık aralarında bir rol farkı var: telefondaki kontrol arayüzü yönetiyor, sunucudaki kontrol sonucu belirliyor. Aynı kontrolün iki yerde durması tekrar gibi görünüyor olabilir, değil — biri hız için, diğeri doğruluk için.

İşlem içinde tek yazma

Firestore işlemi (transaction) tam olarak bu boşluğu kapatıyor. Okumayı işlemin içinde yaparsanız ve işlem tamamlanmadan önce o doküman değişirse, Firebase işlemi baştan çalıştırıyor. Yani ikinci kullanıcı, güncellenmiş değeri görerek yeniden değerlendiriliyor.

async function hazineTalepEt(hazineId, kullaniciId, talepId) {
  const hazineRef = db.collection("hazineler").doc(hazineId);

  return db.runTransaction(async (t) => {
    const snap = await t.get(hazineRef);          // okuma işlemin İÇİNDE
    if (!snap.exists) return { sonuc: "hazine-yok" };

    const hazine = snap.data();

    if (hazine.kazananId) {
      // Aynı kullanıcı tekrar denediyse aynı cevabı üret
      if (hazine.kazananId === kullaniciId) {
        return { sonuc: "zaten-senin" };
      }
      return { sonuc: "baskasi-kazandi" };
    }

    // Kazanma kararı: tek yazma, tek doküman
    t.update(hazineRef, {
      kazananId: kullaniciId,
      talepId: talepId,
      kazanilmaZamani: FieldValue.serverTimestamp()
    });

    return { sonuc: "kazandin" };
  });
}

İki şeye özellikle dikkat ettim. Birincisi, koşulu işlemin dışında kontrol etmemek; koşul da yazma da aynı atomik bloğun içinde. İkincisi, kazanma kararını tek dokümana yazmak. Kazananı hem hazine dokümanına hem kullanıcı dokümanına eşzamanlı yazıp “ikisi tutarlı olsun” demek, çözdüğünüz problemi ikiye bölmek oluyor. Kazananın tek kaynağı hazine dokümanı; kullanıcı tarafındaki ödül kaydı ise o yazma başarılı olduktan sonra türetilen bir sonuç.

Tek dokümana yığılmak

Tek bir dokümana sürekli yazma yapmanın bir hız sınırı var; herkesin aynı satıra saldırdığı tasarımlar bu sınıra çarpar. Tek kazananlı senaryoda çekişme kısa sürdüğü için sorun olmadı, ama aynı deseni “herkes aynı sayaca yazsın” gibi bir yere taşıyacaksanız önce bu sınırı düşünün.

Cihaz saati bir kanıt değildir

“İlk” kelimesi zaman içeriyor, dolayısıyla akla gelen ilk çözüm hep aynı: talebe bir zaman damgası koyalım, küçük olan kazansın. Bu damgayı telefondan alırsanız kuralı doğrudan kullanıcıya teslim etmiş olursunuz. Telefon saati elle değiştirilebilir; ayrıca saat dilimi, yaz saati ve normal sapma da işin içinde.

Bende kazananı zaman damgası belirlemiyor, işlemin sırası belirliyor. Zaman damgası yalnızca kayıt ve ekranda gösterim için var; onu da serverTimestamp() ile sunucu yazıyor. Sunucu damgası bile bir hakem değil, bir tutanak. Bu ayrımı yapmak, sonradan “şu kullanıcının talebi neden geçersiz sayıldı” tartışmasını çok kısaltıyor.

İstemciden gelen zamanı karara sokmayın

Sıralama, ödül dağıtımı, süre bitişi, kampanya penceresi — hiçbirinde istemciden gelen bir tarih alanına güvenmeyin. Kaydetmeniz gerekiyorsa ayrı bir alanda tutun ve otoriteyi sunucu damgasında bırakın.

Aynı talep iki kez gelirse

Mobil ağda en sık yaşanan şey şu: istek sunucuya ulaşıyor, iş yapılıyor, cevap dönerken bağlantı kopuyor. Kullanıcı ekranda hiçbir şey görmediği için tekrar basıyor. Sunucu aynı talebi ikinci kez görüyor.

Bu durumda sunucu tarafında hata gibi görünen bir şey yok; iş başarıyla tamamlanmış, sadece cevabı kimse almamış. Kullanıcının gördüğü ise donmuş bir düğme. Yani sistem düzgün çalışırken bile kullanıcı tekrar denemeye itiliyor. Tekrar deneme normal akışın parçası olarak tasarlanmadıysa, ikinci istek yeni bir kayıt üretiyor.

Doğru davranış, ikinci seferde de aynı cevabı üretmek. Bunu iki parçayla kurdum:

  • İstemci, düğmeye basıldığı anda bir talep kimliği üretiyor; yeniden denemelerde aynı kimliği gönderiyor.
  • Sunucu, kazanan zaten bu kullanıcıysa hata değil başarı dönüyor — yukarıdaki koddaki zaten-senin dalı.

“Zaten sen kazandın” ile “başkası kazandı” bambaşka iki durum. İkisini tek bir hata kovasına atarsanız, kendi ödülünü kazanmış kullanıcıya hata göstermiş olursunuz. Bir işlemin iki kez çalıştırılmasının tek kez çalıştırılmasıyla aynı sonucu vermesine idempotentlik deniyor; pratikteki karşılığı, sunucunun “bu talebi daha önce gördüm mü” sorusuna cevap verebilmesi.

Kaybeden kullanıcıya ne söylüyoruz

Teknik taraf çözülünce geriye ürün tarafı kalıyor. Kaybeden kullanıcı bu hazineye gerçekten yürüyerek gitmişti. Ona “bir hata oluştu” demek en kötü seçenek; ortada hata yok, kural işledi.

İki şeyi değiştirdim. Birincisi mesaj: hazinenin başkası tarafından bulunduğunu açıkça söyleyip haritayı hemen güncelledim, o hazineyi listeden kaldırdım. İkincisi, iyimser arayüzü kaldırdım. Düğmeye basınca doğrudan kutlama ekranı açmak yerine sunucu cevabını bekliyorum. Konfeti bir kez patlıyor ve geri alınamıyor; kazandığını gördükten sonra “aslında kazanmadın” demek, hiç kazanmamış olmaktan daha kötü.

Test etmenin basit yolu

Bu tür hatalar tek cihazla elle test ederken neredeyse hiç görünmez. Aynı talebi paralel olarak defalarca gönderen küçük bir betik yazıp sonunda kaç kazanan yazıldığına bakın. Cevap her seferinde bir olmalı.

Aynı şekil başka yerlerde de karşıma çıktı

Hazine avı bu problemin en görünür hâliydi ama tek örneği değil. ArenaX'te bir yarışmanın ödül dağıtımı, 2.El Bilet'te aynı bilete gelen teklifler, Cepte Perakende'de son adet stok — hepsi aynı şeklin farklı isimleri: sınırlı bir kaynağa aynı anda birden fazla talep.

Artık böyle bir ürün kuralıyla karşılaştığımda kod yazmadan önce dört soru soruyorum:

  1. Kararı kim veriyor — istemci mi, sunucu mu?
  2. Koşul kontrolü ile yazma aynı atomik bloğun içinde mi?
  3. Aynı talep iki kez gelirse sonuç değişiyor mu?
  4. Zamanı kim ölçüyor?

Ürün cümlesi hâlâ tek satır: ilk bulan kazanır. Arka uçtaki karşılığı ise şu dört satır: kararı sunucu verir, koşul ve yazma tek işlemde olur, aynı talep iki kez gelse sonuç değişmez, zamanı sunucu ölçer. Basit görünen kuralların altında genellikle böyle bir eşzamanlılık sorusu duruyor. O soru sorulmadığında hata kaybolmuyor, sadece en kalabalık günü bekliyor.

  • Firebase
  • Firestore
  • Eşzamanlılık
  • Mimari
  • Mobil Uygulama
Paylaş: LinkedIn X WhatsApp
DT

Demir Taşdemir

Mobil Uygulama & Web Geliştirici

2018'den beri yazılım geliştiriyorum. App Store ve Google Play'de 11 uygulama yayınladım; şu an 6 mobil uygulama, 1 e-ticaret platformu ve 1 masaüstü oyun üzerinde çalışıyorum.

Sınırlı kaynak, tek kazanan

Son stok, tek kullanımlık kupon ya da tek kazananlı bir ödül akışı kuruyorsanız, mimarisine birlikte bakabiliriz.