Projektowanie systemów
Kompendium projektowania systemów rozproszonych dla inżynierów senior: load balancing, cache'owanie, CAP theorem, kolejki, replikacja, sharding, mikroserwisy i odporność na awarie.
- Cache invalidation: cache-aside, write-through i realne pułapki spójności
- CAP theorem: dlaczego partition tolerance nie jest opcjonalne, i jak wybrać CP vs AP
- Consistent hashing w load balancingu i shardingu baz danych
- Idempotency keys i circuit breaker jako obrona przed kaskadowymi awariami
- Retry z exponential backoff i jitter zamiast naiwnego retry
- Monolit vs mikroserwisy: kiedy podział faktycznie się opłaca
1 · Load balancing: rozdzielanie ruchu
★ egzaminLoad balancer to punkt wejścia rozpraszający ruch po wielu instancjach backendu. Dobór algorytmu decyduje o tym, jak system znosi nierówne obciążenie, awarie węzłów i skalowanie w górę/w dół bez przestojów.
import bisect
import hashlib
class ConsistentHashRing:
def __init__(self, nodes: list[str] | None = None, virtual_nodes: int = 150):
self.virtual_nodes = virtual_nodes
self.ring: dict[int, str] = {}
self.sorted_keys: list[int] = []
for node in nodes or []:
self.add_node(node)
def _hash(self, key: str) -> int:
return int(hashlib.sha256(key.encode()).hexdigest(), 16)
def add_node(self, node: str) -> None:
for i in range(self.virtual_nodes):
h = self._hash(f"{node}#{i}")
self.ring[h] = node
bisect.insort(self.sorted_keys, h)
def remove_node(self, node: str) -> None:
for i in range(self.virtual_nodes):
h = self._hash(f"{node}#{i}")
del self.ring[h]
self.sorted_keys.remove(h)
def get_node(self, key: str) -> str:
if not self.ring:
raise RuntimeError("empty ring")
h = self._hash(key)
idx = bisect.bisect(self.sorted_keys, h) % len(self.sorted_keys)
return self.ring[self.sorted_keys[idx]] - Active health check: LB sam odpytuje endpoint /healthz w interwałach i wycofuje węzeł po N kolejnych porażkach.
- Passive health check: LB obserwuje realne odpowiedzi z ruchu produkcyjnego (timeouty, 5xx) i wycofuje węzeł bez dodatkowego ruchu kontrolnego.
- Próg histerezy: osobne liczniki dla wycofania (fail threshold) i przywrócenia (recovery threshold), żeby uniknąć oscylacji węzła wchodzącego i wychodzącego z rotacji.
| Algorytm | Kryterium wyboru serwera | Zachowanie przy zmianie liczby węzłów | Typowe zastosowanie |
|---|---|---|---|
| Round robin | Kolejność cykliczna (+ wagi) | Przelicza całą rotację, brak stanu do zachowania | Jednorodne, krótkotrwałe requesty (proste API) |
| Least connections | Liczba aktywnych połączeń | Adaptuje się automatycznie, nowy węzeł szybko dostaje ruch | Zróżnicowany czas przetwarzania (WebSocket, długie zapytania) |
| Consistent hashing | Pozycja na pierścieniu hashy | Przemieszcza tylko K/N kluczy | Sharding cache/bazy, routing sesji, CDN |
2 · Cache'owanie i inwalidacja
★ egzaminCache skraca ścieżkę do danych kosztem dodatkowej kopii stanu, którą trzeba utrzymać w spójności ze źródłem prawdy. Wybór wzorca zapisu (cache-aside, write-through, write-back) determinuje, gdzie leży ta odpowiedzialność i jaki jest koszt błędu.
def get_user(user_id: int) -> dict:
cached = redis_client.get(f"user:{user_id}")
if cached is not None:
return json.loads(cached)
user = db.query_user(user_id)
redis_client.setex(f"user:{user_id}", 300, json.dumps(user))
return user
def update_user(user_id: int, data: dict) -> None:
db.update_user(user_id, data)
redis_client.delete(f"user:{user_id}") # invalidate, nie update - TTL (time-to-live): wpis wygasa automatycznie po czasie, prostota kosztem okna niespójności równego TTL.
- Explicit delete-on-write: aplikacja jawnie usuwa/aktualizuje wpis przy każdym zapisie źródła prawdy (patrz cache-aside).
- Event-driven invalidation: zmiana w bazie publikuje zdarzenie (np. przez message queue), subskrybenci czyszczą swoje lokalne cache, przydatne przy wielu warstwach cache.
- Versioned/keyed cache busting: klucz cache zawiera wersję danych (np. user:42:v7), stara wersja po prostu przestaje być odczytywana, nie trzeba jej aktywnie usuwać.
| Wzorzec | Latencja odczytu (hit) | Latencja zapisu | Ryzyko przy awarii | Typowe zastosowanie |
|---|---|---|---|---|
| Cache-aside | Niska (hit) / wysoka (miss) | Niska (tylko baza + invalidate) | Brak utraty danych, cache to tylko kopia | Ogólnego przeznaczenia, najpopularniejszy |
| Write-through | Niska | Wysoka (dwa zapisy) | Brak utraty danych, zawsze spójny | Dane wymagające silnej spójności odczyt/zapis |
| Write-back | Niska | Bardzo niska | Utrata niezapisanych danych przy awarii cache | Liczniki, metryki, dane tolerujące utratę |
3 · Skalowanie: horyzontalne vs wertykalne
Skalowanie to odpowiedź na rosnące obciążenie: albo dokłada się mocy do istniejącej maszyny (vertical), albo dokłada się kolejne maszyny obsługujące ten sam ruch równolegle (horizontal). Wybór determinuje architekturę aplikacji od pierwszego dnia.
# Źle: stan sesji trzymany lokalnie w procesie, nie przetrwa restartu, nie działa przy wielu instancjach
sessions: dict[str, dict] = {}
def login(user_id: str) -> str:
token = generate_token()
sessions[token] = {"user_id": user_id}
return token
# Dobrze: stan sesji wyniesiony do Redis, dowolna instancja może go odczytać
def login_stateless(user_id: str) -> str:
token = generate_token()
redis_client.setex(f"session:{token}", 3600, json.dumps({"user_id": user_id}))
return token - Wynieść sesję użytkownika i stan lokalny do współdzielonego magazynu (Redis, baza).
- Dodać load balancer i health checki przed instancjami.
- Zapewnić idempotentność operacji, bo requesty mogą trafić do różnych instancji przy retry.
- Wprowadzić service discovery lub DNS-based routing, żeby nowe instancje automatycznie dostawały ruch.
| Kryterium | Vertical scaling | Horizontal scaling |
|---|---|---|
| Limit skalowania | Twardy (fizyczny sufit maszyny) | Teoretycznie brak (dodaj kolejny węzeł) |
| Odporność na awarię | SPOF, restart = przestój | Redundancja, awaria jednego węzła nie wyłącza usługi |
| Złożoność wdrożenia | Niska, brak zmian w kodzie | Wymaga bezstanowości i koordynacji |
| Typowy koszt przy skali | Rośnie nieliniowo (droższy hardware najwyższej klasy) | Rośnie liniowo (więcej tanich instancji) |
- 1 Zidentyfikuj stan w procesie Znajdź sesje, cache w pamięci, liczniki trzymane lokalnie w instancji.
- 2 Wynieś stan na zewnątrz Przenieś sesję/cache do Redis lub bazy współdzielonej między instancjami.
- 3 Dodaj load balancer i health checki Skonfiguruj rozkładanie ruchu i automatyczne wycofywanie niezdrowych instancji.
- 4 Włącz autoscaling Dodawaj/usuwaj instancje na podstawie metryk obciążenia (CPU, długość kolejki żądań).
4 · CAP theorem: realny wybór
★ egzaminCAP theorem opisuje twardy kompromis w systemach rozproszonych: podczas partycji sieci nie da się mieć jednocześnie pełnej spójności i pełnej dostępności. Zrozumienie, co system faktycznie robi podczas awarii sieci, to podstawa świadomego wyboru bazy danych.
def quorum_write(replicas: list["Node"], key: str, value: "VersionedValue", w: int) -> bool:
acks = 0
for node in replicas:
try:
node.write(key, value)
acks += 1
except NodeUnavailable:
continue
return acks >= w
def quorum_read(replicas: list["Node"], key: str, r: int) -> "VersionedValue":
responses = []
for node in replicas:
try:
responses.append(node.read(key))
except NodeUnavailable:
continue
if len(responses) < r:
raise QuorumNotReached(f"got {len(responses)}, need {r}")
return max(responses, key=lambda v: v.version)
# W + R > N gwarantuje, że każdy odczyt widzi co najmniej jedną replikę z najnowszym zapisem - PACELC rozszerza CAP: Else (bez partycji) system i tak wybiera między Latency a Consistency, tradeoff nie znika w normalnej pracy.
- Wiele systemów NoSQL (Cassandra, DynamoDB) pozwala tunować W i R per-zapytanie, przesuwając się płynnie między CP i AP zamiast trzymać się jednego skrajnego trybu.
- Silna spójność (linearizability) nie jest tym samym co spójność transakcyjna ACID: CAP mówi o widoczności zapisów między węzłami, nie o izolacji transakcji.
Systemy CP (np. ZooKeeper, HBase, etcd)
- Odmawiają odpowiedzi przy braku kworum zamiast zwrócić nieaktualne dane.
- Priorytet: spójność ponad dostępność.
- Typowe zastosowanie: koordynacja rozproszona, leader election, konfiguracja.
Systemy AP (np. Cassandra, DynamoDB)
- Zawsze odpowiadają, nawet z węzła, który stracił łączność z resztą klastra.
- Priorytet: dostępność ponad natychmiastową spójność (eventual consistency).
- Typowe zastosowanie: dane o wysokiej tolerancji na chwilową niespójność (liczniki, katalogi produktów, sesje).
5 · Kolejki komunikatów i przetwarzanie asynchroniczne
Kolejki komunikatów odsprzęgają producenta zdarzenia od konsumenta, który je przetwarza, w czasie i w tempie. To kluczowy budulec architektury event-driven i głównego narzędzia do wygładzania skoków obciążenia (load leveling).
import boto3
sqs = boto3.client("sqs")
QUEUE_URL = "https://sqs.eu-central-1.amazonaws.com/123456789/orders"
def publish_order_created(order_id: str) -> None:
sqs.send_message(
QueueUrl=QUEUE_URL,
MessageBody=json.dumps({"order_id": order_id}),
MessageAttributes={"event": {"StringValue": "order.created", "DataType": "String"}},
)
def process_orders() -> None:
while True:
response = sqs.receive_message(
QueueUrl=QUEUE_URL, MaxNumberOfMessages=10, WaitTimeSeconds=20
)
for msg in response.get("Messages", []):
handle_order_event(json.loads(msg["Body"]))
sqs.delete_message(QueueUrl=QUEUE_URL, ReceiptHandle=msg["ReceiptHandle"]) - Load leveling: kolejka buforuje skok ruchu, konsument przetwarza w stałym, przewidywalnym tempie.
- Fan-out: jedno zdarzenie (np. 'order.created') trafia do wielu niezależnych subskrybentów (fakturowanie, magazyn, e-mail) bez sprzęgania ich ze sobą.
- Retry z dead letter queue (DLQ): wiadomość, która wielokrotnie zawiodła przetwarzanie, trafia do osobnej kolejki do ręcznej analizy, zamiast blokować kolejkę główną w nieskończoność.
| Model | Kto odbiera wiadomość | Przechowywanie historii | Przykład |
|---|---|---|---|
| Kolejka point-to-point | Jeden konsument z puli (competing consumers) | Wiadomość znika po ack | SQS, RabbitMQ (queue) |
| Pub/sub | Każdy subskrybent tematu dostaje kopię | Zwykle brak retencji po dostarczeniu | SNS, RabbitMQ (exchange fanout) |
| Log-based streaming | Wielu konsumentów czyta niezależnie ten sam log | Wiadomości retencjonowane przez czas/rozmiar, można przeczytać ponownie | Kafka, Kinesis |
6 · Replikacja i sharding baz danych
★ egzaminGdy jedna baza danych przestaje wystarczać, są dwie ortogonalne odpowiedzi: replikacja (wiele kopii tych samych danych, dla odczytu i odporności) i sharding (podział danych na rozłączne fragmenty, dla zapisu i pojemności). Realne systemy zwykle łączą oba.
import hashlib
def shard_for_user(user_id: int, shard_count: int) -> int:
# hash-based: hashlib zamiast wbudowanego hash() (Python losuje hash() dla str/bytes per proces)
digest = hashlib.sha256(str(user_id).encode()).digest()
return int.from_bytes(digest[:8], "big") % shard_count
def shard_for_order(order_date: str, boundaries: list[str]) -> int:
# range-based: zakresy dat jako granice shardów, ułatwia range scan po dacie
for shard_index, boundary in enumerate(boundaries):
if order_date < boundary:
return shard_index
return len(boundaries) - Single-leader: jeden punkt zapisu, prosty model konfliktów, klasyczny wybór domyślny (Postgres, MySQL).
- Multi-leader: wiele punktów zapisu (np. per region), niska latencja lokalna, koszt rozwiązywania konfliktów.
- Leaderless (quorum-based): każdy węzeł przyjmuje zapisy i odczyty, spójność regulowana parametrami W/R (patrz CAP i kworum).
| Strategia | Rozkład danych | Range queries | Ryzyko |
|---|---|---|---|
| Hash-based | Równomierny (hash klucza) | Trudne/niemożliwe efektywnie | Niskie ryzyko hotspotu przy dobrym kluczu |
| Range-based | Wg zakresu wartości klucza (np. daty) | Naturalnie wspierane | Wysokie ryzyko hotspotu na najnowszym zakresie |
| Directory-based | Jawna mapa klucz→shard w osobnej usłudze | Zależne od implementacji mapy | Dodatkowy punkt awarii (usługa mapująca), ale elastyczne przenoszenie danych |
7 · Monolit vs mikroserwisy: realny tradeoff
Wybór między monolitem a mikroserwisami to nie kwestia mody, tylko tradeoff między prostotą operacyjną a niezależnością skalowania/wdrażania. Dobra odpowiedź zależy od skali zespołu i produktu, nie od tego, co aktualnie promuje się na konferencjach.
Monolit
- Jeden proces/deployment, prosty debugging (jeden stack trace).
- Transakcje ACID w obrębie jednej bazy, bez rozproszonej spójności.
- Wolniejsze wdrażanie całości przy dużym zespole (jedna zmiana blokuje release wszystkich).
- Skalowanie całej aplikacji naraz, nawet gdy obciążony jest tylko jeden moduł.
Mikroserwisy
- Niezależne wdrażanie i skalowanie per serwis, dopasowane do realnego obciążenia.
- Zespoły pracują autonomicznie na własnym serwisie i własnym tempie release.
- Rozproszona spójność danych (brak wspólnej transakcji), potrzeba sag/eventual consistency.
- Koszt operacyjny: sieć, observability, osobny deployment pipeline dla każdego serwisu.
MIGRATED_PREFIX = "/api/invoices"
def handle_request(path: str, request: "Request") -> "Response":
if path == MIGRATED_PREFIX or path.startswith(f"{MIGRATED_PREFIX}/"):
return invoicing_service_client.forward(request)
return legacy_monolith.handle(path, request) - Różne moduły mają radykalnie różne wymagania skalowania (np. jeden endpoint dostaje 100x ruchu innych).
- Wiele niezależnych zespołów blokuje się nawzajem przy wspólnym release monolitu.
- Część logiki wymaga innego stosu technologicznego (np. ML w Pythonie obok reszty w Node).
- 1 Wybierz jedną granicę modułu Zacznij od funkcjonalności o niskim sprzężeniu z resztą monolitu.
- 2 Zbuduj nowy serwis obok monolitu Nowy serwis implementuje tę samą funkcjonalność niezależnie.
- 3 Przekieruj ruch trasa po trasie Router (proxy/gateway) kieruje wybrane ścieżki do nowego serwisu, resztę zostawia w monolicie.
- 4 Usuń starą implementację Gdy cały ruch dla danej funkcjonalności idzie do nowego serwisu, usuń martwy kod z monolitu.
8 · Projektowanie na awarie: circuit breaker, retry, idempotency
★ egzaminSieć zawodzi, usługi padają, requesty się gubią. Projektowanie na awarie to nie unikanie tego faktu, tylko ograniczanie promienia rażenia: circuit breaker zatrzymuje kaskadę, retry z backoff i jitterem próbuje ponownie bez wzmacniania awarii, idempotency key chroni przed skutkiem powtórzonej operacji.
import time
from enum import Enum, auto
class State(Enum):
CLOSED = auto()
OPEN = auto()
HALF_OPEN = auto()
class CircuitBreaker:
def __init__(self, failure_threshold: int = 5, reset_timeout: float = 30.0):
self.failure_threshold = failure_threshold
self.reset_timeout = reset_timeout
self.failures = 0
self.state = State.CLOSED
self.opened_at: float | None = None
def call(self, fn, *args, **kwargs):
if self.state is State.OPEN:
if time.monotonic() - self.opened_at < self.reset_timeout:
raise RuntimeError("circuit open, failing fast")
self.state = State.HALF_OPEN
try:
result = fn(*args, **kwargs)
except Exception:
self.failures += 1
if self.failures >= self.failure_threshold:
self.state = State.OPEN
self.opened_at = time.monotonic()
raise
else:
self.failures = 0
self.state = State.CLOSED
return result import random
import time
def retry_with_backoff(fn, max_attempts: int = 5, base_delay: float = 0.5, max_delay: float = 30.0):
for attempt in range(max_attempts):
try:
return fn()
except TransientError:
if attempt == max_attempts - 1:
raise
capped = min(max_delay, base_delay * 2 ** attempt)
sleep_time = random.uniform(0, capped) # full jitter
time.sleep(sleep_time) def charge_payment(idempotency_key: str, amount: int, currency: str) -> dict:
reserved = db.reserve_idempotency_key(idempotency_key) # atomowy INSERT ... ON CONFLICT (idempotency_key) DO NOTHING RETURNING id
if not reserved:
return db.find_payment_by_idempotency_key(idempotency_key) # replay: klucz już zarezerwowany przez oryginalny request
payment = payment_gateway.charge(amount, currency)
db.save_payment(idempotency_key, payment)
return payment - CLOSED: ruch przechodzi normalnie, licznik błędów rośnie przy każdej porażce, resetuje się przy sukcesie.
- OPEN: requesty odrzucane natychmiast bez wywoływania usługi (fail fast), chroni ją przed dalszym ruchem podczas regeneracji.
- HALF_OPEN: po reset timeout ograniczony ruch próbny sprawdza czy usługa wróciła do zdrowia, sukces wraca do CLOSED, porażka wraca do OPEN.
- 1 Klient generuje unikalny klucz Np. UUID per intencja operacji, ten sam klucz używany przy każdym retry tej samej próby.
- 2 Serwer sprawdza czy klucz już istnieje Jeśli tak, zwraca zapisany wcześniej wynik bez ponownego wykonania operacji.
- 3 Serwer wykonuje operację i zapisuje wynik pod kluczem Zapis wyniku i wykonanie operacji powinny być atomowe (ta sama transakcja).
- 4 Klucz wygasa po rozsądnym czasie Np. 24h, wystarczająco długo by pokryć realistyczne okno retry, nie w nieskończoność.
Sprawdź się - testowanie to nauka
24 pytań w losowej kolejności. Twoje wyniki zapisują się lokalnie.