← Blog
·12 blog.minutes

Her Geliştiricinin Bilmesi Gereken Sistem Tasarımı Temelleri

Yük dengeleme, önbellekleme, sharding, CDN'ler, mesaj kuyrukları, CAP teoremi, hız sınırlama — gerçek dünya örnekleri ve kullanabileceğiniz yapılandırmalarla basitleştirilmiş anlatım.

Her backend geliştiricisi eninde sonunda bir duvara çarpar. Uygulama dizüstü bilgisayarınızda gayet iyi çalışır. Üç eşzamanlı kullanıcıyla staging'de de iyi çalışır. Ardından üretime dağıtırsınız, aynı anda bin kişi gelir ve veritabanı çöker. API 5xx hataları döndürmeye başlar, frontend donar ve Slack'te birisi, telefonunuzu sabah 2'de titretecek ekran görüntüsünü paylaşır.

Sistem tasarımı, o duvarın diğer tarafında yaşayan şeydir. Bir oyuncak uygulamayı, gerçek trafiği çökmeden kaldırabilen bir üretim sisteminden ayıran desenler, ödünleşimler ve altyapı kararları kümesidir. İyi haber şu ki, temelleri anlamak için kıdemli bir altyapı mühendisi olmanıza gerek yok. Yaklaşık sekiz kavramı, bunların nasıl etkileşime girdiğini ve her birine ne zaman başvurmanız gerektiğini bilmeniz yeterlidir.

Bu makale, çalışan backend geliştiricileri için en önemli sistem tasarımı desenlerini kapsar. Her bölüm kavramı açıklar, pratik bir örnek gösterir ve benimsemeden önce değerlendirmeniz gereken ödünleşimleri tanımlar. Bunlar soyut ders kitabı kavramları değil — ölçeklenmesi gereken bir şey inşa ettiğinizde her seferinde başvuracağınız araçlardır.

Yük Dengeleme — Her Sunucuyu Meşgul Tut, Ama Çok Değil

Yük dengeleme, uygulayabileceğiniz en basit ve en etkili sistem tasarımı desenidir. Fikir basittir: tüm trafiği tek bir sunucuya göndermek yerine, gelen istekleri bir sunucu havuzuna dağıtırsınız. Bu size aynı anda iki şey verir: yüksek kullanılabilirlik (bir sunucu ölürse, diğerleri hizmet vermeye devam eder) ve yüksek verim (birden çok sunucu işi paylaşır).

En yaygın yük dengeleme algoritmaları round-robin, en az bağlantı ve IP hash'tir. Round-robin, sunucu listesinde sırayla döner — basit, öngörülebilir, ancak her sunucunun ne kadar meşgul olduğundan habersizdir. En az bağlantı, her isteği en az aktif bağlantıya sahip sunucuya gönderir ve bu, dengesiz istek yüklerini daha iyi yönetir. IP hash, istemcinin IP adresini kullanarak deterministik olarak bir sunucu seçer; bu, yapışkan oturumlara ihtiyaç duyduğunuzda önemlidir — aynı istemcinin her zaman aynı sunucuyu vurmasını sağlamak.

Pratikte, çoğu üretim kurulumu bir kombinasyon kullanır. Bir katman 4 yük dengeleyici (TCP düzeyinde çalışan), ham bağlantıları bir ters proxy veya API ağ geçidi havuzuna dağıtır; bunlar daha sonra istekleri belirli uygulama örneklerine yönlendirmek için katman 7 yük dengeleme (HTTP düzeyinde çalışan) gerçekleştirir. Bu katmanlı yaklaşım, veri düzlemini hızlı ve yönlendirme mantığını esnek tutar.

# nginx.conf — simple round-robin load balancing across three app servers
upstream app_cluster {
    round-robin;
    server app1.internal:3000 max_fails=3 fail_timeout=30s;
    server app2.internal:3000 max_fails=3 fail_timeout=30s;
    server app3.internal:3000 max_fails=3 fail_timeout=30s;
}

