Към съдържанието
wannacode

Когато съседът е бавен: timeout, retry, circuit breaker

Накратко: Timeout спира чакането на бавна услуга, retry с експоненциално чакане и jitter повтаря само временните грешки, а circuit breaker спира заявките, докато услугата не се оправи.

Проблем

Тридесет секунди, които спряха магазина

Сутринта на Black Friday доставчикът на плащания започва да отговаря за 30 секунди вместо за 300 ms. Не пада: отговаря, но бавно. След минута магазинът не показва дори каталога, а каталогът изобщо не вика доставчика.

Ето защо, на малко. Магазинът има пул от 4 нишки (урокът за пула от нишки). Всеки тик идват едно плащане и една заявка за каталога. Доставчикът отговаря за 30 тика:

// пул от 4 нишки; всеки тик идват едно плащане и една заявка за каталога
static void run(String title, int timeout) {
int[] busyUntil = new int[4]; // до кой тик е заета всяка нишка
int refused = 0;
for (int t = 0; t < 8; t++) {
int free = freeThread(busyUntil, t);
// плащането държи нишката, докато доставчикът отговори
if (free >= 0) busyUntil[free] = t + Math.min(30, timeout);
if (freeThread(busyUntil, t) < 0) refused++; // каталогът няма свободна нишка
}
long busy = Arrays.stream(busyUntil).filter(u -> u > 7).count();
System.out.println(title + ": заети нишки " + busy + " от 4, каталогът отказа " + refused + " заявки");
}
static int freeThread(int[] busyUntil, int t) {
for (int i = 0; i < busyUntil.length; i++)
if (busyUntil[i] <= t) return i;
return -1;
}
run("без timeout", Integer.MAX_VALUE);
Изход
без 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 (повторение). Три правила го правят безопасно:

  1. Само временните грешки. Timeout и 503 се повтарят. „Картата е отказана“ или 400 (грешна заявка) ще са същите и на втория опит.
  2. Чакане, което расте: 1, 2, 4, 8 тика. Това е експоненциално чакане (exponential backoff): съседът, който се дави, получава все повече въздух.
  3. Граница на опитите. След нея се отказваш и връщаш последната грешка.
// само временните грешки: доставчикът може да се оправи
static boolean retryable(String answer) {
return answer.equals("timeout") || answer.equals("503");
}
static String withRetry(List<String> answers, int maxAttempts) {
int tick = 0;
for (int attempt = 1; ; attempt++) {
String answer = answers.get(attempt - 1); // записан отговор на опит номер attempt
System.out.println("тик " + tick + ", опит " + attempt + ": " + answer);
if (!retryable(answer) || attempt == maxAttempts) return answer;
tick += 1 << (attempt - 1); // чакане 1, 2, 4, 8… тика
}
}

Доставчикът отговаря по записан сценарий: timeout, после 503, после ok.

System.out.println("резултат: " + withRetry(List.of("timeout", "503", "ok"), 4));
Изход
тик 0, опит 1: timeout
тик 1, опит 2: 503
тик 3, опит 3: ok
резултат: ok

Внимание: повторено плащане може да изтегли парите два пъти, ако първото е минало, а само отговорът се е изгубил. Затова плащането се повтаря само с ключ за идемпотентност: доставчикът познава повторената заявка и връща първия резултат.

Пробвай

Предвиди изхода

Доставчикът отговаря timeout три пъти и ok на четвъртия опит. Границата е 3 опита. Какво печата програмата?

System.out.println("резултат: " + withRetry(List.of("timeout", "timeout", "timeout", "ok"), 3));
Отговор

Пробвай

Намери грешката

Клиент с отказана карта чака цели 7 тика, а банката му праща четири известия за отказ. На кой ред е грешката?

Избери реда с грешката.

Идеята

Jitter: да не тръгнат всички заедно

Когато доставчикът падне, падат хиляди плащания наведнъж. С експоненциалното чакане всички опитват пак на тик 1, после на 3, после на 7: заедно, точно когато доставчикът се опитва да се съвземе. Това е thundering herd (стадото).

Jitter добавя случайност към чакането: всеки клиент чака случайно число тикове, от 1 до двойното на чакането. Програмата ползва малък генератор със семе, за да дава едни и същи числа на всеки език и при всяко пускане:

static int seed = 7;
// генератор със семе: същите числа на всеки език
static int random(int bound) {
seed = (seed * 75 + 74) % 65537;
return seed % bound;
}
static String retryTicks(boolean jitter) {
StringJoiner ticks = new StringJoiner(" ");
int tick = 0;
for (int attempt = 0; attempt < 4; attempt++) {
int wait = 1 << attempt; // 1, 2, 4, 8
if (jitter) wait = 1 + random(2 * wait); // от 1 до 2 · wait
tick += wait;
ticks.add(String.valueOf(tick));
}
return ticks.toString();
}
for (String client : List.of("A", "B", "C"))
System.out.println(client + " без jitter: " + retryTicks(false));
for (String client : List.of("A", "B", "C"))
System.out.println(client + " с jitter : " + retryTicks(true));
Изход
A без jitter: 1 3 7 15
B без jitter: 1 3 7 15
C без jitter: 1 3 7 15
A с jitter : 2 6 11 13
B с jitter : 2 5 12 19
C с jitter : 1 5 8 9

