Przejdź do treści
← wszystkie przedmioty

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.

TEMATY EGZAMINACYJNE W TYM PRZEDMIOCIE
  • 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

★ egzamin

Load 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.

Warstwa L4 vs L7
Load balancer L4 (transportowa, TCP/UDP) rozdziela pakiety bez wglądu w treść żądania, niska latencja. L7 (aplikacyjna, HTTP) czyta nagłówki, ścieżki i cookies, umożliwia routing per-endpoint, kosztem CPU na terminację i parsowanie.
Termin kluczowy
Round robin - Żądania rozdzielane cyklicznie po liście serwerów w stałej kolejności (opcjonalnie z wagami). Prosty i przewidywalny, ale ignoruje realne obciążenie i czas przetwarzania poszczególnych requestów.
Termin kluczowy
Least connections - Nowe żądanie trafia do serwera z najmniejszą liczbą aktywnie obsługiwanych połączeń. Lepiej radzi sobie, gdy requesty mają zróżnicowany czas trwania (np. szybkie GET obok długich uploadów).
Termin kluczowy
Consistent hashing - Węzły i klucze mapowane na wspólny pierścień hashy (0..2^n-1); klucz trafia do najbliższego węzła zgodnie z ruchem wskazówek zegara. Dodanie/usunięcie węzła przemieszcza tylko ułamek K/N kluczy, reszta mapowania zostaje bez zmian.
Pierścień consistent hashing z virtual nodes python
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]]
Pułapka
Round robin bez health checków wysyła ruch też do serwera, który już się wywala, ale wciąż odpowiada na TCP handshake. Health check musi weryfikować realną gotowość aplikacji (np. endpoint /healthz sprawdzający połączenie z bazą), nie tylko otwarty port.
  • 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.
AlgorytmKryterium wyboru serweraZachowanie przy zmianie liczby węzłówTypowe zastosowanie
Round robinKolejność cykliczna (+ wagi)Przelicza całą rotację, brak stanu do zachowaniaJednorodne, krótkotrwałe requesty (proste API)
Least connectionsLiczba aktywnych połączeńAdaptuje się automatycznie, nowy węzeł szybko dostaje ruchZróżnicowany czas przetwarzania (WebSocket, długie zapytania)
Consistent hashingPozycja na pierścieniu hashyPrzemieszcza tylko K/N kluczySharding cache/bazy, routing sesji, CDN
Mnemonik
Round robin liczy kolejkę, least connections liczy obciążenie, consistent hashing liczy odległość na pierścieniu: im bliżej klucza, tym trafniejszy węzeł.

2 · Cache'owanie i inwalidacja

★ egzamin

Cache 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.

Pułapka
Phil Karlton: w informatyce są tylko dwa trudne problemy, cache invalidation i nazywanie rzeczy. Trudność nie leży w samym zapisie do cache, tylko w utrzymaniu spójności wielu kopii danych przy współbieżnych zapisach bez globalnego zegara: każda strategia inwalidacji to kompromis między świeżością danych a złożonością/kosztem.
Termin kluczowy
Cache-aside (lazy loading) - Aplikacja czyta z cache; przy miss sama pobiera dane ze źródła prawdy i zapisuje je do cache. Przy zapisie: aktualizuje bazę i usuwa (invaliduje) wpis z cache, zamiast go nadpisywać, kolejny odczyt odtworzy świeżą wartość.
Termin kluczowy
Write-through - Zapis trafia synchronicznie i do cache, i do bazy w ramach tej samej operacji. Cache jest zawsze spójny z bazą po zapisie, kosztem wyższej latencji zapisu (dwa zapisy zamiast jednego).
Termin kluczowy
Write-back (write-behind) - Zapis trafia najpierw tylko do cache, do bazy jest zrzucany (flush) asynchronicznie później, często zbiorczo. Najniższa latencja zapisu, ale awaria cache przed flushem oznacza trwałą utratę danych.
Cache-aside: odczyt z miss-loadem, zapis z invalidacją python
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ć.
Pułapka
Write-back bez zabezpieczenia (replikacja cache, WAL) traci dane przy awarii węzła cache przed flushem do bazy: używać tylko gdy strata ostatnich zapisów jest akceptowalna (liczniki, metryki), nigdy dla danych transakcyjnych.
WzorzecLatencja odczytu (hit)Latencja zapisuRyzyko przy awariiTypowe zastosowanie
Cache-asideNiska (hit) / wysoka (miss)Niska (tylko baza + invalidate)Brak utraty danych, cache to tylko kopiaOgólnego przeznaczenia, najpopularniejszy
Write-throughNiskaWysoka (dwa zapisy)Brak utraty danych, zawsze spójnyDane wymagające silnej spójności odczyt/zapis
Write-backNiskaBardzo niskaUtrata niezapisanych danych przy awarii cacheLiczniki, metryki, dane tolerujące utratę
Mnemonik
Aside: czytaj z cache, ładuj przy miss, kasuj przy zapisie. Through: zapisuj wszędzie naraz. Back: zapisuj do cache, bazę dogoń później.

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.

