Tahle kapitola je srdce „seniorního" backendu. Tyhle vzory (patterny) oddělují člověka, co „napíše endpoint, co funguje na jeho počítači", od člověka, co postaví systém, který přežije produkci — kde se opakují requesty, vypadávají služby a věci selhávají v půlce. Každý pattern řeší jeden konkrétní způsob, jak se to může pokazit. Vycházíme z Distribuovaných systémů: v reálném světě všechno může selhat kdykoli (síť spadne, server umře uprostřed operace, request dorazí dvakrát).
Idempotency ⭐
Problém: Klient pošle request, ale spojení vyprší (timeout) dřív, než dorazí odpověď. Klient
neví, jestli to prošlo, tak to zkusí znovu (retry). Když to byl POST /payments, právě jsi
zákazníkovi naúčtoval platbu dvakrát.
Co to je: Operace je idempotentní, když ji provedeš víckrát se stejným výsledkem jako
jednou. Smazat objednávku #5 dvakrát = pořád smazaná (idempotentní). Zaplatit dvakrát = dvě platby
(NEidempotentní). (Drobnost: u DELETE je idempotentní stav — pořád smazáno — ale odpověď se
liší: poprvé 200, podruhé 404. Při retry to neber jako chybu.)
Řešení — idempotency key: klient ke každému takovému requestu přiloží unikátní klíč a server si podle něj pozná, že už ten požadavek viděl:
Na čem to stojí: klíč i výsledek ulož ve stejné databázové transakci jako samotnou operaci — jinak vznikne okénko, kdy operace proběhne, ale klíč se ještě neuložil, a duplicita je zpátky. (Jak takový klíč navrhnout v API, je v API Design.)
To je základ. Dvě pasti, na které narazíš později (klidně přeskoč, dokud základ neusadíš):
- Souběžný retry, který ještě běží: klíč zapiš hned na začátku ve stavu in-progress (přes
UNIQUE), ať druhý souběžný request počká / dostane409— jinak proběhne operace dvakrát naráz. - Dedup okno musí překrýt retry okno druhé strany (platí pro klíče, dedup zpráv i webhooků): když si ID pamatuješ 1 hodinu, ale producent opakuje 3 dny (Kafka/Stripe), duplikát po expiraci tiše projde. TTL klíče proto dej delší, než jak dlouho může druhá strana opakovat.
A takhle to vypadá v kódu (zjednodušeně):
async function handlePayment(req, res) {
const key = req.header('Idempotency-Key')
// 1) zkus klíč atomicky založit ve stavu 'in-progress' (UNIQUE na sloupci key)
const created = await db.tryInsertKey(key) // false = klíč už existuje
if (!created) {
const prev = await db.getByKey(key)
if (prev.status === 'in-progress')
return res.status(409).send('už se zpracovává')
return res.status(200).json(prev.response) // vrať ULOŽENOU odpověď, neprováděj znovu
}
// 2) proveď operaci a její výsledek ulož k už založenému klíči v JEDNÉ transakci
// (operace + zápis výsledku atomicky pohromadě — buď obojí, nebo nic)
const result = await db.tx(async (t) => {
const r = await charge(t, req.body)
await t.saveResult(key, r) // přepíše klíč z 'in-progress' na hotovo + uloží odpověď
return r
})
return res.status(200).json(result)
}
Retry + exponenciální backoff + jitter
Problém: Volání selže (vypadek sítě, server vrátí 503). Zkusit to znovu je správně — ale
naivní opakování dokáže situaci zhoršit.
- Naivně: opakovat hned a donekonečna → ještě víc zahltíš službu, která se sotva drží (tzv. retry storm, „bouře opakování").
- Exponenciální backoff: mezi pokusy čekej stále déle — 1 s, 2 s, 4 s, 8 s. Dáš službě čas se vzpamatovat.
- Jitter (náhodný posun): přidej k čekání trochu náhody. Bez něj se totiž všichni klienti, co selhali naráz, pokusí znovu přesně ve stejnou chvíli a srazí službu znovu (thundering herd, „splašené stádo"). Náhoda ty nárazy rozprostře.
- Omezený počet pokusů: neopakuj věčně. Po pár pokusech to odlož stranou (DLQ, viz Messaging).
⚠️ Retry musí jít ruku v ruce s idempotencí. Opakovat neidempotentní operaci = duplicity. Tohle je nejdůležitější propojení celé kapitoly.
A opakuj jen u chyb, kde to má smysl: 5xx a výpadky sítě. U 4xx (request je špatně) opakování
nepomůže — pošleš tu samou špatnou věc znovu.
async function withRetry(fn, maxAttempts = 5) {
for (let attempt = 0; ; attempt++) {
try {
return await fn()
}
catch (err) {
if (attempt >= maxAttempts || !isRetryable(err))
throw err // 4xx a chyby logiky neopakuj
const base = 2 ** attempt * 1000 // 1, 2, 4, 8 s… (exponenciální backoff)
const wait = base / 2 + Math.random() * base // + jitter (náhodný posun)
await sleep(wait)
}
}
}
⚠️ Retry amplification (násobení přes vrstvy). Když retryuje každá vrstva (frontend → API → service → databáze) a každá zkusí 3×, nejnižší služba dostane 3×3×3 = 27× víc zátěže — a to ji v okamžiku, kdy sotva dýchá, dorazí. Proto se retryuje jen na jedné vrstvě (typicky té nejblíž selhání), a velké systémy mají retry budget (token bucket na opakování — když dojde, přestane se retryovat). Vrstvený retry je častá příčina kaskádních pádů.
Circuit breaker (pojistka)
Problém: Služba, na kterou se spoléháš, je dole. Ty na ni ale pořád voláš a každé volání čeká na timeout (třeba 30 s). Tvoje vlákna se přitom hromadí, jak čekají — a spadneš kvůli tomu i ty (řetězový pád, cascading failure).
Řešení: „pojistka" (jako ta elektrická), která má tři stavy:
Výhoda: místo zdlouhavého čekání na timeout selžeš okamžitě (fail fast) a tím chráníš sebe i umírající službu. Často se kombinuje s fallbackem — místo chyby vrátíš třeba data z cache.
Outbox pattern ⭐ (problém dvojího zápisu)
Problém — dvojí zápis (dual write): Potřebuješ udělat dvě věci najednou: (1) uložit objednávku do databáze a (2) poslat o ní zprávu do fronty pro ostatní služby. Jenže databáze a fronta jsou dva různé systémy, takže je nemůžeš zabalit do jedné transakce. Když uložíš do databáze a pak spadneš dřív, než odešleš zprávu, databáze a zbytek světa se rozejdou.
Řešení: zprávu zapiš do té samé databáze a ve stejné transakci jako data — do pomocné tabulky
outbox (česky „odchozí pošta"):
BEGIN (začátek transakce)
ulož objednávku do tabulky orders
ulož zprávu do tabulky outbox ← stejná transakce, takže obojí, nebo nic
COMMIT (potvrzení)
Samostatný proces pak čte tabulku outbox, posílá zprávy do fronty
a po odeslání je v outboxu označí jako hotové.
Ten odesílací proces se dělá buď pollingem tabulky outbox, nebo elegantněji přes
CDC (čtení WAL, viz Databáze). Pozor: pokud běží na víc instancích, musí si řádky
brát přes SELECT ... FOR UPDATE SKIP LOCKED (nebo to dělat jen jeden zvolený leader), jinak by
stejnou zprávu poslalo víc instancí.
Teď je „ulož data" i „chci poslat zprávu" atomicky pohromadě. Odeslání zprávy je at-least-once (ten proces může spadnout po odeslání, než stihne řádek označit) → příjemce proto musí být idempotentní. Vidíš, jak se to vrství? Outbox + at-least-once + idempotentní příjemce = spolehlivý systém.
Saga (transakce přes víc služeb)
Problém: Jedna byznys operace sahá přes víc služeb (objednávka → platba → sklad → doprava) a každá má vlastní databázi. Nejde udělat jednu společnou transakci přes všechny. Co když platba projde, ale rezervace skladu selže?
Řešení: rozlož to na sérii kroků, kde každý má svou kompenzaci (akci, která ho vrátí zpět):
Když některý krok selže, spustí se kompenzace předchozích kroků v opačném pořadí (vrať platbu, zruš objednávku). Výsledkem není okamžitá konzistence, ale eventual consistency — to je cena za to, že nepotřebuješ jednu velkou transakci přes celý svět.
„A proč ne prostě 2PC?" (klasická senior otázka) Existuje i opačný přístup — two-phase commit (dvoufázový commit, 2PC): koordinátor se nejdřív všech služeb zeptá „připraven commitnout?" (fáze prepare), a teprve když všichni řeknou ano, dá pokyn „commit" (fáze commit). Tím dostaneš opravdovou atomicitu napříč službami. Proč se mu v moderních systémech přesto vyhýbáme:
- Je blokující — všechny služby drží zdroje zamčené a čekají na koordinátora; jedna pomalá to zdrží všem.
- Koordinátor je single point of failure — když umře mezi fázemi, ostatní zůstanou „viset" v nejistotě (commitnout? rollbacknout?).
- Špatně škáluje a předpokládá těsně provázané služby.
Proto se v distribuovaných systémech většinou volí saga + eventual consistency (neblokující, odolnější vůči výpadkům) a 2PC se nechává leda pro úzké, silně konzistentní hranice. Saga je tedy přesně náhrada za 2PC tam, kde 2PC neškáluje.
CQRS (oddělení zápisu a čtení)
Problém: Použít stejný datový model pro zápis i pro čtení tě může svazovat — zápis chce čistotu a validaci, čtení chce rychle naservírovat hotová data.
Řešení: oddělíš model pro zápis (mění stav) od modelu pro čtení (optimalizovaný na rychlé dotazy, klidně v jiné databázi). Zkratka znamená Command Query Responsibility Segregation.
Je to mocné, ale drahé na složitost (dvě cesty, které se musí synchronizovat). Většina aplikací CQRS nepotřebuje — sáhni po něm, až máš opravdu extrémně nevyvážené potřeby čtení vs zápisu.
Rate limiting (omezení počtu requestů)
Chrání službu před zahlcením nebo zneužitím tím, že omezí, kolik requestů smí klient poslat. Při
překročení vrátí 429 Too Many Requests. Hlavní algoritmy:
- Token bucket (kýblík s žetony) — kýblík se plní rychlostí X žetonů za sekundu, každý request jeden žeton sebere. Když je prázdný, request se odmítne. Umí zvládnout krátké špičky. Nejpoužívanější.
- Fixed window (pevné okno) — počítej requesty za minutu. Jednoduché, ale na hraně dvou oken můžeš pustit dvojnásobek.
- Sliding window (klouzavé okno) — přesnější varianta předchozího.
Když běžíš na víc serverech, počítadlo se drží centrálně (typicky v Redisu), aby limit platil dohromady napříč všemi instancemi. Pozor: musí to být atomické — samostatné „přečti počet, rozhodni, zapiš" má race condition, takže se to dělá jednou atomickou operací (
INCRu fixed window) nebo malým Lua skriptem (token bucket), který celé rozhodnutí provede nedělitelně.
Distribuované zámky — a proč jsou zrádné
Někdy potřebuješ, aby danou věc dělal v jednu chvíli jen jeden proces napříč celým clusterem
(spustit naplánovanou úlohu, přepočítat něco jednou). Na jednom serveru stačí lokální zámek; přes víc
serverů potřebuješ distribuovaný zámek — typicky v Redisu (SET klíč hodnota NX PX <ttl> =
„zamkni, jen když volný, a sám se uvolni po TTL").
Tady je ta zrada, kterou junior nečeká: distribuovaný zámek negarantuje, že drží opravdu jen jeden. TTL musí být na zámku proto, aby se neuvolnil nikdy (kdyby držitel umřel), jenže:
- Když práce trvá déle než TTL, zámek vyprší ještě během práce a vezme si ho druhý → najednou pracují dva (a je po mutual exclusion).
- Když držitele zamrazí dlouhá pauza (GC, přepnutí procesu), může se „probrat" až po vypršení zámku a pokračovat, jako by ho pořád měl.
Hlavní ponaučení (a stačí, když si odneseš tohle): na samotnou korektnost se na distribuovaný zámek nespoléhej. Buď ať je chráněná operace rovnou idempotentní (a zámek je pak jen optimalizace, ne záruka), nebo přidej fencing token — zámek vydá rostoucí číslo a úložiště odmítne zápis se starým číslem, takže „opožděný" držitel už neublíží. Pravidlo: distribuovaný zámek snižuje souběh, ale nenahrazuje idempotenci.
Pro zvídavé: tohle není akademická drobnost — vedla se o tom známá veřejná debata (algoritmus „Redlock" od autorů Redisu vs kritika Martina Kleppmanna), jestli vůbec jde distribuovaný zámek postavit „správně". Závěr pro praxi je přesně ten výše: ber zámek jako optimalizaci, korektnost si pojisti jinak.
Bulkhead, timeout, DLQ (rychlý přehled)
- Timeout — vždy dej každému volání časový limit. Nekonečné čekání ti vyčerpá vlákna. Banální, ale kritické.
- Bulkhead (přepážka) — odděl zdroje (samostatné pooly), ať jeden přetížený endpoint nepoloží celý systém. Jako vodotěsné přepážky na lodi: jedna se zaplaví, loď plave dál.
- Dead Letter Queue (DLQ) — zprávy, které se opakovaně nepodaří zpracovat, jdou stranou k ruční prohlídce, místo aby donekonečna blokovaly frontu (viz Messaging).
Jak to do sebe zapadá (celkový obraz)
Tohle není seznam triků k naučení nazpaměť — je to obrana proti chaosu reálného světa. Patterny na sebe navazují: idempotence je základ, na kterém teprve můžou bezpečně stát retry, outbox i saga.
🛠️ Cvičení
- Idempotentní endpoint. Naskicuj v pseudokódu
POST /paymentss idempotency key uloženým ve stejné transakci jako platba. Pak ukaž, kde vznikne duplicita, když klíč uložíš až po platbě. - Retry s backoffem a jitterem. Napiš opakování, které zkouší po 1 s, 2 s, 4 s ± náhoda, nejvýš
5×, a jen u
5xx/síťových chyb. Vysvětli, proč by bez jitteru vznikl thundering herd. - Circuit breaker. Naskicuj stavový automat se třemi stavy a prahy (kolik chyb překlopí do OPEN, jak dlouho čekat). Co se stane v HALF-OPEN při úspěchu a při selhání?
- Dvojí zápis → outbox. Máš uložit objednávku do databáze a poslat o ní zprávu. Ukaž, kde to bez
outboxu praskne, a přepiš to přes tabulku
outboxv jedné transakci. - Saga. Pro objednávka → platba → sklad → doprava napiš kroky a jejich kompenzace. Co se stane, když selže rezervace skladu?
Náčrt řešení — rozbal, až si cvičení zkusíš sám
- Idempotentní endpoint — klíč zapiš
UNIQUEve stejné transakci jako platbu, takže buď proběhne obojí, nebo nic; při retry vrať uloženou odpověď. Past: když klíč uložíš až po platbě, vzniká okénko, kdy operace proběhne, ale klíč chybí — retry naúčtuje podruhé. - Retry s backoffem a jitterem — opakuj jen u
5xx/síťových chyb, čekej 1 s, 2 s, 4 s × (1 ± náhoda), nejvýš 5×, pak odlož do DLQ. Bez jitteru udeří všichni klienti, co selhali naráz, přesně ve stejnou chvíli (thundering herd) a službu znovu položí; náhoda nárazy rozprostře. Pozor: u4xxneopakuj. - Circuit breaker — tři stavy CLOSED/OPEN/HALF-OPEN; po N chybách za sebou překlop do OPEN (fail fast bez volání), po klidové době zkus HALF-OPEN. V HALF-OPEN úspěch → CLOSED, selhání → zpět OPEN. Past: práh a klidovou dobu měj konfigurovatelné a v OPEN nezapomeň na fallback (třeba data z cache).
- Dvojí zápis → outbox — bez outboxu uložíš objednávku a spadneš před odesláním zprávy → světy se rozejdou. Fix: zapiš zprávu do tabulky
outboxve stejné transakci jako objednávku a samostatný proces ji odešle. Pozor: doručení je at-least-once → příjemce musí být idempotentní, a při víc instancích ber řádky přesSELECT ... FOR UPDATE SKIP LOCKED. - Saga — kroky objednávka → platba → sklad → doprava, každý se svou kompenzací (uvolni sklad, vrať platbu, zruš objednávku) v opačném pořadí. Když selže rezervace skladu, spustí se kompenzace předchozích kroků (vrať platbu, zruš objednávku). Past: výsledkem je eventual consistency, ne okamžitá — kompenzace samy musí být idempotentní, kdyby se zopakovaly.
🧠 Otázky & odpovědi
Proč retry musí jít ruku v ruce s idempotencí?
Opakování znamená, že operace může proběhnout vícekrát. U neidempotentní operace (POST /payment) to
udělá duplicitu — dvě platby. Když je operace idempotentní (přes idempotency key, nebo přirozeně jako
DELETE), opakování nic nezkazí. Proto je idempotence základ, na kterém teprve smí retry stát.
Proč se do backoffu přidává jitter (náhoda)?
Bez náhody se všichni klienti, co selhali ve stejnou chvíli, pokusí znovu přesně po stejné době a udeří na zotavující se službu naráz (thundering herd) — a znovu ji položí. Náhodný posun ty pokusy rozprostře v čase, takže služba dostane šanci se zvednout.
Jaký problém řeší outbox pattern?
Problém dvojího zápisu: potřebuješ atomicky uložit data do databáze a poslat zprávu do jiného
systému (fronty), ale to jsou dva systémy → nejde jedna transakce. Když uložíš do databáze a spadneš
před odesláním zprávy, světy se rozejdou. Outbox zapíše zprávu do stejné databáze ve stejné
transakci (tabulka outbox) a samostatný proces ji pak odešle. Doručení je at-least-once → příjemce
musí být idempotentní.
K čemu je circuit breaker a jaké má stavy?
Brání řetězovému pádu: když je závislost dole, přestaneš na ni volat, a tím se nezahltíš čekáním na timeouty. Stavy: CLOSED (provoz teče normálně), OPEN (po překročení prahu chyb hned vracíš chybu a službu nevoláš — fail fast), HALF-OPEN (po chvíli klidu pustíš pár zkušebních volání; když projdou → CLOSED, když ne → zpět OPEN). Často se kombinuje s fallbackem (vrať data z cache).
Kdy CQRS NEpoužít?
Když nemáš výrazně nevyvážené potřeby čtení a zápisu. CQRS (oddělený model pro zápis a pro čtení) přidává hodně složitosti — dvě cesty, které se musí synchronizovat, a dočasný nesoulad mezi nimi. Pro běžnou CRUD aplikaci je to zbytečné. Sáhni po něm, teprve až tě k tomu dotlačí opravdu extrémní poměr čtení vs zápisu.
