Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi System Design — #15

System Design Case Study: Flash Sale ve Bilet Satışı

Mert Özen 8 Kas 2026 13 dk 5 görüntülenme
System Design Case Study: Flash Sale ve Bilet Satışı

Flash sale'de overselling'i önleyen şey stok kontrolü ile azaltmanın tek atomik adımda yapılmasıdır; ani trafiği CDN'den sunulan bir waiting room emer, ödeme süresini TTL'li hold'lar yönetir, süresi dolan hold stoğu aynı statement'ta geri verir. 2.000 eşzamanlı alıcıyla 100 bilete naif kodun 2.000 rezervasyonu, koşullu UPDATE ve optimistic concurrency'nin gerçek çıktıları, ödeme timeout'u ve bot savunması.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir; stok örnekleri PostgreSQL 17 ve Npgsql 10 ile, her senaryoda 2.000 eşzamanlı alıcıyla koşturuldu.

Önce Tahmin Et

Bookstore'un etkinlik platformu, bir yazarın da sahneye çıkacağı büyük bir stadyum konserinin 50.000 biletini satacak. Satış 10:00'da açılıyor ve sosyal medyada haftalardır konuşuluyor. Ekibin rezervasyon kodu sade: kalan bilet sayısını oku, sıfırdan büyükse bir azaltıp yaz, bileti alıcıya ayır. Testte her şey doğru çalışıyor.

İki soru. 100 biletin kaldığı bir anda 2.000 kişi aynı saniyede "Satın Al"a basarsa kaç rezervasyon oluşur? Ve ödeme sayfasında on dakikalık süresi dolan bir alıcının bankadan onayı on birinci dakikada gelirse ne olmalı?

Requirement'lar ve Capacity Estimation

Bu yazıdaki senaryo, Türkiye'de büyük konser satışlarında sık konuşulan sorunlardan (sayfanın açılmaması, ödeme adımında biletin elden kaçması, karaborsa için bot alımları) esinlenen genel bir tasarım; belirli bir platformu anlatmıyor. Functional requirement'lar: kullanıcı satış açılınca sıraya girer, sırası gelince bilet seçip belirli bir süre için ayırır (hold), bu sürede öder ve biletini alır; ödemezse bilet stoğa geri döner; kişi başı bilet limiti. Non-functional requirement'lar bu sefer çok net. Overselling asla olmamalı: satılan bilet sayısı stoğu bir tane bile geçemez. Satış anındaki ani trafik sistemi düşürmemeli. Süreç adil olmalı ve botlara karşı dayanıklı olmalı. Ödeme alınıp bilet verilmemesi ya da bilet verilip ödeme alınmaması kabul edilemez.

Varsayımlar: satış anında 1 milyon kişi, kişi başı dakikada 6 sayfa yenileme, 50.000 bilet, ödeme ve rezervasyon tarafının saniyede 500 alıcıyı kaldırabildiği bir kapasite.

const double fans = 1_000_000, tickets = 50_000, checkoutPerSec = 500, refreshesPerFanPerMin = 6;
Console.WriteLine($"without a waiting room: {fans * refreshesPerFanPerMin / 60:N0} requests/s in the first minute");
Console.WriteLine($"admission at {checkoutPerSec} buyers/s: tickets gone after ~{tickets / checkoutPerSec / 60:N1} min, " +
                  $"{fans / tickets:N0} fans per ticket, position 200,000 would wait ~{200_000 / checkoutPerSec / 60:N1} min");
without a waiting room: 100,000 requests/s in the first minute
admission at 500 buyers/s: tickets gone after ~1.7 min, 20 fans per ticket, position 200,000 would wait ~6.7 min