Termin kluczowy
Vertical scaling (scale up) - Zwiększenie zasobów pojedynczej maszyny: więcej CPU, RAM, szybszy dysk. Nie wymaga zmian w architekturze aplikacji, ale ma fizyczny sufit i nie eliminuje pojedynczego punktu awarii (SPOF).
Termin kluczowy
Horizontal scaling (scale out) - Dodanie kolejnych instancji obsługujących ten sam ruch równolegle za load balancerem. Teoretycznie brak górnego limitu, ale wymaga architektury bezstanowej i mechanizmów koordynacji między instancjami.
Warunek konieczny
Horizontal scaling działa tylko, gdy instancja jest bezstanowa: sesja, cache lokalny i stan w pamięci procesu muszą być wyniesione do zewnętrznego magazynu (Redis, baza), inaczej load balancer nie może swobodnie kierować kolejnych żądań do dowolnej instancji.
Pułapka
Vertical scaling wygląda kusząco prosto (jeden klik w konsoli chmury), ale poza fizycznym sufitem sprzętu zostawia pojedynczy punkt awarii: restart czy padnięcie tej jednej maszyny to całkowity przestój, którego horizontal scaling z redundancją unika z definicji.
Stan w procesie vs stan wyniesiony do Redis python
# Ź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.
KryteriumVertical scalingHorizontal scaling
Limit skalowaniaTwardy (fizyczny sufit maszyny)Teoretycznie brak (dodaj kolejny węzeł)
Odporność na awarięSPOF, restart = przestójRedundancja, awaria jednego węzła nie wyłącza usługi
Złożoność wdrożeniaNiska, brak zmian w kodzieWymaga bezstanowości i koordynacji
Typowy koszt przy skaliRośnie nieliniowo (droższy hardware najwyższej klasy)Rośnie liniowo (więcej tanich instancji)
  1. 1 Zidentyfikuj stan w procesie Znajdź sesje, cache w pamięci, liczniki trzymane lokalnie w instancji.
  2. 2 Wynieś stan na zewnątrz Przenieś sesję/cache do Redis lub bazy współdzielonej między instancjami.
  3. 3 Dodaj load balancer i health checki Skonfiguruj rozkładanie ruchu i automatyczne wycofywanie niezdrowych instancji.
  4. 4 Włącz autoscaling Dodawaj/usuwaj instancje na podstawie metryk obciążenia (CPU, długość kolejki żądań).

4 · CAP theorem: realny wybór

★ egzamin

CAP 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.

Termin kluczowy
Consistency (C) - Każdy odczyt zwraca najnowszy zapisany wynik (lub błąd), niezależnie od tego, który węzeł go obsługuje. W terminologii CAP chodzi o linearizability, silniejszą własność niż potoczne 'spójność' w ACID.
Termin kluczowy
Availability (A) - Każde żądanie do działającego węzła otrzymuje odpowiedź (niekoniecznie najnowszą), bez gwarancji że to najświeższe dane.
Termin kluczowy
Partition tolerance (P) - System dalej działa mimo utraty komunikacji między niektórymi węzłami (podział sieci na rozłączne grupy). W realnej sieci partycje się zdarzają, więc P nie jest opcją do wyboru, tylko faktem, z którym system musi sobie poradzić.
Realny wybór
Skoro partycje sieci są nieuniknione w systemie rozproszonym, twierdzenie 'wybierz dowolne 2 z 3' jest mylące: P zostaje zawsze, a realny wybór podczas partycji to CP (odmów odpowiedzi, zachowaj spójność) albo AP (odpowiedz zawsze, zaakceptuj chwilową niespójność).
Kworum W+R>N jako pokrętło strojące spójność python
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
Pułapka
Popularne uproszczenie 'CAP = wybierz dowolne 2 z 3' sugeruje, że można zrezygnować z P. W praktyce partycja sieci prędzej czy później wystąpi niezależnie od woli architekta, jedyny realny wybór to zachowanie systemu w trakcie partycji: CP czy AP.
  • 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).
