← Back to list

100 Bileti 101 Kez Satmamak — Bölüm2

Birinci bölümde aşırı satımın önündeki tek kontrol Redis kilidiydi. Bu yazıda o garantiyi veritabanına da taşıyacağız.

Onur Nafi Güzel · 2026-06-06 07:39 · 0 claps · 4.4 min read
#redis #lua-script
Open on Medium ↗
Wiki topics: 🛠️ · Crafts & DIY

100 Bileti 101 Kez Satmamak — Bölüm2

Birinci bölümde aşırı satımın önündeki tek kontrol Redis kilidiydi. Bu yazıda o garantiyi veritabanına da taşıyacağız.

Dikkat: Redis’i kaldırmıyorum sadece onu “tek otorite” rolünden “hızlı-yol” rolüne indiriyorum. Redis artık yükü hafifleten bir ön katman. Sonuç olarak iki katman birlikte çalışacak; değişen şey şu: doğruluk artık yalnızca Redis doğru çalıştığında değil, Redis tamamen çökse bile garanti altında. Aşırı satımı engelleyen veritabanı olacak.

İlk tasarım çalışıyordu, ama görünmez bir koşula yaslanıyordu: “Redis doğru çalıştığı sürece.” Production ortamında bu kabul edilebilir bir varsayım değil bir cache, asla bir tutarlılık garantisinin tek dayanağı olmamalı. Aşağıda önce mevcut akışı özetliyor, sonra onu neden kırılgan bulduğumu açıklıyor, en sonunda da iki katmanı nasıl birlikte ve doğru şekilde çalıştırdığımı gerçek kodla açıklıyacağım.

Mevcut akış

İlk tasarımda POST /bookings/{eventId} şu sırayı izliyordu: doğrula → Redis kilidini al → ödemeyi yap → tek bir transaction içinde bookings kaydını ekle ve biletleri Booked yap.

mevcut akış

mevcut akış

Buradaki tek eşzamanlılık bariyeri, redis içindeki atomik Lua script'iydi. Biletleri Booked'a çeviren modül metodu ise hiçbir koşul taşımıyordu.

Problem

Bu akışta birbirini besleyen iki zayıflık var.

  1. Doğruluk tamamen Redis’e bağlı. Aşırı satımı engelleyen tek şey Redis kilidi olduğu için, Redis yanıldığı an sistem de yanılıyor: bir restart, bir network partition, erken düşen bir TTL, ya da ileride bir replica failover’ında kaybolan bir kilit hepsi aynı bileti iki kez Booked yaptırabilir. Çünkü veritabanındaki yazma koşulsuz; "Available mı?" kontrolü orada yok. Bir tutarlılık garantisinin bütün ağırlığı bir cache'in omuzlarında duruyor.

  2. Önce para çekiliyor, sonra veritabanına yazılıyor. Sıra ödeme → transaction olduğu için, ödeme başarılı olup CommitAsync herhangi bir nedenle hata alırsa ortaya şu çıkıyor: para çekildi ama booking yok. Tam da kaçınmak istediğimiz "belki satıldı, belki satılmadı" durumu.

Özetle: doğruluk Redis’te durduğu ve yazma ödemeden önce yapıldığı sürece sistem, hem asla çökmeyecek bir Redis’e hem de hep happy-path akışında tam doğrulukta çalışıyordu.

Çözüm

Strateji üç cümlede özetlenebilir: garantiyi veritabanına ver, Redis’i hızlı-yola indir, ve kartı çekmeden önce koltuğu rezerve et.

Bunu üç yapı taşı hayata geçiriyor:

  1. Bilet durumu üzerinde atomik compare-and-set (CAS) tickets.status'a koşullu UPDATE. Aşırı satımın nihai karar merci artık veritabanı.
  2. Reserve → Pay → Confirm akışı — Reserved durumu devreye giriyor, ödeme öncesi kalıcı bir hold kuruluyor.
  3. Redis advisory olarak kalıyor, ama kesinti durumunda sorun olmayacak; çünkü doğruluk ona bağlı değil.

