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.
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ış
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.
-
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
Bookedyaptı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. -
Önce para çekiliyor, sonra veritabanına yazılıyor. Sıra
ödeme → transactionolduğu için, ödeme başarılı olupCommitAsyncherhangi 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:
- 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ı. - Reserve → Pay → Confirm akışı —
Reserveddurumu devreye giriyor, ödeme öncesi kalıcı bir hold kuruluyor. - 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ış
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