Пошук уроків, статей та іншого контенту
Розберете стани Circuit Breaker, налаштування порогів і відновлення викликів після відмов залежності.
Circuit Breaker — це патерн захисту системи від повторних викликів залежності, яка працює нестабільно або недоступна.
Залежністю може бути:
HTTP-сервіс;
база даних;
черга повідомлень;
зовнішній платіжний або поштовий API;
будь-який інший компонент, доступний через мережу.
Без Circuit Breaker система продовжує надсилати запити до несправної залежності. Це може призвести до:
накопичення тайм-аутів;
зайнятих потоків або з’єднань;
збільшення навантаження на залежність;
каскадного поширення відмов;
повільної відповіді для користувачів.
Circuit Breaker тимчасово припиняє виклики до залежності, коли кількість помилок перевищує налаштований поріг. Після паузи він обережно перевіряє, чи відновилася залежність.
Circuit Breaker має три основні стани:
CLOSED — виклики дозволені;
OPEN — виклики заблоковані;
HALF_OPEN — виконується пробна перевірка відновлення.
У стані CLOSED Circuit Breaker пропускає виклики до залежності.
Він підраховує помилки. Якщо кількість помилок досягає налаштованого порогу, Circuit Breaker переходить у стан OPEN.
Наприклад:
поріг помилок — 3;
перша помилка збільшує лічильник до 1;
друга — до 2;
третя — відкриває Circuit Breaker.
Після успішного виклику лічильник послідовних помилок зазвичай скидається.
OPENУ стані OPEN нові виклики до залежності не виконуються. Замість цього система одразу отримує помилку Circuit Breaker.
Це називається швидкою відмовою або fail fast.
Circuit Breaker залишається відкритим протягом певного часу, наприклад 10 секунд. Цей параметр часто називають:
resetTimeout;
openDuration;
cooldownPeriod.
Поки цей час не минув, немає сенсу навантажувати залежність новими запитами.
HALF_OPENПісля завершення паузи Circuit Breaker переходить у стан HALF_OPEN.
У цьому стані дозволяються один або кілька пробних викликів:
якщо вони успішні, Circuit Breaker повертається до CLOSED;
якщо вони знову завершуються помилкою, Circuit Breaker повертається до OPEN.
У стані HALF_OPEN важливо обмежувати кількість одночасних пробних викликів. Інакше після відновлення паузи система може одразу надіслати велику кількість запитів до ще нестабільної залежності.
Поріг визначає, скільки помилок потрібно для переходу з CLOSED у OPEN.
Поріг можна рахувати по-різному:
кількість послідовних помилок;
кількість помилок у часовому вікні;
відсоток помилок серед останніх викликів.
Для простих сценаріїв достатньо послідовного лічильника. Наприклад, три послідовні помилки відкривають Circuit Breaker.
Для сервісів із нерівномірним навантаженням часто корисніше використовувати часові вікна або відсоток помилок. Наприклад, відкрити Circuit Breaker, якщо за останні 100 запитів помилковими були понад 50%.
OPENЦе час, протягом якого виклики блокуються без спроб звернутися до залежності.
Занадто короткий інтервал може створити повторне навантаження на залежність, яка ще не встигла відновитися.
Занадто довгий інтервал означає, що система довше не перевірятиме доступність залежності.
Можна вимагати один або кілька успішних викликів у стані HALF_OPEN перед переходом до CLOSED.
Наприклад:
один успішний виклик — для простої залежності;
кілька успішних викликів — для нестабільної або критично важливої залежності.
Після успішного відновлення лічильники помилок потрібно скинути.
Circuit Breaker повинен враховувати не лише явні помилки, а й надто довгі виклики.
Залежність, яка не відповідає протягом 30 секунд, фактично теж недоступна для користувача. Тому тайм-аут запиту має трактуватися як помилка, яка впливає на стан Circuit Breaker.
Нижче наведено спрощену реалізацію Circuit Breaker на JavaScript. Вона використовує:
три послідовні помилки для відкриття;
паузу 1 секунду у стані OPEN;
два успішні пробні виклики для повернення до CLOSED;
лише один одночасний пробний виклик у стані HALF_OPEN.
class CircuitOpenError extends Error {
constructor() {
super("Circuit Breaker відкритий");
this.name = "CircuitOpenError";
}
}
class CircuitBreaker {
constructor({
failureThreshold = 3,
resetTimeout = 1000,
successThreshold = 2
} = {}) {
this.failureThreshold = failureThreshold;
this.resetTimeout = resetTimeout;
this.successThreshold = successThreshold;
this.state = "CLOSED";
this.failureCount = 0;
this.successCount = 0;
this.openedAt = 0;
this.probeInFlight = false;
}
async execute(action) {
this.prepareState();
if (this.state === "OPEN") {
throw new CircuitOpenError();
}
if (this.state === "HALF_OPEN") {
if (this.probeInFlight) {
throw new CircuitOpenError();
}
this.probeInFlight = true;
}
try {
const result = await action();
this.handleSuccess();
return result;
} catch (error) {
this.handleFailure();
throw error;
} finally {
if (this.state === "HALF_OPEN") {
this.probeInFlight = false;
}
}
}
prepareState() {
if (
this.state === "OPEN" &&
Date.now() - this.openedAt >= this.resetTimeout
) {
this.state = "HALF_OPEN";
this.successCount = 0;
this.probeInFlight = false;
}
}
handleSuccess() {
if (this.state === "CLOSED") {
// Успішний виклик скидає лічильник послідовних помилок.
this.failureCount = 0;
return;
}
if (this.state === "HALF_OPEN") {
this.successCount += 1;
if (this.successCount >= this.successThreshold) {
this.state = "CLOSED";
this.failureCount = 0;
this.successCount = 0;
}
}
}
handleFailure() {
if (this.state === "HALF_OPEN") {
this.open();
return;
}
if (this.state === "CLOSED") {
this.failureCount += 1;
if (this.failureCount >= this.failureThreshold) {
this.open();
}
}
}
open() {
this.state = "OPEN";
this.openedAt = Date.now();
this.failureCount = 0;
this.successCount = 0;
this.probeInFlight = false;
}
getState() {
return this.state;
}
}
const sleep = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
let dependencyCall = 0;
async function unstableDependency() {
dependencyCall += 1;
// Перші три виклики імітують недоступну залежність.
if (dependencyCall <= 3) {
throw new Error("Залежність недоступна");
}
return `Успішна відповідь №${dependencyCall}`;
}
async function main() {
const breaker = new CircuitBreaker({
failureThreshold: 3,
resetTimeout: 1000,
successThreshold: 2
});
for (let attempt = 1; attempt <= 7; attempt += 1) {
try {
const result = await breaker.execute(unstableDependency);
console.log(
`Спроба ${attempt}: ${result}; стан: ${breaker.getState()}`
);
} catch (error) {
console.log(
`Спроба ${attempt}: ${error.message}; стан: ${breaker.getState()}`
);
}
// Після четвертої спроби чекаємо завершення паузи стану OPEN.
if (attempt === 4) {
await sleep(1100);
}
}
}
main();Порядок роботи прикладу:
Перший виклик завершується помилкою.
Другий виклик завершується помилкою.
Третій виклик завершується помилкою, тому Circuit Breaker переходить у OPEN.
Четвертий виклик блокується одразу, без звернення до залежності.
Після паузи Circuit Breaker переходить у HALF_OPEN.
Наступні два успішні пробні виклики переводять його у CLOSED.
Не кожна помилка залежності означає, що її потрібно вважати недоступною.
Зазвичай до відкриття Circuit Breaker можуть призводити:
тайм-аут;
помилка мережевого з’єднання;
неможливість встановити TCP-з’єднання;
помилка DNS;
відповіді 5xx від HTTP-сервісу.
Натомість помилки бізнес-логіки часто не повинні змінювати стан Circuit Breaker:
некоректні дані запиту;
відсутній ресурс;
помилка автентифікації;
відповідь 4xx, якщо вона означає помилку клієнта.
Наприклад, неправильний пароль не свідчить про те, що сервіс автентифікації недоступний.
Тому в реальній реалізації потрібно мати правило класифікації помилок:
function isDependencyFailure(error) {
return (
error.code === "ETIMEDOUT" ||
error.code === "ECONNRESET" ||
error.code === "ECONNREFUSED" ||
error.status >= 500
);
}Якщо бібліотека Circuit Breaker дозволяє налаштувати предикат помилки, до лічильника потрібно додавати лише помилки, які справді вказують на проблему залежності.
Circuit Breaker не усуває помилку залежності. Він лише обмежує наслідки її несправності.
Після переходу в OPEN система може поводитися по-різному:
повертати помилку клієнту;
використовувати кешовані дані;
повертати значення за замовчуванням;
ставити операцію в чергу;
пропускати необов’язкову операцію.
Ця поведінка називається fallback. Вона залежить від призначення конкретного виклику.
Наприклад, якщо недоступний сервіс рекомендацій товарів, можна показати сторінку без рекомендацій. Але для платіжної операції зазвичай потрібно повідомити користувача про неможливість завершити оплату, а не повертати успішний результат.
Circuit Breaker та retry вирішують різні задачі:
retry повторює окремий невдалий виклик;
Circuit Breaker тимчасово блокує нові виклики після серії відмов.
Їх можна комбінувати, але необережне налаштування створює зайве навантаження.
Наприклад, один запит може мати три повторні спроби, а кожна спроба — потрапляти під Circuit Breaker. Якщо багато запитів одночасно запускають retry, кількість звернень до несправної залежності значно зростає.
Тому для retry зазвичай потрібні:
обмежена кількість повторів;
затримка між спробами;
поступове збільшення затримки;
випадкове розсіювання затримки для одночасних клієнтів.
У межах Circuit Breaker важливо, щоб остаточний невдалий результат враховувався як одна помилка операції, а не як неконтрольоване збільшення лічильника через внутрішні повтори.
Стан Circuit Breaker потрібно контролювати через логи та метрики.
Корисно відстежувати:
кількість переходів CLOSED → OPEN;
тривалість перебування у OPEN;
кількість відхилених викликів;
кількість успішних і невдалих проб у HALF_OPEN;
кількість тайм-аутів;
частку помилок залежності;
час відповіді залежності.
Відкритий Circuit Breaker не завжди означає помилку самої програми. Це може бути сигналом, що залежність недоступна або її параметри не відповідають навантаженню.
У спрощеному вигляді алгоритм виглядає так:
У стані CLOSED виконати виклик.
Якщо виклик успішний, скинути лічильник помилок.
Якщо виклик завершився помилкою, збільшити лічильник.
Після досягнення порогу перейти у OPEN.
У стані OPEN відхиляти виклики без звернення до залежності.
Після завершення тайм-ауту перейти у HALF_OPEN.
Виконати обмежену кількість пробних викликів.
За успішного відновлення перейти у CLOSED.
За нової помилки знову перейти у OPEN.
Поодинока мережева помилка не обов’язково означає тривалу недоступність залежності. Якщо відкривати Circuit Breaker після першого збою, система стане надто чутливою.
Потрібно використовувати поріг або статистичне правило.
Якщо виклик може чекати відповідь необмежено довго, Circuit Breaker може не отримати результат і не перейти в потрібний стан.
Кожен мережевий виклик повинен мати обмежений час очікування.
OPENДуже довга пауза може приховати відновлення залежності й довше, ніж потрібно, блокувати корисні операції.
Значення потрібно підбирати відповідно до часу типового відновлення залежності.
Якщо після паузи всі запити одразу переходять у HALF_OPEN і викликають залежність, система може перевантажити її під час відновлення.
Потрібно обмежувати кількість пробних викликів.
Помилка валідації або неправильний запит клієнта не означає відмову залежності. Якщо враховувати такі помилки, Circuit Breaker може відкриватися без реальної проблеми з сервісом.
Відхилити виклик недостатньо. Потрібно визначити, що отримає користувач або інша частина системи, коли залежність недоступна.
У простій реалізації стан Circuit Breaker зберігається в пам’яті процесу. Після перезапуску процесу лічильники скидаються.
Для багатьох випадків це прийнятно, оскільки Circuit Breaker захищає конкретний екземпляр застосунку. Але важливо враховувати цю властивість під час проєктування системи з кількома екземплярами.
Circuit Breaker захищає систему від повторних викликів несправної залежності.
CLOSED пропускає виклики та підраховує помилки.
OPEN блокує виклики й забезпечує швидку відмову.
HALF_OPEN виконує обмежені пробні виклики після паузи.
Основні параметри — поріг помилок, тривалість OPEN і кількість успішних проб.
Тайм-аути потрібно враховувати як можливі відмови залежності.
Не всі помилки слід додавати до лічильника Circuit Breaker.
Для стабільного відновлення потрібно обмежувати пробні виклики та визначати fallback.
Метрики переходів між станами допомагають зрозуміти стан залежностей і коректність налаштувань.