Ana içeriğe geç
Kesit Python
İleri · Üretime hazırlık

Sistem tasarımı temelleri: ölçekleme, tutarlılık ve tasarım mülakatı

Gereksinim çıkarma ve kaba kapasite hesabından yük dengeleme, önbellek, replikasyon, tutarlı hash, CAP/PACELC, kuyruklar, idempotentlik ve circuit breaker'a; URL kısaltıcı ve Lezzet Kurye sipariş akışı tasarımlarına kadar sistem tasarımının yapı taşlarını Python simülasyonlarıyla öğrenirsin.

Uygulamada aç, 20 alıştırmayı çözÜcretsiz, hesap gerekmez, kurulum yok.

Bu sayfa dersin okuma sürümüdür. Metin bir kutuyu çalıştırmanı ya da adıma geçmeni söylüyorsa dersi uygulamada aç.

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ğerKullanım
Bir gün86 400 sn ≈ 105 snGünlük istek / 105 ≈ ortalama QPS
Tepe çarpanıOrtalamanın 2–10 katıYemek siparişinde akşam tepesi çok sivridir
Bellekten okumaMikrosaniyelerÖnbellek neden hızlı?
Aynı veri merkezinde ağ turu~0,5 msServisler arası çağrı maliyeti
SSD'den rastgele okuma~0,1 msVeritabanı indeks okuması
Kıtalar arası ağ turu~100–150 msCDN neden gerekli?
%99,9 erişilebilirlikYılda ~8,8 saat kesintiSLA 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}")
Mülakatta yüksek sesle hesaplaDeğerlendirici kesin sayıyı değil, varsayımlarını açıkça söyleyip yuvarlayarak ilerlemeni ister: "Günde 2 milyon, yaklaşık 105 saniye; ortalama 20 QPS... hayır, 2·106/105 = 20 değil, 2·106/8,64·104 ≈ 23". Kendi hatanı yakalaman artı puandır.

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 stratejisiNası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 olanUzun süren isteklerde
Hash (IP, kullanıcı)Aynı anahtar aynı sunucuyaYerel ö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; }
}
Kendi bilgisayarında deneNginx ve Docker Compose ile üç kopyalı bir FastAPI servisi kurmayı Docker ve dağıtım: konteyner, compose, bulut dersinde gördün. 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)
Önbelleğin iki zor problemi1) Geçersiz kılma (invalidation): fiyat değişince eski menü 30 saniye daha görünür; kabul edilebilir mi? Kritik veride yazınca anahtarı silmek gerekir. 2) Sürü etkisi (thundering herd / cache stampede): popüler bir anahtarın süresi dolunca yüzlerce istek aynı anda veritabanına koşar; çözüm kilit, erken yenileme ya da TTL'ye rastgele sapma eklemektir.
# 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.

VeriSeçimGerekçe
Cüzdan bakiyesi, ödemeTutarlı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 konumuDüşü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ışı

  1. Sipariş API isteği idempotency key ile alır; sepeti doğrular, siparişi olusturuldu durumunda yazar ve aynı işlemde outbox'a siparis_olustu olayını ekler.
  2. Ödeme servisi olayı tüketir, sağlayıcıyı circuit breaker arkasından çağırır; sonuç olayını yayınlar.
  3. Restoran servisi tablete bildirim gönderir; restoran onaylayınca durum hazirlaniyor olur.
  4. Kurye atama servisi yakındaki uygun kuryeleri (konum indeksi, ör. geohash) bulur, teklif gönderir; kabul edilince yolda.
  5. 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üreAdımDeğerlendirici neye bakar?
5 dkGereksinimleri netleştirDoğru soruları sordun mu, kapsamı daralttın mı?
5 dkKaba tahminQPS, depolama, okuma/yazma oranı mantıklı mı?
10 dkÜst düzey tasarım ve APIBileşenler ve veri akışı tutarlı mı?
15 dkDerinleşme (veri modeli, ölçekleme, darboğaz)Ödünleşimleri (trade-off) gerekçelendirebiliyor musun?
5 dkArızalar, izleme, özetTek arıza noktası, gözlemlenebilirlik, gelecekteki büyüme
Gerçek işteŞirketlerde tasarım genelde bir RFC ya da "tasarım belgesi" ile yürür: bağlam, hedefler ve hedef olmayanlar, alternatifler ve neden elendikleri, kapasite tahmini, geçiş planı, izleme ve geri dönüş planı. Ekip belgeyi yorumlar, ardından küçük bir prototip ve yük testiyle varsayımlar doğrulanır. En pahalı hatalar yanlış teknolojiden değil, sorulmamış bir gereksinimden ("iptal edilen siparişin parası kaç dakikada iade edilmeli?") çıkar.
Uzmanlaş

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.

Alıştırmalara geç (20)