İki sayı tasarımı belirliyor. Kapı açıldığı an saniyede 100 bin istek geliyor ama arkadaki sistem saniyede 500 alıcıyı işleyebiliyor: aradaki 200 katlık farkı bir şey emmeli. Ve bilet başına 20 kişi var; biletler 1,7 dakikada bitiyor, sıradaki 200.000'inci kişi ise 6,7 dakika bekleyecek. O kişiye bunu bekletmeden önce söylemek de requirement'ın parçası. Bu sistem, serinin önceki case study'lerinin tersine, yılın büyük kısmında boşta duran ve birkaç dakika için yüz kat yüke dayanması gereken bir sistem.

API ve Veri Modeli

Akış dört çağrıya bölünüyor:

  • POST /api/events/{id}/queue: sıraya katıl; imzalı bir sıra token'ı ve sıradaki yerini döner. GET /api/queue/status ile sıra durumu sorulur.
  • POST /api/events/{id}/holds: sırası gelen kullanıcı, token ve bir Idempotency-Key ile bilet ayırır; holdId ve expiresAt döner.
  • POST /api/holds/{holdId}/payment: ödemeyi başlatır; ödeme sağlayıcısının sonucu bir callback (webhook) ile gelir.
  • GET /api/holds/{holdId}: hold'un durumu (held, confirmed, expired).

Veri modelinin merkezi iki tablo: events (etkinlik başına kalan bilet sayısı) ve holds (kim, hangi etkinlikten, hangi durumda, ne zamana kadar). Numaralı koltuklu salonlarda kalan sayısının yerini seats tablosu alır ve her koltuk kendi satırında ayrılır; aşağıdaki pattern'ler koltuk satırı için de aynen geçerli.

High-Level Design

15-ticketing-tr.png

Kullanıcı önce waiting room'a girer; burası CDN'den (Content Delivery Network) sunulan statik bir sayfa ve küçük bir sıra service'i. Sırası gelen kullanıcı imzalı bir token ile rezervasyon adımına geçer; stok atomik bir adımla düşer ve on dakikalık bir hold oluşur. Ödeme başarılıysa hold onaylanır ve bilet çıkar. Süre dolarsa ya da ödeme başarısız olursa hold düşer ve bilet stoğa geri döner; süre dolduktan sonra gelen ödeme iade edilir. Bütün kırmızı yolların ortak noktası, hiçbirinin "belki" bırakmaması.

Deep Dive: Virtual Waiting Room

Saniyede 100 bin isteği arkadaki saniyede 500'lük sisteme doğrudan vermek, serinin "Reliability Pattern'leri: Timeout, Retry, Backoff + Jitter, Circuit Breaker ve Load Shedding" yazısındaki goodput çöküşünün ders kitabı örneği olur: sistem her isteği yarım işler, hiçbirini bitiremez ve kullanıcılar yenile tuşuna basarak yükü katlar. Waiting room bu farkı emen bir queue; serinin "Asenkron İşleme: Queue, Worker, Fan-Out ve Backpressure" yazısındaki load leveling'in kullanıcıya görünen hâli.

Tasarımın parçaları şunlar. Bekleme sayfası statik ve CDN'den gelir; milyon kişinin yenilemesi origin sunucusuna dokunmaz. Sıraya giren kullanıcıya imzalı bir token verilir (HMAC, Hash-based Message Authentication Code ile; içinde kullanıcı, etkinlik ve sıradaki yer); rezervasyon endpoint'i token'sız ya da sırası gelmemiş token'lı isteği daha veritabanına gitmeden reddeder. Kabul hızı sabit bir sayı değil, arkadaki sistemin sağlığına bağlı bir değer: ödeme sağlayıcısı yavaşlarsa kapı daralır. İstemcinin sıra durumunu sorma aralığı jitter'lıdır, yoksa milyon istemci aynı saniyede sorar. Ve biletler bittiği an sıradaki herkese bu söylenir; capacity tablosundaki 200.000'inci kişiyi 6,7 dakika boşuna bekletmek hem kullanıcıya hem sisteme haksızlık.

