Skip to main content

Що таке circuit breaker?

Circuit breaker - це шаблон проектування, який виявляє повторні збої при зверненні до зовнішнього сервісу і тимчасово зупиняє туди надсилати запити. Один повільний сервіс не кладе всю систему.

Теорія

TL;DR

  • Аналогія: автоматичний вимикач у щитку - спрацьовує при перевантаженні, захищає проводку, потім скидається.
  • Три стани: Closed (все проходить), Open (миттєве відхилення), Half-Open (пробні запити для перевірки відновлення).
  • Головна різниця з retry: retry збільшує навантаження на сервіс що падає; circuit breaker зупиняє весь трафік до сигналу відновлення.
  • Використовуй при зверненнях до зовнішніх API, баз даних, мікросервісів де частота помилок перевищує 10-20%.
  • Завжди визначай fallback - кешовані дані або дефолтне значення замість голої помилки.

Швидкий приклад

javascript
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.

javascript
// Неправильно - відкривається на 1 збій з 1 виклику { errorThresholdPercentage: 1 } // Правильно - чекає мінімум 10 викликів перед оцінкою { errorThresholdPercentage: 50, volumeThreshold: 10 }

Немає fallback

Без fallback клієнт отримує голу 500 або необроблений rejection. Кешовані дані або дефолтне значення набагато краще ніж сторінка помилки.

javascript
// Неправильно - 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, всі інші виклики (платежі, профіль, доставка) також блокуються.

javascript
// Неправильно - один 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

javascript
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 сервісу що звертається до мікросервісу інвентаризації.

javascript
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.

javascript
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 щоб уникнути неоднозначності.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?