server {
    listen 443 ssl;
    location / {
        proxy_pass http://app_cluster;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Çoğu geliştiricinin kaçırdığı kritik detay, sağlık kontrolüdür. Bir yük dengeleyici, yalnızca hangi sunucuların sağlıklı olduğunu biliyorsa kullanışlıdır. app2 çökerse ancak yük dengeleyici ona trafik göndermeye devam ederse, hata oranınız yaklaşık üçte bir oranında artar. Her zaman aktif sağlık kontrolleri yapılandırın — yük dengeleyici periyodik olarak her sunucuya ping atar ve yanıt vermeyenleri otomatik olarak havuzdan çıkarır.

Önbellekleme — Hız, Ama Hangi Bedelle?

Önbellekleme, herhangi bir geliştiricinin kullanabileceği en yüksek kaldıraçlı performans optimizasyonudur. Tek bir önbellek isabeti, 200 milisaniyelik bir veritabanı sorgusunu 2 milisaniyelik bir bellek aramasına dönüştürebilir. İki kat büyüklük sırası. Milyonlarca istekte uygulandığında, bu fark aylık 10.000 dolarlık bir veritabanı faturası ile 500 dolarlık bir fatura arasındaki farktır.

Kanonik önbellekleme yığınının her biri farklı özelliklere sahip üç katmanı vardır. Uygulama düzeyinde önbellekleme (Redis veya Memcached gibi bellek içi önbellekler), pahalı hesaplamaların veya veritabanı sorgularının sonuçlarını saklar. CDN önbelleklemesi, statik ve yarı statik varlıkları kullanıcılara yakın uç konumlarda saklar. HTTP önbelleklemesi, tarayıcıların ve proxy'lerin sunucularınızı hiç dahil etmeden yanıtları önbelleklemesine izin vermek için Cache-Control ve ETag başlıklarını kullanır.

Önbelleklemenin en zor kısmı kurulumu değil — altta yatan veri değiştiğinde önbelleği geçersiz kılmaktır. Endüstri birkaç güvenilir desende karar kılmıştır. Cache-aside (tembel yükleme olarak da bilinir), uygulamanın önce önbelleği kontrol etmesi, isabetsizlikte veritabanına düşmesi ve sonucu önbelleğe doldurması anlamına gelir. Write-through önbellek, her yazmanın aynı anda hem önbelleğe hem de veritabanına gitmesi anlamına gelir. Write-behind önbellek, yazmaların önce önbelleğe gittiği ve veritabanına asenkron olarak aktarıldığı anlamına gelir.

Bilgisayar biliminde sadece iki zor şey vardır: önbellek geçersiz kılma ve adlandırma. Önbellek geçersiz kılma daha zordur çünkü yeniden adlandırıp geçmesini umamazsınız.

Çoğu uygulama için, cache-aside kısa bir yaşam süresi (TTL) ile doğru varsayılandır. Bayat veri toleransınıza uygun bir TTL belirleyin — kullanıcı profilleri için 60 saniye, ürün listeleri için 5 dakika, referans verileri için 24 saat. Daha güçlü tutarlılığa ihtiyacınız olduğunda, write-through önbellek kullanın ancak daha yüksek yazma gecikmesini kabul edin. Maksimum okuma verimine ihtiyacınız olduğunda ve nihai tutarlılığı tolere edebildiğinizde, cömert bir TTL ile cache-aside kullanın.

import redis.asyncio as aioredis
import json

cache = aioredis.Redis.from_url("redis://cache:6379")

CACHE_TTL = 300  # 5 minutes

async def get_user_profile(user_id: str) -> dict:
    key = f"profile:{user_id}"
    cached = await cache.get(key)
    if cached:
        return json.loads(cached)
    profile = await db.fetch_user(user_id)
    if profile:
        await cache.setex(key, CACHE_TTL, json.dumps(profile))
    return profile

Bir uyarı: önbellekleme, sorunları çözmek yerine maskeleyebilir. Veritabanı sorgularınız bir indeksi kaçırdığınız için yavaşsa, önbellek eklemek semptomu gizler ancak altta yatan sorgu hala yavaştır. Önbellek çıkarılır, yavaş sorgu çalışır, kullanıcı bekler. Önce yavaş yollarınızı her zaman profilleyin ve optimize edin, ardından optimize edilmiş versiyonun üzerine önbellek ekleyin.

Veritabanı Sharding — İş Bölüşümü ile Tek Bir Veritabanının Boğulmasını Önlemek

Sharding (yatay bölümleme), tek bir veritabanı örneğinin yazma veriminizi veya veri kümesi boyutunuzu kaldıramadığında başvurduğunuz şeydir. Fikir, verilerinizi birden çok veritabanı örneğine bölmektir; burada her örnek (shard), verilerin bir alt kümesini tutar. Uygulama, bir shard anahtarına — tipik olarak kullanıcı kimliğinin bir hash'i, coğrafi bölge veya zaman aralığı — dayanarak hangi shard'ı sorgulayacağını belirler.

Shard anahtarı, herhangi bir parçalanmış sistemdeki en önemli tek karardır. İyi bir shard anahtarı, verileri shard'lar arasında eşit olarak dağıtır ve sorgu modellerinizle uyumludur. Kötü bir shard anahtarı, sıcak shard'lar oluşturur — trafiğin çoğunu yöneten birkaç shard, geri kalanı boşta kalırken. Örneğin, oluşturma zaman damgasına göre shard'lamak makul görünür, ta ki bugünün shard'ının tüm yazmaları işlediğini, geçen yılın shard'larının ise hiçbirini işlemediğini fark edene kadar.

Tutarlı hashleme, basit shard'lamayı etkileyen yeniden dengeleme sorununu çözer. Basit bir modulo tabanlı shard'lama şemasında (shard = hash(anahtar) % N), yeni bir shard eklemek neredeyse tüm verilerin yeniden karıştırılmasını gerektirir. Tutarlı hashleme, hem anahtarları hem de shard'ları bir hash halkasına eşler; bir shard eklediğinizde, yalnızca yeni shard'ın yakın komşuluğundaki anahtarların taşınması gerekir. Bu, ölçeği büyütmeyi ve küçültmeyi çok daha az acı verici hale getirir.

  • Hash tabanlı shard'lama — shard anahtarını hash'leyerek dağıt; basit ve dengeli ama yeniden shard'lama pahalıdır
  • Aralık tabanlı shard'lama — değer aralıklarına göre böl (ör. ID'leri 1–10000 olan kullanıcılar shard A'da, 10001–20000 shard B'de); aralık sorguları için verimli ancak sıcak noktalara eğilimlidir
  • Dizin tabanlı shard'lama — anahtarları shard'lara eşleyen bir arama tablosu tut; esnek ancak bir arama adımı ve dizin düşerse tek hata noktası ekler
  • Coğrafi shard'lama — kullanıcı bölgesine göre böl; gecikme için mükemmel ancak kullanıcılar seyahat ederse veya verilerin küresel olması gerekiyorsa zordur

Shard'lama ile kabul ettiğiniz ödünleşim, shard'lar arası sorguların pahalı veya imkansız hale gelmesidir. Kullanıcılar tablonuzu user_id'ye ve siparişler tablonuzu user_id'ye göre shard'larsanız, son 30 gündeki tüm siparişler için bir sorgu her shard'ı vurmalıdır. Küresel analitik veya shard'lar arası birleştirmelere ihtiyaç duyan uygulamalar, genellikle tüm shard'lardan gelen verileri asenkron olarak toplayan ikincil bir okuma kopyası (veya özel bir analitik veritabanı) kullanır.

CAP Teoremi — Sadece İkisini Seçebilirsiniz

CAP teoremi, dağıtık bir veri deposunun aynı anda üç garantiden en fazla ikisini sağlayabileceğini belirtir: Tutarlılık (her okuma en son yazmayı alır), Kullanılabilirlik (her istek, en son veri olmasa bile bir yanıt alır) ve Bölüm Toleransı (sistem, düğümler arasındaki ağ arızalarına rağmen çalışmaya devam eder).

Pratikte, bölüm toleransı isteğe bağlı değildir. Ağlar başarısız olur. Paketler düşer, bağlantılar zaman aşımına uğrar, veri merkezlerinin gücü kesilir. Yani gerçek seçim CP (tutarlılık + bölüm toleransı) ve AP (kullanılabilirlik + bölüm toleransı) arasındadır. etcd veya Zookeeper gibi bir CP sistemi, düğümler arasında tutarlılığı garanti edemiyorsa okumaları sunmayı reddeder. Cassandra veya DynamoDB gibi bir AP sistemi, ulaşılabilir herhangi bir düğümden, o düğümde bayat veri olsa bile okumaları sunar.

Bu akademik bir ayrım değildir. Birden çok veri merkezine yayılan bir sistem tasarladığınızda, aralarındaki ağ bağlantısı koptuğunda ne olacağına karar vermelisiniz. Potansiyel olarak bayat veri ile istekleri sunmaya devam mı edersiniz (AP)? Yoksa ağ düzelene kadar hizmet vermeyi durdurur musunuz (CP)? Cevap uygulamanıza bağlıdır. Bir içerik dağıtım ağı AP olmalıdır — bayat içerik, hiç içerik olmamasından iyidir. Bir ödeme işleme sistemi CP olmalıdır — bölünmüşken iki düğüm aynı ödemeyi kabul ettiği için bir müşteriyi asla çift faturalandırmak istemezsiniz.

Mesaj Kuyrukları — Senkron Acıyı Asenkron Zarafete Dönüştürmek

Mesaj kuyrukları, dağıtık sistemlerde asenkron işlemenin bel kemiğidir. Bir hizmetin (üretici), tüketicinin işlemesini beklemeden bir kuyruğa mesaj göndermesine izin verirler. Tüketici, hazır olduğunda mesajı alır, işler ve tamamlandığını bildirir. Bu, üreticiyi tüketiciden hem zaman hem de mekan olarak ayırır — aynı hızda veya hatta aynı anda çalışmaları gerekmez.

Önemsiz olmayan her backend sistemi, bir yerde bir mesaj kuyruğu kullanmalıdır. Kanonik örnek, e-posta göndermektir. Bir kullanıcı platformunuza kaydolduğunda, HTTP yanıtının e-posta gönderme hizmetinin bir şablon oluşturmasını, SendGrid'e bağlanmasını ve mesajı iletmesini beklemesini istemezsiniz. Bunun yerine, API'niz bir send_email olayını bir kuyruğa iter ve hemen 201 Created yanıtı döndürür. Ayrı bir çalışan, olayı alır, e-postayı gönderir ve görevi tamamlanmış olarak işaretler.

İki baskın mesaj kuyruğu modeli, yayınla-abone ol (pub/sub) ve iş kuyruklarıdır. Pub/sub'da, her mesaj tüm abonelere yayınlanır. Bu, birden çok hizmetin aynı olaya tepki vermesi gereken olay odaklı mimariler için kullanışlıdır — yeni bir kullanıcı kaydı, aynı anda bir hoş geldiniz e-postası, bir CRM güncellemesi ve bir analitik olayını tetikleyebilir. Bir iş kuyruğunda, her mesaj tam olarak bir tüketiciye iletilir. Bu, işi bir çalışan havuzuna dağıtmak için kullanışlıdır — her resim yüklemesi tam olarak bir küçük resim oluşturucuya gider.

# docker-compose.yml — minimal RabbitMQ setup for local development
version: "3.8"
services:
  rabbitmq:
    image: rabbitmq:3-management-alpine
    ports:
      - "5672:5672"   # AMQP port for producers/consumers
      - "15672:15672" # Management UI
    environment:
      RABBITMQ_DEFAULT_USER: app
      RABBITMQ_DEFAULT_PASS: dev-only-password
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq

volumes:
  rabbitmq_data:

Mesaj kuyruklarının zor kısmı, arızaları zarif bir şekilde yönetmektir. Tüketici, bir mesajı işlemenin yarısında çökerse ne olur? RabbitMQ ve Amazon SQS bunu teslimat onayları ile halleder — tüketici, bir mesajın başarıyla işlendiğini açıkça onaylamalıdır. Tüketici onaylamadan bağlantıyı keserse, mesaj yeniden kuyruğa alınır ve başka bir tüketiciye iletilir. Bu en-az-bir-kere teslimat garantisi, tüketicilerinizin idempotent olması gerektiği anlamına gelir: aynı mesajı iki kez işlemek, bir kez işlemekle aynı sonucu üretmelidir.

Ölü mektup kuyrukları da bir diğer temel desendir. Bir mesaj, birkaç yeniden denemeden sonra işlenemezse (aşağı akış hizmeti kapalı, veri bozuk, iş kuralı değişti), mesaj sonsuza kadar yeniden denenmek yerine bir ölü mektup kuyruğuna taşınır. Bir operatör, ölü mektup kuyruğunu izler, kök nedeni araştırır ve ya mesajı düzeltip yeniden kuyruğa alır ya da yok saymanın güvenli olduğunu onayladıktan sonra atar.

CDN'ler ve Hız Sınırlama — Ön Cephe Savunmaları

İçerik dağıtım ağları ve hız sınırlayıcılar farklı amaçlara hizmet eder ancak ortak bir özelliği paylaşırlar: kullanıcılarınız ile sunucularınız arasındaki ilk savunma hattıdırlar. Bir CDN, statik varlıkları ve önbelleğe alınmış yanıtları kullanıcıya yakın tutar, gecikmeyi azaltır ve kaynak sunucularınızdan trafiği boşaltır. Bir hız sınırlayıcı, tek bir kullanıcının veya istemcinin sisteminizi isteklerle boğmasını önler.

CDN'ler, içeriğinizi küresel bir uç sunucu ağına dağıtarak çalışır. Tokyo'daki bir kullanıcı bir varlık istediğinde, CDN isteği Virginia'daki kaynak sunucunuza kadar yönlendirmek yerine en yakın uç konumdan sunar. Bu, statik varlıklar için gecikmeyi 200 milisaniyeden 10 milisaniyeye düşürür. Modern CDN'ler daha da ileri gider — API yanıtlarını önbelleğe alabilir, TLS bağlantılarını sonlandırabilir ve hatta uçta sunucusuz işlevler çalıştırabilir.

Hız sınırlama, sisteminizi birden çok düzeyde korur. Küresel hız sınırlama, tüm sisteminizin saniyede kaldırabileceği toplam istekleri sınırlandırarak trafik ani artışlarına ve DDoS saldırılarına karşı korur. Kullanıcı başına hız sınırlama, tek bir kötü niyetli kiracının diğer kullanıcıları kaynaklardan mahrum bırakmasını engeller. Uç nokta düzeyinde hız sınırlama, farklı rotalara farklı limitler uygular — bir giriş uç noktası dakikada 5 isteğe izin verirken salt okunur bir arama uç noktası dakikada 100 isteğe izin verebilir.

Kayan pencere algoritması, hem doğru hem de verimli olduğu için hız sınırlama için endüstri standardıdır. Sayaçları sabit aralıklarla sıfırlamak yerine (sınırda patlamalara izin verir), kayan pencere, istekleri kayan bir zaman penceresi üzerinde değerlendirir. Redis bunu uygulamak için doğal seçimdir — zaman damgalarını puan olarak kullanan sıralı bir küme kullanın, pencerenin dışındaki girişleri kırpın ve kalan girişleri sayın. Bellek maliyeti minimumdur (istek başına birkaç bayt) ve zaman karmaşıklığı logaritmiktir.

// Redis-backed sliding window rate limiter (TypeScript)
import { createClient } from "redis";

const redis = createClient({ url: "redis://ratelimit:6379" });

async function checkRateLimit(
  key: string,
  limit: number,
  windowMs: number
): Promise<{ allowed: boolean; remaining: number }> {
  const now = Date.now();
  const windowStart = now - windowMs;

  const multi = redis.multi();
  multi.zRemRangeByScore(key, 0, windowStart);
  multi.zAdd(key, { score: now, value: `${now}` });
  multi.zCard(key);
  multi.expire(key, Math.ceil(windowMs / 1000));

  const [, , count] = await multi.exec() as [any, any, number];
  return {
    allowed: count <= limit,
    remaining: Math.max(0, limit - count),
  };
}

Hem CDN'ler hem de hız sınırlayıcılar önemli bir operasyonel ilkeyi paylaşır: başarısızda açık mı yoksa başarısızda kapalı mı? CDN ucu kaynağa ulaşamazsa, bayat bir önbellek yanıtı mı sunmalı (başarısızda açık) yoksa bir hata mı döndürmeli (başarısızda kapalı)? Hız sınırlayıcının Redis kümesi düşerse, tüm istekler geçmeli mi (başarısızda açık — aşırı yük riski) yoksa tüm istekler reddedilmeli mi (başarısızda kapalı — garantili kesinti)?

Evrensel bir cevap yoktur, ancak çoğu sistem için iyi bir varsayılan şudur: okumalar için başarısızda açık, yazmalar için başarısızda kapalı. Bayat bir ürün listeleme sayfası kabul edilebilir. Kayıp bir satın alma siparişi değildir. Bu kararı runbook'unuzda açıkça belgeleyin, böylece nöbetçi mühendis altyapı zorlandığında hangi davranışı bekleyeceğini bilir.

Hepsini Bir Araya Koymak — Ödünleşimlerle Düşünmek

Sistem tasarımı, desenleri ezberlemekle ilgili değildir. Ödünleşimleri anlamak ve hangi desenin kısıtlamalarınıza uyduğunu tanımakla ilgilidir. Her karar, tutarlılık ve kullanılabilirlik, okuma verimi ve yazma gecikmesi, operasyonel karmaşıklık ve ham performans arasında bir ödünleşim içerir. En iyi mühendisler, en çok deseni bilenler değil, bir soruna bakıp hangi kısıtlamaların sabit ve hangilerinin pazarlık edilebilir olduğunu belirleyebilenlerdir.

İşte yeni bir sistem tasarımı problemiyle karşılaştığınızda kullanabileceğiniz hızlı bir karar çerçevesi. İşlevsel olmayan gereksinimlerinizi listeleyerek başlayın: beklenen trafik hacmi, gecikme hedefleri, tutarlılık gereksinimleri, altyapı maliyetleri için bütçe ve ekibin teknolojiye aşinalığı. Ardından bu makaledeki desenler üzerinde çalışın ve her birinin sizi gereksinimlerinize yaklaştırıp yaklaştırmadığını veya uzaklaştırıp uzaklaştırmadığını sorun.

Gecikme birincil endişenizse, önbellekleme ve bir CDN ile başlayın — en az karmaşıklık için en büyük iyileştirmeyi sağlarlar. Kullanılabilirlik kritikse, sağlık kontrolleri ile yük dengeleme kullanın, hizmetlerinizi durum bilgisi olmayacak şekilde tasarlayın ve işin izin verdiği yerde AP'yi CP'ye tercih edin. Yazma ağırlıklı iş yükleriyle uğraşıyorsanız, shard'lama ve mesaj kuyruklarını erken değerlendirin — milyonlarca veri satırınız olmadan önce bunları eklemek çok daha kolaydır. Üçüncü taraf geliştiricilerin tüketeceği genel bir API ile uğraşıyorsanız, ilk günden itibaren hız sınırlama uygulayın. Daha sonra eklemek, API'nizi sürümlemeniz veya mevcut istemcileri bozmanız anlamına gelir.

Sistem tasarımında en önemli beceri, neye ihtiyacınız olmadığını bilmektir. Çoğu uygulamanın shard'lamaya ihtiyacı yoktur. Çoğu uygulamanın bir mesaj kuyruğuna ihtiyacı yoktur. Erken dağıtma, sistem tasarımında tüm kötülüklerin köküdür — her dağıtık sistem, tek sunuculu bir sistemin sahip olmadığı hata modları ortaya çıkarır. Karmaşıklığı yalnızca metrikler size söylediğinde ekleyin, desen bir iş görüşmesinde etkileyici göründüğü için değil.

Basit başlayın. Her şeyi ölçün. Her seferinde bir desen ekleyin. Devam etmeden önce iyileştirmeyi doğrulayın. Hayatta kalan sistemler, en sofistike mimariye sahip olanlar değil — anlaşılması, işletilmesi ve bir sonraki darboğaz göründüğünde değiştirilmesi kolay olanlardır.