Adalet için bir karar daha var: satış açılmadan önce gelenler. Kapıya 09:59'da gelen ile 09:50'de gelen arasında sırayı varış zamanına göre vermek, sayfayı en çok yenileyeni ve en hızlı bot'u ödüllendirir. Yaygın çözüm, açılıştan önce gelen herkesi aynı havuza alıp sıralarını rastgele dağıtmak, açılıştan sonra gelenleri ise geliş sırasıyla arkaya eklemek.

Deep Dive: Overselling'i Önlemek

Önce Tahmin Et'teki ilk soruyu PostgreSQL'de, her biri kendi bağlantısıyla çalışan 2.000 eşzamanlı alıcıyla denedim. Naif kod:

// Oku, kontrol et, yaz: üç adım; iki alıcının aynı sayıyı okumasını hiçbir şey engellemiyor
public async Task<bool> NaiveReserveAsync(string userId)
{
    await using NpgsqlConnection conn = await db.OpenConnectionAsync();
    await using NpgsqlCommand read = new("SELECT remaining FROM events WHERE event_id = 1", conn);
    int remaining = (int)(await read.ExecuteScalarAsync())!;
    if (remaining <= 0) return false;
    await using NpgsqlCommand write = new("UPDATE events SET remaining = @r WHERE event_id = 1", conn);
    write.Parameters.AddWithValue("r", remaining - 1);
    await write.ExecuteNonQueryAsync();
    await InsertHoldAsync(conn, null, userId);
    return true;
}

Aynı yükü iki düzeltmeyle tekrarladım. Birincisi kontrolü ve azaltmayı tek bir koşullu UPDATE'e taşıyor, hold'u da aynı transaction'da yazıyor; ikincisi optimistic concurrency ile bir version kolonuna bakıyor ve çakışınca tekrar deniyor:

// Kontrol ve azaltma tek statement'ta; hold aynı transaction'da yazılıyor
public async Task<bool> AtomicReserveAsync(string userId, TimeSpan holdFor)
{
    await using NpgsqlConnection conn = await db.OpenConnectionAsync();
    await using NpgsqlTransaction tx = await conn.BeginTransactionAsync();
    await using NpgsqlCommand dec = new(
        "UPDATE events SET remaining = remaining - 1 WHERE event_id = 1 AND remaining > 0", conn, tx);
    if (await dec.ExecuteNonQueryAsync() == 0) return false;          // tükendi; dispose rollback yapar
    await InsertHoldAsync(conn, tx, userId, holdFor);
    await tx.CommitAsync();
    return true;
}

// Optimistic concurrency: satırı okuduğumuzdan beri kimse değiştirmediyse başarılı
public async Task<(bool Reserved, int Conflicts)> OptimisticReserveAsync(string userId)
{
    await using NpgsqlConnection conn = await db.OpenConnectionAsync();
    for (int conflicts = 0; ; conflicts++)
    {
        await using NpgsqlCommand read = new("SELECT remaining, version FROM events WHERE event_id = 1", conn);
        await using NpgsqlDataReader r = await read.ExecuteReaderAsync();
        await r.ReadAsync();
        (int remaining, int version) = (r.GetInt32(0), r.GetInt32(1));
        await r.CloseAsync();
        if (remaining <= 0) return (false, conflicts);
        await using NpgsqlCommand write = new("""
            UPDATE events SET remaining = @r, version = version + 1 WHERE event_id = 1 AND version = @v
            """, conn);
        write.Parameters.AddWithValue("r", remaining - 1);
        write.Parameters.AddWithValue("v", version);
        if (await write.ExecuteNonQueryAsync() == 1)
        {
            await InsertHoldAsync(conn, null, userId);
            return (true, conflicts);
        }
    }
}
naive     : 2000 reservations for 100 tickets, remaining = 69
atomic    : 100 reservations for 100 tickets, remaining = 0
optimistic: 100 reservations, 942 version conflicts retried

