Neden sistem tasarımı?
Bir fonksiyonu doğru yazmak ile bir sistemi doğru kurmak farklı becerilerdir. Lezzet Kurye akşam 19:00'da binlerce sipariş alır; restoran tableti donarsa, ödeme sağlayıcısı yavaşlarsa ya da veritabanı diski dolarsa müşteri yalnızca "uygulama çalışmıyor" der. Sistem tasarımı (system design), bu tür yükleri ve arızaları önceden düşünüp bileşenleri (servis, veritabanı, önbellek, kuyruk) doğru yere koyma işidir. Orta ve kıdemli backend mülakatlarının neredeyse hepsinde 45 dakikalık bir tasarım oturumu vardır; işte de her yeni özellik küçük bir tasarım belgesiyle başlar.
Bu derste ezber mimari şemalar yerine hesap yapan ve simülasyon kuran bir yaklaşım izleyeceksin: kaba kapasite tahmini, tutarlı hash, önbellek, circuit breaker, idempotent ödeme ve sipariş durum makinesi gibi parçaları Python ile modelleyeceksin. Gerçek teknolojilerin (Redis, Kafka, PostgreSQL, Nginx) kodunu gri kutularda göreceksin; mantığını ise tarayıcıda çalışan sahte sınıflarla deneyeceksin.
1. Gereksinim çıkarma ve kaba tahmin
Tasarıma kutu çizerek değil soru sorarak başlanır. İşlevsel gereksinimler (functional requirements) sistemin ne yaptığıdır: "müşteri sipariş verir, kurye konum paylaşır". İşlevsel olmayan gereksinimler (non-functional requirements) ise nasıl yaptığıdır: gecikme hedefi, erişilebilirlik (availability), tutarlılık, maliyet. Ardından kaba tahmin (back-of-the-envelope estimation) gelir: saniyedeki istek (QPS, queries per second), depolama ve bant genişliği.
| Akılda tutulacak sayı | Yaklaşık değer | Kullanım |
|---|---|---|
| Bir gün | 86 400 sn ≈ 105 sn | Günlük istek / 105 ≈ ortalama QPS |
| Tepe çarpanı | Ortalamanın 2–10 katı | Yemek siparişinde akşam tepesi çok sivridir |
| Bellekten okuma | Mikrosaniyeler | Önbellek neden hızlı? |
| Aynı veri merkezinde ağ turu | ~0,5 ms | Servisler arası çağrı maliyeti |
| SSD'den rastgele okuma | ~0,1 ms | Veritabanı indeks okuması |
| Kıtalar arası ağ turu | ~100–150 ms | CDN neden gerekli? |
| %99,9 erişilebilirlik | Yılda ~8,8 saat kesinti | SLA pazarlığı |
GUNLUK_SIPARIS = 2_000_000 # hedef: günde 2 milyon sipariş
TEPE_CARPANI = 7 # akşam tepesi ortalamanın ~7 katı
OKUMA_ORANI = 20 # her siparişe ~20 okuma (menü, durum, takip)
KAYIT_KB = 2 # bir sipariş kaydı ~2 KB
SANIYE_GUN = 24 * 60 * 60
yazma_qps = GUNLUK_SIPARIS / SANIYE_GUN
tepe_yazma = yazma_qps * TEPE_CARPANI
tepe_okuma = tepe_yazma * OKUMA_ORANI
yillik_gb = GUNLUK_SIPARIS * KAYIT_KB * 365 / 1024 / 1024
print(f"Ortalama yazma : {yazma_qps:,.0f}/sn")
print(f"Tepe yazma : {tepe_yazma:,.0f}/sn")
print(f"Tepe okuma : {tepe_okuma:,.0f}/sn")
print(f"Yıllık veri : {yillik_gb:,.0f} GB")
Sonuçlar kesin değil, büyüklük mertebesi (order of magnitude) önemli: saniyede 160 yazma tek bir PostgreSQL sunucusu için rahattır; saniyede 3 000 okuma için ise önbellek ve okuma replikası düşünmeye başlarsın. Tepe çarpanını uydurmak yerine gerçek veriden ölçebilirsin. Lezzet Kurye'nin sipariş tablosunda saatlik dağılıma bakalım:
import veri
from collections import Counter
siparisler = veri.kayitlar("lezzet_kurye", "lk_orders")
saatlik = Counter(s["placed_at"][:13] for s in siparisler) # "2024-11-10 20"
gun_sayisi = len({s["placed_at"][:10] for s in siparisler})
ortalama = len(siparisler) / (gun_sayisi * 24)
tepe_saat, tepe = saatlik.most_common(1)[0]
print(f"{len(siparisler)} sipariş, {gun_sayisi} gün")
print(f"Saatlik ortalama {ortalama:.2f}, en yoğun saat {tepe_saat}: {tepe}")
print(f"Tepe çarpanı ≈ {tepe / ortalama:.1f}")
2. Ölçekleme: dikey, yatay ve durumsuz servis
Dikey ölçekleme (scale up) sunucuya daha çok CPU/RAM eklemektir: basit ama tavanı ve tek arıza noktası vardır. Yatay ölçekleme (scale out) aynı servisten çok kopya çalıştırıp önüne yük dengeleyici (load balancer) koymaktır. Yatay ölçeklemenin ön koşulu servisin durumsuz (stateless) olmasıdır: oturum, sepet ya da yüklenen dosya sunucunun belleğinde değil, ortak bir depoda (Redis, veritabanı, nesne deposu) durur. Böylece herhangi bir istek herhangi bir kopyaya gidebilir ve bir kopya ölünce kimse fark etmez.
| Dengeleme stratejisi | Nasıl seçer? | Ne zaman? |
|---|---|---|
| Sırayla (round robin) | Sıradaki sunucu | İstekler benzer maliyetteyse |
| En az bağlantı (least connections) | Açık isteği en az olan | Uzun süren isteklerde |
| Hash (IP, kullanıcı) | Aynı anahtar aynı sunucuya | Yerel önbellek isabeti önemliyse |
# nginx.conf — üç uygulama kopyasının önünde yük dengeleyici
upstream siparis_api {
least_conn;
server 10.0.0.11:8000 max_fails=3 fail_timeout=10s;
server 10.0.0.12:8000 max_fails=3 fail_timeout=10s;
server 10.0.0.13:8000 max_fails=3 fail_timeout=10s;
}
server {
listen 80;
location / { proxy_pass http://siparis_api; proxy_read_timeout 5s; }
}
docker compose up --scale api=3 ile kopyaları artırıp yukarıdaki yapılandırmayı deneyebilirsin.3. Önbellek katmanları ve CDN
Okuma yazmadan çok fazlaysa önbellek (cache) ilk silahtır. En yaygın kalıp cache-aside (tembel yükleme): uygulama önce önbelleğe bakar, yoksa veritabanından okur ve önbelleğe TTL (yaşam süresi) ile yazar. Statik dosyalar (menü fotoğrafları, JS) kullanıcıya yakın sunuculardan, yani CDN'den (Content Delivery Network) servis edilir. Katmanlar: tarayıcı → CDN → uygulama içi bellek → dağıtık önbellek (Redis) → veritabanı.
class TTLOnbellek:
def __init__(self, ttl, saat):
self.ttl, self.saat, self.veri = ttl, saat, {}
self.isabet = self.kacirma = 0
def al(self, anahtar, yukle):
simdi = self.saat()
kayit = self.veri.get(anahtar)
if kayit and simdi - kayit[1] < self.ttl:
self.isabet += 1
return kayit[0]
self.kacirma += 1
deger = yukle(anahtar) # veritabanına git
self.veri[anahtar] = (deger, simdi)
return deger
zaman = [0, 1, 2, 31, 32]
saat = iter(zaman).__next__
menu = TTLOnbellek(ttl=30, saat=saat)
for _ in zaman:
menu.al("restoran:5", lambda k: f"{k} menüsü (DB)")
print("isabet:", menu.isabet, "kaçırma:", menu.kacirma)
# Gerçek dünyada: redis-py ile cache-aside (pip install redis)
import json, redis
r = redis.Redis(host="localhost", port=6379)
def menu_getir(restoran_id, db):
anahtar = f"menu:{restoran_id}"
if (veri := r.get(anahtar)) is not None:
return json.loads(veri)
menu = db.menu_oku(restoran_id)
r.set(anahtar, json.dumps(menu), ex=300) # 5 dk TTL
return menu
4. Veri katmanı: replikasyon, sharding ve tutarlı hash
Replikasyon (replication) verinin kopyalarını tutmaktır. Lider-takipçi (leader-follower) düzeninde yazmalar lidere gider, okumalar takipçilere dağıtılır. Takipçiler birkaç milisaniye-saniye geride kalabilir (replication lag): müşteri adresini güncelleyip sayfayı yenilediğinde eski adresi görebilir. Bunun yaygın çözümü "kendi yazdığını oku" (read-your-writes) kuralıdır: kullanıcı yeni yazdıysa bir süre onun okumalarını lidere yönlendir.
Sharding (bölümleme) veri tek makineye sığmadığında veriyi anahtara göre parçalara ayırmaktır. Aralık bazlı (A–M, N–Z) bölümleme sıcak noktalar üretebilir; hash bazlı bölümleme dengelidir. Ama naif hash(anahtar) % N yönteminin büyük bir kusuru var: sunucu sayısı değişince anahtarların neredeyse tamamı yer değiştirir. Tutarlı hash (consistent hashing) anahtarları ve sunucuları aynı halkaya yerleştirir; her anahtar saat yönünde ilk sunucuya gider. Yeni sunucu eklenince yalnız onun komşu dilimindeki anahtarlar taşınır. Dengeyi sağlamak için her sunucu halkaya birçok sanal düğüm (virtual node) ile konur.
import hashlib, bisect
def ozet(s):
return int(hashlib.md5(s.encode()).hexdigest(), 16) # süreçten bağımsız, kararlı
anahtarlar = [f"siparis-{i}" for i in range(3000)]
mod3 = {a: ozet(a) % 3 for a in anahtarlar}
mod4 = {a: ozet(a) % 4 for a in anahtarlar}
print("mod N ile taşınan:", sum(mod3[a] != mod4[a] for a in anahtarlar))
def halka_kur(dugumler, sanal=100):
halka = sorted((ozet(f"{d}#{i}"), d) for d in dugumler for i in range(sanal))
return [h for h, _ in halka], [d for _, d in halka]
def bul(halka, anahtar):
noktalar, sahipler = halka
i = bisect.bisect(noktalar, ozet(anahtar)) % len(noktalar)
return sahipler[i]
h3, h4 = halka_kur("ABC"), halka_kur("ABCD")
tasinan = [a for a in anahtarlar if bul(h3, a) != bul(h4, a)]
print("tutarlı hash ile taşınan:", len(tasinan), "→ hepsi", set(bul(h4, a) for a in tasinan))
3 000 anahtarın mod N ile yaklaşık dörtte üçü taşınırken tutarlı hash ile yalnız yeni düğüme düşen ~dörtte biri taşınır. Cassandra, DynamoDB ve birçok dağıtık önbellek istemcisi bu fikri kullanır.
CAP ve PACELC
CAP teoremi: ağ bölünmesi (Partition) olduğunda sistem ya tutarlılık (Consistency: herkes son yazılanı görür) ya da erişilebilirlik (Availability: her istek cevap alır) seçer. PACELC bunu tamamlar: bölünme yokken (Else) bile gecikme (Latency) ile tutarlılık arasında seçim yaparsın.
| Veri | Seçim | Gerekçe |
|---|---|---|
| Cüzdan bakiyesi, ödeme | Tutarlılık (CP) | Çift harcama kabul edilemez; gerekirse hata ver |
| Restoran puanı, beğeni sayısı | Erişilebilirlik (AP) | Birkaç saniye eski olması sorun değil |
| Kurye konumu | Düşük gecikme (EL) | 2 saniye eski konum, bekleyen bir cevaptan iyidir |
5. Kuyruklar ve olay güdümlü mimari
Senkron iletişimde servis A, B'nin cevabını bekler (HTTP/gRPC). Asenkron iletişimde A bir mesajı kuyruğa (RabbitMQ, Kafka, Redis Streams, SQS) bırakır ve devam eder; B kendi hızında tüketir. Sipariş verildiğinde müşteriye "alındı" demek için SMS, fatura, puan ve analitik işlerinin bitmesini beklemek gerekmez: "siparis_olustu" olayı (event) yayınlanır, her servis kendi işini yapar. Bu olay güdümlü (event-driven) mimari servisleri birbirinden ayırır ve tepe yükü kuyrukta biriktirerek yumuşatır.
Bedeli: mesajlar en az bir kez (at-least-once) teslim edilir, yani aynı olay iki kez gelebilir. Bu yüzden tüketiciler idempotent olmalıdır (aynı olayı ikinci kez işlemek sonucu değiştirmemeli). Ödeme gibi dış çağrılarda istemci her isteğe bir idempotency key ekler; sunucu aynı anahtarı gördüğünde ilk sonucu döndürür. Veritabanına yazıp olayı yayınlamak arasında çökme olursa olay kaybolmasın diye olaylar aynı işlemde bir "giden kutusu" (outbox) tablosuna yazılır ve ayrı bir süreç onları kuyruğa aktarır (transactional outbox).
6. Dayanıklılık: zaman aşımı, retry, circuit breaker, backpressure
Dağıtık sistemde bir bağımlılık er geç yavaşlar. HTTP ve REST API istemcisi: requests, httpx ve dayanıklı istekler dersinde gördüğün zaman aşımı (timeout) ve üstel geri çekilmeli yeniden deneme (exponential backoff + jitter) ilk savunmadır. Ama ödeme sağlayıcısı tamamen çökmüşse her isteği 3 kez denemek yükü üçe katlar. Circuit breaker (sigorta) üst üste hatalardan sonra "açılır" ve bir süre çağrıyı hiç yapmadan hızlıca hata döndürür; süre dolunca "yarı açık" duruma geçip tek bir deneme isteğine izin verir; başarılıysa kapanır, değilse yeniden açılır.
class SigortaAcik(Exception):
pass
class Sigorta:
def __init__(self, esik=3, bekleme=10):
self.esik, self.bekleme = esik, bekleme
self.durum, self.hata, self.acilma = "kapali", 0, None
def cagir(self, fn, t):
if self.durum == "acik" and t - self.acilma >= self.bekleme:
self.durum = "yari_acik"
if self.durum == "acik":
raise SigortaAcik("bağımlılık dinleniyor")
try:
sonuc = fn()
except Exception:
self.hata += 1
if self.durum == "yari_acik" or self.hata >= self.esik:
self.durum, self.acilma = "acik", t
raise
self.durum, self.hata = "kapali", 0
return sonuc
def odeme_coktu():
raise ConnectionError("ödeme servisi cevap vermiyor")
s = Sigorta(esik=3, bekleme=10)
for t, fn in [(0, odeme_coktu), (1, odeme_coktu), (2, odeme_coktu), (3, odeme_coktu), (13, lambda: "onay")]:
try:
print(t, s.cagir(fn, t), s.durum)
except Exception as e:
print(t, type(e).__name__, s.durum)
Backpressure (geri basınç) üreticinin tüketiciden hızlı olduğu durumda sistemin "yavaşla" diyebilmesidir: sınırlı kuyruk dolunca yeni işi reddetmek (HTTP 429/503), hız sınırlama (rate limiting, ör. token bucket) ya da üreticiyi bekletmek. Sınırsız kuyruk backpressure değildir; yalnızca belleğin dolmasını erteler. Bulkhead (bölme) ise farklı bağımlılıklar için ayrı bağlantı havuzları kullanarak birinin tükenmesinin diğerlerini boğmasını engeller.
7. Örnek tasarımlar
URL kısaltıcı
Okuma ağırlıklıdır (bir link milyonlarca kez açılır). Kısa kod, artan bir sayacın base62 gösterimidir (0-9, a-z, A-Z): 7 karakter 627 ≈ 3,5 trilyon link demektir. Kod → URL eşlemesi anahtar-değer deposunda tutulur, popüler kodlar önbellekte durur, yönlendirme 301/302 ile yapılır. Sayacın tek arıza noktası olmaması için her sunucuya sayı blokları dağıtılır.
ALFABE = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def kodla(n):
if n == 0:
return ALFABE[0]
parcalar = []
while n:
n, kalan = divmod(n, 62)
parcalar.append(ALFABE[kalan])
return "".join(reversed(parcalar))
for sayi in [61, 62, 125, 3_521_614_606_207]:
print(sayi, "→", kodla(sayi))
Bildirim servisi
Diğer servisler "şu kullanıcıya şu şablonla bildir" olayı yayınlar. Bildirim servisi kullanıcı tercihlerine (SMS mi, push mu, sessiz saatler) bakar, kanal başına ayrı kuyruklara dağıtır, sağlayıcı hız sınırlarına uyar, başarısızları geri çekilmeyle yeniden dener ve olay numarasıyla tekrarları eler (aynı SMS'in iki kez gitmesi müşteri şikâyetidir).
Lezzet Kurye sipariş akışı
- Sipariş API isteği idempotency key ile alır; sepeti doğrular, siparişi
olusturuldudurumunda yazar ve aynı işlemde outbox'asiparis_olustuolayını ekler. - Ödeme servisi olayı tüketir, sağlayıcıyı circuit breaker arkasından çağırır; sonuç olayını yayınlar.
- Restoran servisi tablete bildirim gönderir; restoran onaylayınca durum
hazirlaniyorolur. - Kurye atama servisi yakındaki uygun kuryeleri (konum indeksi, ör. geohash) bulur, teklif gönderir; kabul edilince
yolda. - Teslimde
teslim; her geçiş bir olaydır. Bildirim, analitik ve puanlama servisleri bu olayları bağımsız tüketir.
Durumlar arasındaki izinli geçişleri bir durum makinesi (state machine) ile açıkça tanımlamak, "teslim edilmiş sipariş iptal edildi" gibi imkânsız durumları kod düzeyinde engeller.
8. Tasarım mülakatı: format ve rubrik
| Süre | Adım | Değerlendirici neye bakar? |
|---|---|---|
| 5 dk | Gereksinimleri netleştir | Doğru soruları sordun mu, kapsamı daralttın mı? |
| 5 dk | Kaba tahmin | QPS, depolama, okuma/yazma oranı mantıklı mı? |
| 10 dk | Üst düzey tasarım ve API | Bileşenler ve veri akışı tutarlı mı? |
| 15 dk | Derinleşme (veri modeli, ölçekleme, darboğaz) | Ödünleşimleri (trade-off) gerekçelendirebiliyor musun? |
| 5 dk | Arızalar, izleme, özet | Tek arıza noktası, gözlemlenebilirlik, gelecekteki büyüme |
Dağıtık işlemler ve saga
Sipariş, ödeme ve stok farklı veritabanlarındaysa tek bir ACID işlem yoktur. Saga kalıbı her adımı yerel bir işlem olarak yapar ve hata olursa önceki adımları telafi eden işlemlerle (ödemeyi iade et, stoğu geri ekle) geri alır. Koreografi (olaylarla) ya da orkestrasyon (merkezi bir yönetici) ile kurulur.
CQRS ve okuma modelleri
Yazma modeli normalleştirilmiş kalırken okuma için olaylardan beslenen ayrı, sorguya hazır tablolar (materialized view) tutulur. Restoran paneli "bugünkü siparişlerim" sorgusunu ağır JOIN'ler yerine hazır bir tablodan okur. Bedeli: okuma modeli birkaç saniye gecikmeli tutarlıdır (eventual consistency).
Ölçmeden ölçekleme
Tasarım varsayımlarını yük testiyle (Locust, k6) doğrula, p50/p95/p99 gecikmeyi izle; gozlemlenebilirlik yaklaşımıyla SLO'ları tanımla. Çoğu sistem ilk gün tek bir veritabanı ve birkaç uygulama kopyasıyla gayet iyi çalışır; sharding'i gerçekten gerektiğinde, ölçüme dayanarak ekle.