Що таке circuit breaker?
Circuit breaker - це шаблон проектування, який виявляє повторні збої при зверненні до зовнішнього сервісу і тимчасово зупиняє туди надсилати запити. Один повільний сервіс не кладе всю систему.
Теорія
TL;DR
- Аналогія: автоматичний вимикач у щитку - спрацьовує при перевантаженні, захищає проводку, потім скидається.
- Три стани: Closed (все проходить), Open (миттєве відхилення), Half-Open (пробні запити для перевірки відновлення).
- Головна різниця з retry: retry збільшує навантаження на сервіс що падає; circuit breaker зупиняє весь трафік до сигналу відновлення.
- Використовуй при зверненнях до зовнішніх API, баз даних, мікросервісів де частота помилок перевищує 10-20%.
- Завжди визначай fallback - кешовані дані або дефолтне значення замість голої помилки.
Швидкий приклад
const CircuitBreaker = require('opossum');
const axios = require('axios');
const breaker = new CircuitBreaker(async () => {
return axios.get('https://api.example.com/inventory');
}, {
timeout: 1000, // помилка якщо немає відповіді за 1с
errorThresholdPercentage: 50, // відкрити після 50% помилок
resetTimeout: 5000 // пробний запит через 5с
});
breaker.fallback(() => ({ available: false, source: 'cache' }));
breaker.fire().then(console.log);Після того як 50% запитів завершились помилкою, breaker.fire() одразу повертає fallback замість того щоб чекати на недоступний сервіс.
Як працюють три стани
Closed - нормальний стан. Запити проходять, breaker рахує помилки у ковзному вікні (останні N секунд через кільцевий буфер). Коли частка помилок перевищує поріг, стан змінюється на Open.
Open - миттєве відхилення. Breaker навіть не намагається зробити виклик: повертає Promise.reject() або налаштований fallback одразу. Запускається таймер відліку до наступної перевірки.
Half-Open - пробний режим. Після закінчення resetTimeout breaker пропускає 1-2 запити. Якщо вони успішні, стан повертається в Closed. Якщо ні - breaker знову відкривається і таймер починається заново. Тут ховається тонка проблема: в кластері зі 100 подів усі можуть почати пробні запити одночасно і добити сервіс що вже відновлюється. Рішення - розбити проби: наприклад, тільки поди де podIndex % 10 === 0 роблять probe, або використати leader election.
Як breaker відстежує збої всередині
Breaker тримає ковзне вікно - зазвичай реалізоване як кільцевий буфер (у Hystrix це RingBitSet). Кожен запит записується як біт: 0 для успіху, 1 для помилки. На кожному запиті перераховується (failures / total) * 100. Якщо це число перевищує errorThresholdPercentage І загальна кількість викликів перевищує volumeThreshold - breaker відкривається.
Параметр volumeThreshold легко пропустити, але він важливий. Без нього один збій з одного загального виклику дає 100% помилок і некоректно відкриває breaker. Бібліотеки opossum і Resilience4j обидві надають цей параметр.
Коли використовувати
- Виклики до зовнішніх сервісів з великою затримкою (БД, API, мікросервіси) - так, потрібен circuit breaker.
- Service mesh з Istio або Linkerd - логіка breaker реалізована на рівні Envoy proxy, налаштовується декларативно.
- Batch-задачі що запускаються раз на годину з малим об'ємом - достатньо простого retry з exponential backoff.
- Внутрішні виклики всередині одного процесу - circuit breaker не потрібен.
- Частота помилок стабільно менше 5-10% - overhead без користі.
Кожен сервіс або endpoint отримує окремий екземпляр breaker. Один спільний breaker для всіх викликів означає що один повільний сервіс блокує платежі, профіль користувача і все інше разом.
Типові помилки
Занадто низький errorThresholdPercentage без volumeThreshold
Один мережевий збій дає 100% помилок, відкриває breaker і блокує абсолютно здоровий сервіс на весь час resetTimeout.
// Неправильно - відкривається на 1 збій з 1 виклику
{ errorThresholdPercentage: 1 }
// Правильно - чекає мінімум 10 викликів перед оцінкою
{ errorThresholdPercentage: 50, volumeThreshold: 10 }Немає fallback
Без fallback клієнт отримує голу 500 або необроблений rejection. Кешовані дані або дефолтне значення набагато краще ніж сторінка помилки.
// Неправильно - caller отримує unhandled exception
breaker.fire(sku).then(use);
// Правильно - повернути останні відомі дані з кешу
breaker.fallback(async (sku) => {
const cached = await redis.get(`inventory:${sku}`);
return cached ? JSON.parse(cached) : { available: false };
});Один глобальний breaker для всіх сервісів
Якщо сервіс інвентаризації деградує і відкриває спільний breaker, всі інші виклики (платежі, профіль, доставка) також блокуються.
// Неправильно - один breaker на все
const globalBreaker = new CircuitBreaker(anyCall, options);
// Правильно - окремий екземпляр на кожну залежність
const inventoryBreaker = new CircuitBreaker(checkInventory, options);
const paymentBreaker = new CircuitBreaker(chargeCard, options);Неправильний resetTimeout
1 секунда - breaker занадто агресивно зондує сервіс що відновлюється. 5 хвилин - система залишається в деградованому стані довго після того як downstream сервіс вже здоровий. Спирайся на реальний час відновлення залежності, зазвичай 30 секунд до 2 хвилин.
Де зустрічається в реальних системах
- Netflix - Hystrix реалізував breaker на кожну команду всередині Zuul gateway, з дашбордом для моніторингу стану в реальному часі.
- Spring Cloud - Resilience4j замінив Hystrix і тепер є стандартним circuit breaker у Spring Boot.
- Node.js -
opossum(розроблений у PayPal) є стандартною бібліотекою для Express-мікросервісів. - AWS - API Gateway з Lambda використовує вбудований throttling як проксі-breaker.
- Istio - Envoy sidecar-и реалізують circuit breaking на рівні mesh через конфіг
DestinationRule, без змін у коді застосунку.
Follow-up питання
Q: Поясни три стани та переходи між ними без коду.
A: Closed рахує помилки у вікні; перевищення порогу переводить в Open, який відхиляє миттєво і запускає таймер. Після таймера Half-Open пропускає 1-2 пробні запити; успіх закриває, невдача відкриває знову.
Q: Чим circuit breaker відрізняється від retry?
A: Retry надсилає більше запитів до сервісу що вже падає і посилює проблему під навантаженням. Circuit breaker зупиняє весь трафік до сигналу відновлення. Вони доповнюють одне одного: використовуй exponential backoff retry всередині breaker для тимчасових помилок, і відкривай breaker при стійких збоях.
Q: Як розподілити стан circuit breaker між 50 екземплярами одного сервісу?
A: Зберігай стан у Redis через pub/sub або розподілений лічильник. Тільки один екземпляр (обраний consistent hashing або leader election) робить probe в Half-Open, щоб уникнути thundering herd.
Q: Яка математика за errorThresholdPercentage?
A: (failures / total) * 100 > threshold, обчислюється у ковзному вікні. Hystrix використовує RingBitSet з останніх N викликів. Обчислення спрацьовує тільки якщо total >= volumeThreshold.
Q: В service mesh зі 100 подами - як уникнути thundering herd під час Half-Open?
A: Обмеж пробу: тільки поди де podIndex % N === 0 надсилають probe-запит. Або використай Envoy з outlier_detection - Istio через consecutiveErrors та interval робить це з коробки без змін у коді.
Приклади
Базовий circuit breaker через opossum
const CircuitBreaker = require('opossum');
const axios = require('axios');
const breaker = new CircuitBreaker(async () => {
const { data } = await axios.get('https://api.example.com/status');
return data;
}, {
timeout: 1000, // таймаут виклику 1с
errorThresholdPercentage: 50, // відкрити після 50% збоїв у вікні
resetTimeout: 5000, // half-open probe через 5с
volumeThreshold: 5 // мінімум 5 викликів перед оцінкою
});
breaker.fallback(() => ({ status: 'unknown', source: 'fallback' }));
// Event hooks для моніторингу
breaker.on('open', () => console.log('Breaker OPEN - відхиляємо запити'));
breaker.on('halfOpen', () => console.log('Breaker HALF-OPEN - зондуємо'));
breaker.on('close', () => console.log('Breaker CLOSED - трафік відновлено'));
breaker.fire()
.then(result => console.log('Відповідь:', result))
.catch(err => console.error('Відхилено:', err.message));Event hooks зручно підключити до Prometheus або DataDog щоб бачити зміни стану breaker на дашборді в реальному часі.
Production Express API з Redis fallback
Патерн для e-commerce сервісу що звертається до мікросервісу інвентаризації.
const express = require('express');
const CircuitBreaker = require('opossum');
const axios = require('axios');
const redis = require('redis');
const redisClient = redis.createClient();
// Окремий breaker на кожну залежність
const inventoryBreaker = new CircuitBreaker(async (sku) => {
const { data } = await axios.post('http://inventory-service/check', { sku });
return data;
}, {
timeout: 200, // inventory повинен відповісти за 200мс
errorThresholdPercentage: 25, // відкрити після 25% збоїв
resetTimeout: 10000, // чекати 10с перед probe
volumeThreshold: 10
});
inventoryBreaker.fallback(async (sku) => {
const cached = await redisClient.get(`inventory:${sku}`);
if (cached) return JSON.parse(cached);
return { available: false, source: 'default' };
});
const app = express();
app.use(express.json());
app.post('/order', async (req, res) => {
const { sku } = req.body;
try {
const inventory = await inventoryBreaker.fire(sku);
res.json({ canOrder: inventory.available });
} catch (err) {
res.status(503).json({ error: 'Сервіс недоступний' });
}
});
app.listen(3000);Я бачив як команди пропускали volumeThreshold в такій схемі і потім 20 хвилин дебажили чому breaker відкривається в staging після першого cold-start таймауту. Ставте його одразу.
Half-open probe і volumeThreshold на практиці
Деталь яка часто спливає на code review.
const breaker = new CircuitBreaker(apiCall, {
errorThresholdPercentage: 50,
volumeThreshold: 5, // потрібно 5 викликів перш ніж % щось означає
halfOpenActionCount: 2, // рівно 2 probe-запити перед рішенням
resetTimeout: 3000
});
// Без volumeThreshold:
// Виклик 1 падає -> 1/1 = 100% -> breaker відкривається одразу
// Неправильно для сервісу з одним cold-start таймаутом
// З volumeThreshold: 5:
// Виклики 1-4 падають -> недостатньо даних, залишається Closed
// Виклик 5 падає -> 5/5 = 100% -> breaker відкривається
// Набагато реалістичніша поведінка
breaker.on('halfOpen', () => {
console.log('Надсилаємо 2 probe-запити перед рішенням');
});halfOpenActionCount також важливий у розподіленому середовищі. Якщо два probe-запити йдуть одночасно і один успішний а інший ні, результат залежить від порядку завершення. Тому частина команд залишає це значення на 1 щоб уникнути неоднозначності.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.