Naif kod 100 bilet için 2.000 rezervasyon oluşturdu ve sayaçta hâlâ 69 bilet görünüyor; bir önceki koşuda 57'ydi. Her alıcı eski bir değeri okuyup "kalan − 1"i mutlak değer olarak yazdığı için yazmalar birbirini eziyor (lost update). PostgreSQL'in varsayılan isolation level'ı READ COMMITTED bunu engellemiyor; SERIALIZABLE'da veritabanı bu çakışmaları serialization hatasıyla reddederdi ve her alıcı için retry gerekirdi. OWASP (Open Worldwide Application Security Project) Top 10'u anlatan Web Güvenliği serisinin "OWASP A06: Insecure Design — İş Mantığı Açıkları, Race Condition ve Rate Limiting" yazısındaki race condition'ın ölçekli hâli bu.

Koşullu UPDATE tam 100 rezervasyon üretti. Çalışmasının sebebi, kontrolün (remaining > 0) ve azaltmanın aynı satır kilidi altında, tek bir statement'ta yapılması: ikinci alıcı birincinin commit'ini bekler ve güncel değeri görür. Optimistic concurrency de doğru sonucu verdi ama 100 başarı için 942 çakışma ve tekrar denemeyle. Çözüm Desenleri serisinin "Optimistic Concurrency Uçtan Uca: rowversion'dan ETag'e, If-Match ve 412 ile Çakışma Ekranı" yazısında anlattığımız gibi optimistic concurrency, çakışmanın seyrek olduğu yerde parlıyor; herkesin aynı satıra saldırdığı bir flash sale bunun tam tersi.

Koşullu UPDATE de bir yerde tıkanır: hepsi aynı satıra yazdığı için o satırın kilidi sistemin tavanı olur. Saniyede 500 alıcı için tek satır genelde yeter. Daha fazlası gerekirse iki yol var. Stoğu N alt sayaca bölmek (her biri 50.000/N bilet tutar, alıcı rastgele bir alt sayaçtan düşer, boşalan alt sayaç atlanır) kilit çekişmesini N'e böler. Ya da sayacı Redis'e taşımak: serinin "Rate Limiting Tasarımı: Token Bucket, Sliding Window ve Distributed Rate Limiter" yazısındaki atomik Lua script'iyle DECR'ı yalnız sıfırın üstündeyse yapmak ve hold kaydını veritabanına asenkron yazmak. Bu ikincisi çok daha hızlı, ama artık iki kaynak var ve Redis'teki sayaç ile veritabanındaki hold sayısının uzlaştırılması (reconciliation) gerekiyor.

Deep Dive: Hold, TTL ve Ödeme Timeout'u

Hold, bileti ödeme süresince başkasına vermemenin yolu; ama sonsuza kadar tutulan bir hold, ödemeyen kullanıcıların bileti kilitlemesi demek. Bu yüzden her hold'un bir expires_at'i var ve süresi dolanlar bileti stoğa geri veriyor. İki kritik nokta var: süresi dolan hold'u işaretlemek ile stoğu artırmak aynı atomik adımda olmalı, ve ödeme onayı yalnız hâlâ geçerli bir hold'u onaylamalı:

// Süresi dolan hold'lar biletlerini, kendilerini expire eden statement'ın içinde geri verir
public async Task<int> ReleaseExpiredAsync()
{
    await using NpgsqlCommand cmd = db.CreateCommand("""
        WITH expired AS (
            UPDATE holds SET status = 'expired'
            WHERE status = 'held' AND expires_at < now()
            RETURNING event_id)
        UPDATE events e SET remaining = e.remaining + x.n
        FROM (SELECT event_id, count(*) AS n FROM expired GROUP BY event_id) x
        WHERE e.event_id = x.event_id
        RETURNING x.n
        """);
    return Convert.ToInt32(await cmd.ExecuteScalarAsync() ?? 0);
}