Без jitter тримата удрят доставчика в едни и същи тикове. С jitter се разпръскват. В истинския код семе не се слага: ползваш генератора на езика, а при тестовете подаваш свой, за да е изходът предвидим.

Идеята

Circuit breaker: спри да викаш

Retry помага при кратък проблем. Ако доставчикът не работи от десет минути, всяко повторение е още една заявка към някой, който се дави, и още една нишка, която чака. Тогава трябва circuit breaker (прекъсвач), като предпазителя в електрическото табло. Той има три състояния:

  • затворен (closed): заявките минават. Прекъсвачът брои грешките поред;
  • отворен (open): след 3 грешки поред спира да вика. Всяка заявка получава отказ веднага, без да чака;
  • полуотворен (half-open): след 4 тика пуска една проба. Ако мине, прекъсвачът се затваря. Ако падне, пак се отваря.
enum State { CLOSED, OPEN, HALF_OPEN }
class CircuitBreaker {
static final int THRESHOLD = 3; // поредни грешки до отваряне
static final int COOLDOWN = 4; // тикове в OPEN преди проба
State state = State.CLOSED;
int failures = 0;
int openedAt = 0;
int calls = 0; // колко пъти е викан доставчикът
String call(int tick, boolean providerUp) {
if (state == State.OPEN) {
if (tick - openedAt < COOLDOWN)
return "отказ веднага";
state = State.HALF_OPEN; // една проба
}
calls++;
if (providerUp) {
state = State.CLOSED;
failures = 0;
return "ok";
}
failures++;
boolean tooMany = failures >= THRESHOLD;
if (state == State.HALF_OPEN || tooMany) {
state = State.OPEN;
openedAt = tick;
}
return "грешка";
}
}

Виж го

Прекъсвачът през 12 тика

String call(int tick, boolean providerUp) {
if (state == State.OPEN) {
if (tick - openedAt < COOLDOWN)
return "отказ веднага";
state = State.HALF_OPEN; // една проба
}
calls++;
if (providerUp) {
state = State.CLOSED;
failures = 0;
return "ok";
}
failures++;
boolean tooMany = failures >= THRESHOLD;
if (state == State.HALF_OPEN || tooMany) {
state = State.OPEN;
openedAt = tick;
}
return "грешка";
}
Стъпка по стъпка, с думи
  1. CLOSED: всяко викане стига до доставчика на плащания. Той не отговаря: тик 0 и тик 1 са грешки, броячът е 2 от 3.
  2. Тик 2: трета грешка поред. Прекъсвачът се отваря (OPEN) и помни кога.
  3. Тикове 3, 4 и 5 получават отказ веднага. Не стигат до доставчика и не държат нишка. Магазинът може веднага да предложи „плати по-късно“.
  4. Тик 6: минали са 4 тика. HALF_OPEN: минава една проба. Доставчикът още не работи и прекъсвачът пак се отваря.
  5. От тик 7 доставчикът пак работи, но прекъсвачът още не знае. Тикове 7 до 9 получават отказ: това е цената на чакането.
  6. Тик 10: пробата минава, прекъсвачът се затваря (CLOSED) и броячът се нулира. Доставчикът е викан 6 пъти от 12.

Доставчикът не отговаря до тик 7, а после работи. Записаният сценарий е tick >= 7:

var breaker = new CircuitBreaker();
for (int tick = 0; tick < 12; tick++) {
boolean up = tick >= 7; // записано: доставчикът се връща на тик 7
String result = breaker.call(tick, up);
System.out.println("тик " + tick + ": " + result + " → " + breaker.state);
}
System.out.println("викания към доставчика: " + 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: доставчикът вече работи, а прекъсвачът още отказва. По-кратко изчакване връща работата по-рано, но удря доставчика по-често.

Пробвай

Попълни

Колко тика чака отвореният прекъсвач, преди да пусне проба?

if (state == State.OPEN) {
    if (tick - openedAt < )
        return "отказ веднага";
    state = 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 до двойното. Има и „равен“ (половината фиксирана, половината случайна) и „декорелиран“. Целта е една: клиентите да не се синхронизират.

1 << (attempt - 1) е 2 на степен attempt − 1 с отместване на битове. StringJoiner слепва числата с интервал.

Упражнение

Повторения с краен срок

Реши задачата в редактора си.

Клиентът чака плащането най-много 5 тика. Напиши withDeadline, който повтаря като withRetry, но без граница на опитите: спира, когато следващият опит би бил след крайния срок, и казва защо. Доставчикът отговаря timeout три пъти, после ok.

Изход
тик 0, опит 1: timeout
тик 1, опит 2: timeout
тик 3, опит 3: timeout
спирам: опит 4 би бил на тик 7, след срока 5
резултат: timeout

Още задачи

  • Прекъсвач и retry заедно. Сложи прекъсвача пред withRetry. Отказът „веднага“ временна грешка ли е?
  • Bulkhead. Раздели пула от проблема на две: 2 нишки за плащания и 2 за каталога. Колко заявки за каталога се отказват сега без timeout?

Тест

Провери се

1. Защо бавният доставчик е по-опасен от доставчик, който изобщо не работи?
2. Кои грешки си струва да се повторят?
3. Защо към чакането се добавя jitter?
4. Прекъсвачът е отворен. Какво става с нова заявка, преди да мине времето за изчакване?
5. Три слоя един над друг, всеки повтаря до 3 пъти. Колко заявки може да стигнат до най-долния, ако той не отговаря?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·