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.
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.
Load balancing: rozdzielanie ruchu
na egzaminieLoad 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.
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.
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.
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).
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.
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]] 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.
| 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 |
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ł.
Cache'owanie i inwalidacja
na egzaminieCache 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.
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.
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ść.
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).
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.
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ć.
Write-back bez zabezpieczenia (replikacja cache, WAL) traci dane przy awarii węzła cache przed flushem do bazy. Stosuj go tylko, gdy strata ostatnich zapisów jest akceptowalna (liczniki, metryki), nigdy dla danych transakcyjnych.
| 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ę |
Aside: czytaj z cache, ładuj przy miss, kasuj przy zapisie. Through: zapisuj wszędzie naraz. Back: zapisuj do cache, bazę dogoń później.
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.
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).
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.
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.
Vertical scaling wygląda kusząco prosto (jeden klik w konsoli chmury). Poza fizycznym sufitem sprzętu zostawia jednak pojedynczy punkt awarii. Restart czy padnięcie tej jednej maszyny to całkowity przestój, którego horizontal scaling z redundancją unika z definicji.
# Ź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) |
- Zidentyfikuj stan w procesie Znajdź sesje, cache w pamięci, liczniki trzymane lokalnie w instancji.
- Wynieś stan na zewnątrz Przenieś sesję/cache do Redis lub bazy współdzielonej między instancjami.
- Dodaj load balancer i health checki Skonfiguruj rozkładanie ruchu i automatyczne wycofywanie niezdrowych instancji.
- Włącz autoscaling Dodawaj/usuwaj instancje na podstawie metryk obciążenia (CPU, długość kolejki żądań).
CAP theorem: realny wybór
na egzaminieCAP 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.
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.
Availability (A): Każde żądanie do działającego węzła otrzymuje odpowiedź (niekoniecznie najnowszą), bez gwarancji że to najświeższe dane.
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ć.
Partycje sieci są nieuniknione w systemie rozproszonym, więc twierdzenie 'wybierz dowolne 2 z 3' jest mylące. P zostaje zawsze. Realny wybór podczas partycji to CP (odmów odpowiedzi, zachowaj spójność) albo AP (odpowiedz zawsze, zaakceptuj chwilową niespójność).
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 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).
P jest zawsze, bo sieć zawodzi. Pytanie brzmi nie 'czy P', tylko 'C czy A, gdy P uderzy'.
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).
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.
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ę.
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.
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"]) 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ść.
| 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 |
Replikacja i sharding baz danych
na egzaminieGdy jedna baza danych przestaje wystarczać, są dwie ortogonalne odpowiedzi. Replikacja to wiele kopii tych samych danych, dla odczytu i odporności. Sharding to podział danych na rozłączne fragmenty, dla zapisu i pojemności. Realne systemy zwykle łączą oba.
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.
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.
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.
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.
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) 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).
| 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 |
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.
Wiele firm o dużej skali (np. Shopify, Basecamp) świadomie utrzymuje modularny monolit latami. Koszt operacyjny mikroserwisów (deployment, obserwowalność, sieć) przewyższa bowiem 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.
'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. Jest gorszy od monolitu, bo dokłada koszt sieci bez zysku niezależności.
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.
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).
- Wybierz jedną granicę modułu Zacznij od funkcjonalności o niskim sprzężeniu z resztą monolitu.
- Zbuduj nowy serwis obok monolitu Nowy serwis implementuje tę samą funkcjonalność niezależnie.
- Przekieruj ruch trasa po trasie Router (proxy/gateway) kieruje wybrane ścieżki do nowego serwisu, resztę zostawia w monolicie.
- Usuń starą implementację Gdy cały ruch dla danej funkcjonalności idzie do nowego serwisu, usuń martwy kod z monolitu.
Projektowanie na awarie: circuit breaker, retry, idempotency
na egzaminieSieć 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.
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.
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 bez opóźnienia (albo ze stałym opóźnieniem bez jittera) sprawia, że w momencie awarii wszyscy klienci ponawiają próbę jednocześnie. Pogłębia to 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.
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 Retry na operacji nieidempotentnej (np. 'obciąż kartę 100 PLN') bez idempotency key jest groźny. Sieć może zgubić odpowiedź po tym, jak operacja się faktycznie powiodła. Wtedy klient widzi timeout i retry'uje, a 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.
- Klient generuje unikalny klucz Np. UUID per intencja operacji, ten sam klucz używany przy każdym retry tej samej próby.
- Serwer sprawdza czy klucz już istnieje Jeśli tak, zwraca zapisany wcześniej wynik bez ponownego wykonania operacji.
- Serwer wykonuje operację i zapisuje wynik pod kluczem Zapis wyniku i wykonanie operacji powinny być atomowe (ta sama transakcja).
- Klucz wygasa po rozsądnym czasie Np. 24h, wystarczająco długo by pokryć realistyczne okno retry, nie w nieskończoność.
Sprawdź się
20 z 24 pytań, losowo, z wyjaśnieniem po każdym. Wynik zostaje na tym urządzeniu.
Rozpocznij quiz