Mnemonik
P jest zawsze, bo sieć zawodzi. Pytanie brzmi nie 'czy P', tylko 'C czy A, gdy P uderzy'.

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).

Decoupling i backpressure
Producent publikuje zdarzenie i kończy pracę natychmiast, nie czekając aż konsument je przetworzy. Kolejka buforuje nadmiar podczas skoku ruchu, konsument przetwarza w swoim tempie, zamiast paść pod nagłym obciążeniem.
Termin kluczowy
Message queue (point-to-point) vs pub/sub - W kolejce point-to-point każda wiadomość trafia do dokładnie jednego konsumenta z puli (load balancing pracy). W pub/sub wiadomość trafia do wszystkich subskrybentów tematu (fan-out), każdy dostaje własną kopię.
Termin kluczowy
Gwarancje dostawy - At-most-once: wiadomość może zostać zgubiona, nigdy zdublowana. At-least-once: wiadomość może zostać zdublowana, nigdy zgubiona (najczęstszy wybór, wymaga idempotentnego konsumenta). Exactly-once: w praktyce nieosiągalne end-to-end bez dodatkowej deduplikacji, realnie to at-least-once plus idempotency.
Producent i konsument kolejki (AWS SQS) python
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"])
Pułapka
At-least-once delivery oznacza, że ta sama wiadomość może dotrzeć dwa razy (np. konsument przetworzył ją, ale padł zanim zdążył potwierdzić odbiór). Jeśli handler nie jest idempotentny, duplikat wykona efekt uboczny (np. wysłanie maila) dwa razy.
  • 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ść.
ModelKto odbiera wiadomośćPrzechowywanie historiiPrzykład
Kolejka point-to-pointJeden konsument z puli (competing consumers)Wiadomość znika po ackSQS, RabbitMQ (queue)
Pub/subKażdy subskrybent tematu dostaje kopięZwykle brak retencji po dostarczeniuSNS, RabbitMQ (exchange fanout)
Log-based streamingWielu konsumentów czyta niezależnie ten sam logWiadomości retencjonowane przez czas/rozmiar, można przeczytać ponownieKafka, Kinesis

6 · Replikacja i sharding baz danych

★ egzamin

Gdy 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.

Termin kluczowy
Leader-follower (primary-replica) replication - Tylko jeden węzeł (leader) przyjmuje zapisy, followerzy replikują jego log zmian i obsługują odczyty. Prosty model rozwiązywania konfliktów (jest tylko jedno źródło zapisu), ale leader to potencjalne wąskie gardło i punkt awarii wymagający failover.
Termin kluczowy
Multi-leader replication - Wiele węzłów przyjmuje zapisy niezależnie (np. po jednym na region), zmiany propagowane są między nimi asynchronicznie. Wymaga rozwiązywania konfliktów zapisu (np. last-write-wins, CRDT), w zamian daje niską latencję zapisu lokalnie w każdym regionie.
Termin kluczowy
Sharding (partycjonowanie) - Podział danych na rozłączne fragmenty (shardy) rozłożone na różne węzły, każdy shard obsługuje swój podzbiór kluczy. W przeciwieństwie do replikacji, każdy shard trzyma inne dane, więc zwiększa łączną pojemność zapisu i przechowywania.
Replication lag
Zmiana zapisana na leaderze propaguje do followerów z opóźnieniem (replication lag). Odczyt z repliki tuż po zapisie na leaderze może zwrócić starszą wersję danych: problem read-your-writes, rozwiązywany np. czytaniem z leadera przez chwilę po własnym zapisie.
Hash-based vs range-based shard routing python
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)
Pułapka
Zły wybór klucza shardingu (np. po ID popularnego, viralowego konta) tworzy hotspot: jeden shard dostaje nieproporcjonalnie duży ruch, mimo że mechanizm shardingu działa poprawnie. Klucz musi mieć wysoką kardynalność i równomierny rozkład ruchu.
  • 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).