// Ödeme callback'i: yalnız hâlâ geçerli olan hold onaylanır
public async Task<bool> ConfirmAsync(long holdId)
{
    await using NpgsqlCommand cmd = db.CreateCommand(
        "UPDATE holds SET status = 'confirmed' WHERE hold_id = @id AND status = 'held' AND expires_at > now()");
    cmd.Parameters.AddWithValue("id", holdId);
    return await cmd.ExecuteNonQueryAsync() == 1;
}

100 hold oluşturdum; 70'i ödedi, 30'unun süresi doldu (süreyi geriye çekerek). Ardından süresi dolmuş bir hold için geç gelen ödemeyi ve zaten bileti olan bir kullanıcının çift tıklamasını denedim:

holds     : 70 confirmed, released 30 expired holds, remaining = 30
late pay  : confirm expired hold 71 -> rejected, refund the payment
double hold: 23505 duplicate key value violates unique constraint "ux_holds_active"
after double click: remaining = 30

Süresi dolan 30 bilet tek statement'ta stoğa döndü. Önce Tahmin Et'teki ikinci sorunun cevabı da üçüncü satırda: onay, süresi dolmuş hold'u reddediyor ve tek doğru hamle parayı iade etmek. Bileti yine de vermek, o bilet çoktan başkasına satılmış olabileceği için overselling'in arka kapısı olurdu. Çift tıklamayı (event_id, user_id) üzerindeki filtered unique index (WHERE status IN ('held', 'confirmed')) yakaladı; insert başarısız olunca transaction geri alındı ve az önce düşen stok geri geldi, sayaç 30'da kaldı. Aynı index kişi başı bir bilet limitini de veritabanı seviyesinde garanti ediyor.

Ödeme tarafının en zor anı timeout. Ödeme sağlayıcısına giden çağrı zaman aşımına uğradığında para çekildi mi, çekilmedi mi bilinmez. Bu durumda hold'u hemen düşürmek, parası çekilmiş kullanıcının biletini kaybetmesi demek; sonsuza kadar beklemek de bileti kilitlemek. Doğru sıra: ödemeyi Idempotency-Key ile başlat (aynı anahtarla tekrar denendiğinde sağlayıcı ikinci kez çekmez), timeout'ta hold'u "ödeme belirsiz" durumunda tut, sağlayıcıya anahtarla durumu sor ve ancak cevaba göre onayla ya da serbest bırak. Hold süresi de bu sorgulamaya pay bırakacak şekilde seçilir. Callback'ler tekrar gelebileceği için onay adımının kendisi idempotent olmalı; yukarıdaki koşullu UPDATE ikinci callback'te 0 satır döner ve hiçbir şey değiştirmez.

Deep Dive: Bot Trafiği

Bilet satışında bot, gerçek kullanıcıyla aynı endpoint'leri insanüstü hızla çağıran ve biletleri karaborsa için toplayan bir yazılım. Tek bir savunma yetmez; katmanlar birbirini tamamlar. Hesap seviyesinde: satışa yalnız doğrulanmış (telefon ya da kimlikle) ve satıştan önce açılmış hesaplar katılabilir, kişi başı bilet limiti veritabanında yukarıdaki unique index'le zorlanır. Sıra seviyesinde: token kullanıcıya ve oturuma bağlıdır, başka bir istemciye taşınamaz; waiting room'a girişte bir CAPTCHA (Completely Automated Public Turing test to tell Computers and Humans Apart) ya da benzeri bir insan doğrulaması istenebilir. İstek seviyesinde: hesap ve IP (Internet Protocol) başına rate limit, edge'de WAF (Web Application Firewall) ve bot tespit kuralları. İş kuralı seviyesinde: biletin üzerine isim yazılması ve devrin kısıtlanması, karaborsanın ekonomisini bozduğu için en etkili caydırıcılardan biri. Hiçbiri tek başına yeterli değil; amaç, bir bot'un maliyetini gerçek bir kullanıcının elde edeceği avantajdan yüksek yapmak.

Trade-off'lar

