Когато съседът е бавен: timeout, retry, circuit breaker
Накратко: Timeout спира чакането на бавна услуга, retry с експоненциално чакане и jitter повтаря само временните грешки, а circuit breaker спира заявките, докато услугата не се оправи.
Проблем
Тридесет секунди, които спряха магазина
Сутринта на Black Friday доставчикът на плащания започва да отговаря за 30 секунди вместо за 300 ms. Не пада: отговаря, но бавно. След минута магазинът не показва дори каталога, а каталогът изобщо не вика доставчика.
Ето защо, на малко. Магазинът има пул от 4 нишки (урокът за пула от нишки). Всеки тик идват едно плащане и една заявка за каталога. Доставчикът отговаря за 30 тика:
// пул от 4 нишки; всеки тик идват едно плащане и една заявка за каталогаfunction run(title, timeout) { const busyUntil = [0, 0, 0, 0]; // до кой тик е заета всяка нишка let refused = 0; for (let t = 0; t < 8; t++) { const free = busyUntil.findIndex((u) => u <= t); // плащането държи нишката, докато доставчикът отговори if (free >= 0) busyUntil[free] = t + Math.min(30, timeout); if (busyUntil.findIndex((u) => u <= t) < 0) refused++; // каталогът няма свободна нишка } const busy = busyUntil.filter((u) => u > 7).length; console.log(`${title}: заети нишки ${busy} от 4, каталогът отказа ${refused} заявки`);}run("без timeout", Infinity);без timeout: заети нишки 4 от 4, каталогът отказа 5 заявкиСлед четири тика и четирите нишки чакат доставчика. Каталогът отказва всичко след това, макар да няма нищо общо с плащането. Бавният съсед е по-опасен от мъртвия: мъртвият поне отговаря веднага с грешка.
Идеята
Timeout: не чакай вечно
Timeout е най-дългото време, което чакаш отговор. След него спираш, освобождаваш нишката и казваш на клиента нещо ясно: „Плащането не мина, поръчката е запазена, опитай пак след малко.“ Същият магазин с timeout 2 тика:
run("timeout 2 тика", 2);timeout 2 тика: заети нишки 2 от 4, каталогът отказа 0 заявкиПлащанията пак не минават, защото доставчикът е бавен. Но всяко държи нишка най-много 2 тика, винаги остават свободни нишки и каталогът работи.
Колко да е timeout-ът? Идва от бюджета на закъснението: ако плащането има 450 ms за p95, timeout от 30 секунди не пази нищо. Обикновено е малко над p99 на съседа, мерено, а не познато. И timeout-ът на долния слой трябва да е по-кратък от този на горния: иначе горният се е отказал, а долният още работи за никого.
Идеята
Retry: опитай пак, но умно
Много грешки са временни: изпуснат пакет, рестартиращ сървър, отговор 503 („зает съм“). Втори опит след малко често минава. Това е retry (повторение). Три правила го правят безопасно:
- Само временните грешки. Timeout и 503 се повтарят. „Картата е отказана“ или 400 (грешна заявка) ще са същите и на втория опит.
- Чакане, което расте: 1, 2, 4, 8 тика. Това е експоненциално чакане (exponential backoff): съседът, който се дави, получава все повече въздух.
- Граница на опитите. След нея се отказваш и връщаш последната грешка.
// само временните грешки: доставчикът може да се оправиfunction retryable(answer) { return answer === "timeout" || answer === "503";}function withRetry(answers, maxAttempts) { let tick = 0; for (let attempt = 1; ; attempt++) { const answer = answers[attempt - 1]; // записан отговор на опит номер attempt console.log(`тик ${tick}, опит ${attempt}: ${answer}`); if (!retryable(answer) || attempt === maxAttempts) return answer; tick += 2 ** (attempt - 1); // чакане 1, 2, 4, 8… тика }}Доставчикът отговаря по записан сценарий: timeout, после 503, после ok.
console.log("резултат:", withRetry(["timeout", "503", "ok"], 4));тик 0, опит 1: timeoutтик 1, опит 2: 503тик 3, опит 3: okрезултат: okВнимание: повторено плащане може да изтегли парите два пъти, ако първото е минало, а само отговорът се е изгубил. Затова плащането се повтаря само с ключ за идемпотентност: доставчикът познава повторената заявка и връща първия резултат.
Пробвай
Предвиди изхода
Доставчикът отговаря timeout три пъти и ok на четвъртия опит. Границата е 3 опита. Какво печата програмата?
console.log("резултат:", withRetry(["timeout", "timeout", "timeout", "ok"], 3));Опитите са на тикове 0, 1 и 3 (чакане 1, после 2). Третият е последният позволен, затова функцията връща timeout, без да стигне до ok на четвъртия.
Пробвай
Намери грешката
Клиент с отказана карта чака цели 7 тика, а банката му праща четири известия за отказ. На кой ред е грешката?
Избери реда с грешката.
Всичко без ok се смята за временно, включително отказаната карта. Тя не се оправя от чакане. Повтарят се само timeout и 503, а останалото се връща веднага.
Идеята
Jitter: да не тръгнат всички заедно
Когато доставчикът падне, падат хиляди плащания наведнъж. С експоненциалното чакане всички опитват пак на тик 1, после на 3, после на 7: заедно, точно когато доставчикът се опитва да се съвземе. Това е thundering herd (стадото).
Jitter добавя случайност към чакането: всеки клиент чака случайно число тикове, от 1 до двойното на чакането. Програмата ползва малък генератор със семе, за да дава едни и същи числа на всеки език и при всяко пускане:
let seed = 7;
// генератор със семе: същите числа на всеки езикfunction random(bound) { seed = (seed * 75 + 74) % 65537; return seed % bound;}
function retryTicks(jitter) { const ticks = []; let tick = 0; for (let attempt = 0; attempt < 4; attempt++) { let wait = 2 ** attempt; // 1, 2, 4, 8 if (jitter) wait = 1 + random(2 * wait); // от 1 до 2 · wait tick += wait; ticks.push(tick); } return ticks.join(" ");}for (const client of ["A", "B", "C"]) console.log(`${client} без jitter: ${retryTicks(false)}`);for (const client of ["A", "B", "C"]) console.log(`${client} с jitter : ${retryTicks(true)}`);A без jitter: 1 3 7 15B без jitter: 1 3 7 15C без jitter: 1 3 7 15A с jitter : 2 6 11 13B с jitter : 2 5 12 19C с jitter : 1 5 8 9Без jitter тримата удрят доставчика в едни и същи тикове. С jitter се разпръскват. В истинския код семе не се слага: ползваш генератора на езика, а при тестовете подаваш свой, за да е изходът предвидим.
Идеята
Circuit breaker: спри да викаш
Retry помага при кратък проблем. Ако доставчикът не работи от десет минути, всяко повторение е още една заявка към някой, който се дави, и още една нишка, която чака. Тогава трябва circuit breaker (прекъсвач), като предпазителя в електрическото табло. Той има три състояния:
- затворен (closed): заявките минават. Прекъсвачът брои грешките поред;
- отворен (open): след 3 грешки поред спира да вика. Всяка заявка получава отказ веднага, без да чака;
- полуотворен (half-open): след 4 тика пуска една проба. Ако мине, прекъсвачът се затваря. Ако падне, пак се отваря.
const THRESHOLD = 3; // поредни грешки до отварянеconst COOLDOWN = 4; // тикове в OPEN преди проба
class CircuitBreaker { state = "CLOSED"; failures = 0; openedAt = 0; calls = 0; // колко пъти е викан доставчикът
call(tick, providerUp) { if (this.state === "OPEN") { if (tick - this.openedAt < COOLDOWN) return "отказ веднага"; this.state = "HALF_OPEN"; // една проба } this.calls++; if (providerUp) { this.state = "CLOSED"; this.failures = 0; return "ok"; } this.failures++; const tooMany = this.failures >= THRESHOLD; if (this.state === "HALF_OPEN" || tooMany) { this.state = "OPEN"; this.openedAt = tick; } return "грешка"; }}Виж го
Прекъсвачът през 12 тика
call(tick, providerUp) { if (this.state === "OPEN") { if (tick - this.openedAt < COOLDOWN) return "отказ веднага"; this.state = "HALF_OPEN"; // една проба } this.calls++; if (providerUp) { this.state = "CLOSED"; this.failures = 0; return "ok"; } this.failures++; const tooMany = this.failures >= THRESHOLD; if (this.state === "HALF_OPEN" || tooMany) { this.state = "OPEN"; this.openedAt = tick; } return "грешка";}Стъпка по стъпка, с думи
- CLOSED: всяко викане стига до доставчика на плащания. Той не отговаря: тик 0 и тик 1 са грешки, броячът е 2 от 3.
- Тик 2: трета грешка поред. Прекъсвачът се отваря (OPEN) и помни кога.
- Тикове 3, 4 и 5 получават отказ веднага. Не стигат до доставчика и не държат нишка. Магазинът може веднага да предложи „плати по-късно“.
- Тик 6: минали са 4 тика. HALF_OPEN: минава една проба. Доставчикът още не работи и прекъсвачът пак се отваря.
- От тик 7 доставчикът пак работи, но прекъсвачът още не знае. Тикове 7 до 9 получават отказ: това е цената на чакането.
- Тик 10: пробата минава, прекъсвачът се затваря (CLOSED) и броячът се нулира. Доставчикът е викан 6 пъти от 12.
Доставчикът не отговаря до тик 7, а после работи. Записаният сценарий е tick >= 7:
const breaker = new CircuitBreaker();for (let tick = 0; tick < 12; tick++) { const up = tick >= 7; // записано: доставчикът се връща на тик 7 const result = breaker.call(tick, up); console.log(`тик ${tick}: ${result} → ${breaker.state}`);}console.log(`викания към доставчика: ${breaker.calls} от 12`);тик 0: грешка → CLOSEDтик 1: грешка → CLOSEDтик 2: грешка → OPENтик 3: отказ веднага → OPENтик 4: отказ веднага → OPENтик 5: отказ веднага → OPENтик 6: грешка → OPENтик 7: отказ веднага → OPENтик 8: отказ веднага → OPENтик 9: отказ веднага → OPENтик 10: ok → CLOSEDтик 11: ok → CLOSEDвикания към доставчика: 6 от 12От 12 заявки само 6 стигат до доставчика. Другите получават отказ веднага: не държат нишка и не натоварват някой, който и без това не отговаря. Цената се вижда на тикове 7 до 9: доставчикът вече работи, а прекъсвачът още отказва. По-кратко изчакване връща работата по-рано, но удря доставчика по-често.
JavaScript няма enum, затова състоянията са низове. Полетата на класа (state = "CLOSED") се пишат направо в тялото му.Пробвай
Попълни
Колко тика чака отвореният прекъсвач, преди да пусне проба?
if (this.state === "OPEN") {
if (tick - this.openedAt < )
return "отказ веднага";
this.state = "HALF_OPEN";
}Времето за изчакване. Докато не е минало, всяка заявка получава отказ веднага. После прекъсвачът става полуотворен и пуска една проба.
Идеята
Bulkhead и отговорът при отказ
Bulkhead (преграда, като в корабния корпус) е отделен пул нишки за всеки съсед: плащането има свои 4 нишки, каталогът свои. Тогава бавният доставчик изяжда само своите.
Прекъсвачът и timeout-ът казват „не сега“, а системата трябва да знае какво да направи тогава. Това е fallback: поръчката се запазва като „чака плащане“ и клиентът плаща по-късно; страницата показва цената от кеша; плащането минава през втори доставчик. Кой отговор е приемлив, решава бизнесът, както при компромисите.
Идеята
Кога вреди
- Повторения на всеки слой. Шлюзът повтаря 3 пъти, поръчките 3 пъти, плащането 3 пъти: до 27 заявки към доставчика за едно натискане. Повтаря се на едно място.
- Повторение на операция, която не е идемпотентна. Двойно плащане, два имейла, две пратки. Без ключ за идемпотентност не повтаряй.
- Timeout по-дълъг от този на викащия. Горният слой вече е казал „грешка“ на клиента, а долният още чака и повтаря.
- Прекъсвач за цялата услуга заради един бавен адрес. Ако е бавно само търсенето, не спирай и плащането.
- Числа, познати, а не мерени. Твърде нисък праг отваря прекъсвача при всеки шум, твърде дълго изчакване го държи отворен, когато съседът отдавна работи.
Същите механизми често ги прави и инфраструктурата (service mesh като Istio или Linkerd, API gateway). Тя се учи в курса по DevOps ZeroToOps. Тук е частта в кода: какво правиш, когато съседът не отговори.
По-точно, за любопитните
Библиотеките. В истински проект не пишеш прекъсвач сам: Java има Resilience4j (Spring Cloud Circuit Breaker го ползва); C# има Polly и Microsoft.Extensions.Http.Resilience за HttpClient; Python има tenacity за retry и pybreaker; Node има cockatiel и opossum. Hystrix на Netflix е исторически образец и вече не се развива.
Сроковете по подразбиране. В .NET HttpClient чака 100 секунди по подразбиране. В Java HttpClient и в requests за Python срок няма, докато не го зададеш. В Node fetch (undici) чака заглавките до 300 секунди. Мрежовите клиенти видя в урока „Мрежово програмиране“.
Срокът се предава. Ако клиентът има общо 2 секунди, а шлюзът вече е изхарчил 0,5, услугата под него получава 1,5, не своите 2. gRPC го прави с deadline, който пътува със заявката.
Retry-After и бюджет на повторенията. Отговорите 503 и 429 могат да кажат кога да опиташ (Retry-After). Много системи ограничават и повторенията общо, например до 10% от заявките, за да не удвоят натоварването точно когато е най-лошо.
Видове jitter. Тук е „пълен“ jitter: случайно от 1 до двойното. Има и „равен“ (половината фиксирана, половината случайна) и „декорелиран“. Целта е една: клиентите да не се синхронизират.
2 ** n е степенуване. Math.random няма семе, затова генераторът е собствен.Упражнение
Повторения с краен срок
Реши задачата в редактора си.
Клиентът чака плащането най-много 5 тика. Напиши withDeadline, който повтаря като withRetry, но без граница на опитите: спира, когато следващият опит би бил след крайния срок, и казва защо. Доставчикът отговаря timeout три пъти, после ok.
тик 0, опит 1: timeoutтик 1, опит 2: timeoutтик 3, опит 3: timeoutспирам: опит 4 би бил на тик 7, след срока 5резултат: timeoutОще задачи
- Прекъсвач и retry заедно. Сложи прекъсвача пред
withRetry. Отказът „веднага“ временна грешка ли е? - Bulkhead. Раздели пула от проблема на две: 2 нишки за плащания и 2 за каталога. Колко заявки за каталога се отказват сега без timeout?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Мрежово програмиране · Пул от нишки и задачи · Дизайн на API: договор, версии, повторени заявки · Нефункционални изисквания, които се мерят
- Продължава в: Транзакция през няколко услуги (saga) · Интеграция със стари и чужди системи · Архитектурен преглед · Твоята първа архитектурна задача
- Задачи по темата: Архитектът в работа