StrategiaRozkład danychRange queriesRyzyko
Hash-basedRównomierny (hash klucza)Trudne/niemożliwe efektywnieNiskie ryzyko hotspotu przy dobrym kluczu
Range-basedWg zakresu wartości klucza (np. daty)Naturalnie wspieraneWysokie ryzyko hotspotu na najnowszym zakresie
Directory-basedJawna mapa klucz→shard w osobnej usłudzeZależne od implementacji mapyDodatkowy 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 nie jest przestarzały
Wiele firm o dużej skali (np. Shopify, Basecamp) świadomie utrzymuje modularny monolit latami, bo koszt operacyjny mikroserwisów (deployment, obserwowalność, sieć) przewyższa korzyści przy ich rozmiarze zespołu.

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.
Pułapka
'Mikroserwisy są zawsze lepsze' ignoruje, że dzielą monolit na fizyczne procesy, ale nie eliminują sprzężenia logicznego: jeśli serwisy komunikują się synchronicznie i są wdrażane razem, powstaje distributed monolith, gorszy od monolitu, bo dokłada koszt sieci bez zysku niezależności.
Termin kluczowy
Distributed monolith - Antywzorzec: serwisy fizycznie rozdzielone (osobne repo/proces/baza), ale ciasno sprzężone synchronicznymi wywołaniami, przez co zmiana w jednym wymusza koordynowane wdrożenie pozostałych. Ma koszty mikroserwisów bez ich korzyści.
Strangler fig pattern: routing trasa po trasie python
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. 1 Wybierz jedną granicę modułu Zacznij od funkcjonalności o niskim sprzężeniu z resztą monolitu.
  2. 2 Zbuduj nowy serwis obok monolitu Nowy serwis implementuje tę samą funkcjonalność niezależnie.
  3. 3 Przekieruj ruch trasa po trasie Router (proxy/gateway) kieruje wybrane ścieżki do nowego serwisu, resztę zostawia w monolicie.
  4. 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

★ egzamin

Sieć 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.

Termin kluczowy
Circuit breaker - Stanowy wrapper wokół wywołania zewnętrznej usługi: CLOSED (normalny ruch, licz błędy), OPEN (po przekroczeniu progu błędów odrzucaj natychmiast bez wywoływania usługi, fail fast), HALF_OPEN (po reset timeout przepuść próbny ruch, by sprawdzić odzyskanie).
Termin kluczowy
Idempotency key - Unikalny identyfikator dołączony do żądania wykonującego efekt uboczny (np. obciążenie karty). Serwer zapamiętuje wynik pierwszego żądania z danym kluczem, powtórzone żądanie (retry po timeout) zwraca ten sam wynik zamiast wykonać operację ponownie.
Retry storm
Retry bez opóźnienia (albo ze stałym opóźnieniem bez jittera) sprawia, że w momencie awarii wszyscy klienci ponawiają próbę jednocześnie, co pogłębia przeciążenie usługi, która właśnie próbuje się podnieść. To realny mechanizm, przez który krótka awaria zamienia się w długi outage.
Circuit breaker: CLOSED / OPEN / HALF_OPEN python
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
Retry z exponential backoff i pełnym jitterem python
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)
Idempotency key przy obciążeniu karty płatniczej python
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
Pułapka
Retry na operacji nieidempotentnej (np. 'obciąż kartę 100 PLN') bez idempotency key: jeśli sieć zgubi odpowiedź po tym, jak operacja się faktycznie powiodła, klient widzi timeout i retry'uje, serwer wykonuje obciążenie drugi raz. Idempotency key jest obowiązkowy przy każdym retry operacji z efektem ubocznym.
  • 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. 1 Klient generuje unikalny klucz Np. UUID per intencja operacji, ten sam klucz używany przy każdym retry tej samej próby.
  2. 2 Serwer sprawdza czy klucz już istnieje Jeśli tak, zwraca zapisany wcześniej wynik bez ponownego wykonania operacji.
  3. 3 Serwer wykonuje operację i zapisuje wynik pod kluczem Zapis wyniku i wykonanie operacji powinny być atomowe (ta sama transakcja).
  4. 4 Klucz wygasa po rozsądnym czasie Np. 24h, wystarczająco długo by pokryć realistyczne okno retry, nie w nieskończoność.
Fiszki - aktywne przypominanie

Sprawdź się - testowanie to nauka

24 pytań w losowej kolejności. Twoje wyniki zapisują się lokalnie.

Rozpocznij quiz
Zbudowane przez Tenzan Logic