1) Atomik CAS: Koşullu UPDATE, ExecuteUpdateAsync

EF Core 8'in ExecuteUpdateAsync'i ile tek bir SQL UPDATE üretiyorum, koşulu doğrudan WHERE'in içine gömüyorum ve etkilenen satır sayısını geri alıyorum:

// RESERVE: Available VEYA süresi geçmiş Reserved → Reserved
public Task<int> TryReserveTicketsAsync(
  IReadOnlyCollection<Guid> ticketIds,
  Guid bookingId,
  DateTime reservedUntil,
  CancellationToken ct = default)
{
    var now = DateTime.UtcNow;
    return db.Set<Ticket>()
        .Where(t => ticketIds.Contains(t.Id) &&
              (t.Status == TicketStatus.Available ||
              (t.Status == TicketStatus.Reserved && t.ReservedUntil < now)))   // ← COMPARE
        .ExecuteUpdateAsync(s => s
            .SetProperty(t => t.Status, TicketStatus.Reserved)                  // ← SET
            .SetProperty(t => t.ReservedBy, bookingId)
            .SetProperty(t => t.ReservedUntil, reservedUntil)
            .SetProperty(t => t.UpdatedAt, now), ct);
}

// CONFIRM: yalnız BU booking'in tuttuğu Reserved → Booked
public Task<int> TryConfirmTicketsAsync(
  IReadOnlyCollection<Guid> ticketIds,
  Guid bookingId,
  CancellationToken ct = default) =>
    db.Set<Ticket>()
        .Where(t => ticketIds.Contains(t.Id) &&
           t.Status == TicketStatus.Reserved && t.ReservedBy == bookingId)
        .ExecuteUpdateAsync(s => s
            .SetProperty(t => t.Status, TicketStatus.Booked)
            .SetProperty(t => t.ReservedBy, (Guid?)null)
            .SetProperty(t => t.ReservedUntil, (DateTime?)null)
            .SetProperty(t => t.UpdatedAt, DateTime.UtcNow), ct);

// RELEASE: telafi — Reserved → Available (ödeme/confirm başarısızsa)

Üretilen SQL sorgusu şu şekilde oluyor.

UPDATE tickets SET "Status" = 'Reserved', ...
WHERE "Id" = ANY(@ids)
  AND ("Status" = 'Available' OR ("Status" = 'Reserved' AND "ReservedUntil" < @now));

Bunun aşırı satımı tek başına neden engellediği şurada: Postgres bu UPDATE’i satır kilidi altında işler. İki istek aynı bileti aynı anda hedeflerse serileşirler; ilki commit eder etmez ikincinin WHERE koşulu artık tutmaz ve sıfır satır günceller. Yani her bilet için her zaman tam olarak bir kazanan vardır. Çağıran taraf da kararı dönen etkilenen satır sayısıyla verir: sayı istenen bilet adedine eşit değilse transaction geri alınır ve 409 döner. Redis hiç devrede olmasa bile bu garanti yerinde durur.

2) Yeni akış — Reserve → Pay → Confirm

Endpoint artık iki ayrı transaction kullanıyor. Sebebi şu: ödeme harici ve yavaş bir işlem; bir veritabanı transaction’ını ödeme boyunca açık tutmak yanlış bir yaklaşım olurdu. Onun yerine hold’u kalıcı kılıp transaction’ı kapatıyoruz, ödemeyi açık transaction olmadan yapıyoruz, sonra ikinci bir transaction’da onaylıyoruz.