KararSeçtiğimizAlternatif ve bedeli
Ani trafikCDN'den statik waiting room + imzalı tokenDoğrudan satış: her şey aynı anda yavaşlar, kimse satın alamaz
Stok düşümüKoşullu UPDATE, tek transactionOptimistic concurrency: doğru ama yoğun çakışmada yüzlerce retry; Redis sayacı: daha hızlı ama iki kaynak ve uzlaştırma
Ödeme süresi10 dakikalık hold + toplu expireHold yok: ödeme adımında bilet elden kaçar; uzun hold: ödemeyenler stoğu kilitler
Ödeme timeout'uBelirsiz durum, sağlayıcıya sorgu, sonra kararHemen serbest bırakmak: parası çekilen bileti kaybeder
Erken gelenlerAçılış öncesi havuz, rastgele sıraVarış sırası: en çok yenileyen ve en hızlı bot kazanır

Sık Düşülen Tuzaklar

Oku, kontrol et, yaz

Ayrı statement'larla yapılan stok kontrolü eşzamanlı yükte delinir; yukarıda 100 bilete 2.000 rezervasyon. Kontrol ve azaltma tek atomik adım olmalı.

Expire ve stok iadesini ayırmak

Hold'u expired yapıp stoğu ayrı bir adımda artırmak, ikisinin arasında çöken bir job'da bileti kaybettirir ya da iki kez iade eder. İkisi aynı statement'ta ya da aynı transaction'da.

Süresi dolmuş hold'a bilet vermek

Geç gelen ödeme için bileti "yine de" vermek, o biletin ikinci kez satılması demek olabilir. Onay koşullu, geç ödeme iade.

Waiting room'u origin sunucusunda çalıştırmak

Bekleme sayfası da uygulama sunucusundan geliyorsa ilk dakikada o da düşer. Statik içerik CDN'de, sıra service'i küçük ve ayrı.

Satış gününü ilk kez satış gününde test etmek

Bu sistem yılda birkaç dakika gerçek yük görür. Load testing ile beklenen trafiğin birkaç katını önceden dene ve kabul hızını o ölçümle belirle.

Küçük Bir Deneme

★☆☆ Capacity hesabını 3 milyon kişi ve saniyede 2.000 alıcı kapasiteyle tekrar yap. Biletler kaç dakikada biter, sıradaki 1.000.000'uncu kişi ne zaman "tükendi" haberini almalı?

★★☆ Stoğu 10 alt sayaca böl: events yerine event_stock(event_id, bucket, remaining) kullan, alıcı rastgele bir bucket'tan düşsün, boşsa diğerlerini denesin. 2.000 alıcılık testte rezervasyon sayısı hâlâ tam 100 mü?

★★★ Ödeme timeout'u için "payment_pending" durumunu ekle: timeout'ta hold bu duruma geçsin, ReleaseExpiredAsync bu hold'lara dokunmasın, ayrı bir job sağlayıcıya sorup onaylasın ya da serbest bıraksın. Hangi durumda kullanıcı en fazla ne kadar bekler?

Bu yazıyla System Design serisi bitiyor. Requirement'ları netleştirmek ve sayıları kâğıda dökmekle başladık; load balancing, cache, replication ve sharding, consistent hashing, queue'lar, rate limiting, reliability ve ID üretimi gibi yapı taşlarını tek tek inceledik; son dört yazıda onları URL (Uniform Resource Locator) shortener, news feed, chat ve bilet satışında bir araya getirdik. Her case study'de aynı soruya döndük: hangi requirement hangi trade-off'u zorunlu kılıyor? Bu tasarımların altında yatan teori, yani replication ve consistency model'leri, CAP (Consistency, Availability, Partition tolerance) teoreminin ve partition yokken de latency ile consistency arasında bir seçim olduğunu ekleyen PACELC'in (Partition, Availability, Consistency, Else, Latency, Consistency) doğru okunuşu, clock'lar ve consensus, ilerideki Distributed Systems Temelleri serisinin konusu.