// 3) RESERVE (tx1) — otoriter ve all-or-nothing. Henüz kart çekilmedi.
await using (var tx = await db.Database.BeginTransactionAsync(ct))
{
    var reserved = await events.TryReserveTicketsAsync(ticketIds, bookingId, reservedUntil, ct);
    if (reserved != ticketIds.Count)
    {
        await tx.RollbackAsync(ct);                  // kısmi rezervasyonu geri al
        await ReleaseRedisAsync(locks, ticketIds, bookingId, lockedInRedis);
        return Results.Conflict(new { error = "ticket(s) not available" });   // kart çekilmeden 409
    }
    await tx.CommitAsync(ct);                         // kalıcı hold; ödeme boyunca DB kilidi tutulmaz
}

// 4) PAY. Başarısızsa hold'u serbest bırak.
var paid = await payments.ChargeAsync(req.PaymentDetails, total, ct);
if (!paid)
{
    await events.ReleaseReservationAsync(ticketIds, bookingId, ct);
    return Results.Problem("payment failed", statusCode: 402);
}

// 5) CONFIRM (tx2) — Reserved→Booked + booking kaydı, atomik.
await using (var tx = await db.Database.BeginTransactionAsync(ct))
{
    var confirmed = await events.TryConfirmTicketsAsync(ticketIds, bookingId, ct);
    if (confirmed != ticketIds.Count)
    {
        await tx.RollbackAsync(ct);
        await payments.RefundAsync(req.PaymentDetails, total, ct);   // hold ödeme sırasında düştüyse telafi
        await events.ReleaseReservationAsync(ticketIds, bookingId, ct);
        return Results.Conflict(new { error = "reservation expired during payment; refunded" });
    }
    db.Add(new Booking { Id = bookingId, /* ... */ Status = BookingStatus.Confirmed });
    await db.SaveChangesAsync(ct);
    await tx.CommitAsync(ct);
}

Yeni akışın bütün resmi şöyle:

yeni akış

yeni akış

Böylece koltuk artık kart çekilmeden önce rezerve ediliyor. Confirm başarısız olursa (hold, ödeme sürerken düşmüşse) refund ve release ile temiz bir rollback yapılıyor.

3) Redis’i karar merciinden indirmek

Bu tasarımın özü, başta vurguladığım nokta: Redis gitmiyor, sadece terfi tersine dönüyor. Artık o yalnızca bir hızlı-yol; yük altında çakışmaları erkenden eleyip veritabanına gereksiz iş gitmesini önlüyor. Ama doğruluğun dayanağı değil. Bu yüzden kesintisi de artık ölümcül değil tolore edilebilir.

4) Kendi kendini iyileştiren hold ve BackgorundJob

TryReserve'in WHERE'indeki ikinci şart olan Status == Reserved && ReservedUntil < now süresi geçmiş hold'ları geri kazanıyor. Sahibi ödemeyi tamamlamadan ortadan kaybolan bir koltuk, bir sonraki rezervasyon denemesinde anında yeniden satılabilir hâle geliyor; herhangi bir arka plan işine ihtiyaç olmadan. Peki ya o koltuğu kimse yeniden denemezse? İşte o terk edilmiş hold'lar için bir BackgroundService 30 saniyede bir Reserved && ReservedUntil < now → Available temizliği yapıyor ve koltukları tekrar aktif hale getiriyor.

Redis tamamen durdurulmuşken bile sistem ne çöktü ne de fazladan tek bir bilet sattı çünkü doğruluk artık veritabanında. Üstelik bu, “Redis’i çıkardık” demek değil; Redis açıkken de aynı sonucu alıyoruz. Fark, garantinin ona bağlı olmaktan çıkmış olması.


메타데이터
post_id
50f33ce86b71
slug
100-bileti-101-kez-satmamak-bölüm2-50f33ce86b71
url
https://medium.com/@ongguzel/100-bileti-101-kez-satmamak-b%C3%B6l%C3%BCm2-50f33ce86b71
canonical_url
https://medium.com/@ongguzel/100-bileti-101-kez-satmamak-b%C3%B6l%C3%BCm2-50f33ce86b71
author_url
https://medium.com/@ongguzel
status
ok
fetched_at
2026-06-24 11:06:28