Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Circuit Breaker и ограничение параллелизма: меньше аварий и стабильнее SLA без лишних серверов

Разработка и технологии27 января 2026 г.
Когда внешнее API, база или наш сервис начинают «плыть», система часто добивает их лавиной повторных запросов. Простая комбинация предохранителя (circuit breaker), ограничения параллелизма и аккуратного сброса части нагрузки сдерживает шторм, предотвращает каскадные отказы и экономит на инфраструктуре.
Circuit Breaker и ограничение параллелизма: меньше аварий и стабильнее SLA без лишних серверов

Оглавление

  • Зачем это бизнесу: где теряются деньги
  • Что за предохранитель (circuit breaker) и чем он помогает
  • Ограничение параллелизма и «сброс нагрузки»: быстрое лекарство от шторма
  • Как выбрать пороги: ошибка vs пропускная способность
  • Реализация на Go: предохранитель + транспорт для HTTP‑клиента
    • Быстрый фолбэк
  • Отсеки (bulkheads): разделяем судьбу зависимостей
  • Наблюдаемость и опыт ввода в прод
  • Частые ошибки и как их избежать
  • Чек‑лист внедрения

Зачем это бизнесу: где теряются деньги

  • Пики трафика, «глюки» внешнего API или короткие деградации базы превращаются в лавины повторных запросов и очередей. В итоге падает не только проблемная зависимость, но и половина вашей платформы.
  • Пользователи видят долгие ожидания и ошибки. Конверсия падает, поддержка горит, аналитика и отчеты плывут.
  • Добавление серверов не спасает от каскадных отказов. Нужны защитные контуры: ограничить параллельность, быстро разорвать неудачные попытки и временно отрезать «больной» контур.

Комбинация трех простых техник дает максимум эффекта:

  1. Предохранитель (circuit breaker) — если доля ошибок высока, кратковременно «размыкаем» цепь и перестаем бить зависимость.
  2. Ограничение параллелизма — не даем запросам распухать до сотен одновременных, удерживаем нагрузку в безопасном коридоре.
  3. Сброс нагрузки (load shedding) — лучше честно отказать быстро, чем повесить все в очередях и обрушить систему.

Что за предохранитель (circuit breaker) и чем он помогает

Предохранитель следит за окном запросов (например, последние 10 секунд) и долей неуспехов. Если:

  • накопилось достаточно обращений (минимальный порог), и
  • доля ошибок превысила заданный порог,

он «размыкает» цепь на короткое время. Все новые вызовы в этот момент сразу получают быстрый отказ или «мягкий» фолбэк (например, ответ из кэша), не добивая зависимость. Затем предохранитель переходит в «полуоткрытое» состояние: пропускает несколько пробных запросов. Если те успешны — возвращаемся в «закрытое» (нормальная работа), если нет — снова «открытое».

Чем это полезно:

  • Защищает от каскадных отказов.
  • Снижает пиковую нагрузку, экономя на железе.
  • Улучшает P99: вместо секунд ожидания — быстрый контролируемый отказ или фолбэк.

Ограничение параллелизма и «сброс нагрузки»: быстрое лекарство от шторма

Ограничение параллелизма — это семафор на вызовы в зависимости. Например, одновременно держим не больше 50 запросов к платежному провайдеру. Остальные — сразу отклоняем (сброс нагрузки) или ставим в крохотную очередь с таймаутом. Ключевое: не допускать длинных очередей, которые превращают секунды проблем в минуты деградации.

Простой принцип:

  • Максимум одновременных запросов — фиксированная цифра.
  • Очередь — нулевая или очень маленькая (1–5), с коротким таймаутом.
  • Отказы по лимиту — быстрые и явные (например, HTTP 429/503), с понятным логом и метрикой.

Как выбрать пороги: ошибка vs пропускная способность

Стартовые настройки:

  • Окно анализа: 10–30 секунд, 10–20 «ведер» по 1–2 секунды.
  • Минимум запросов на решение: 20–50 (чтобы не реагировать на случайные всплески).
  • Порог ошибок: 50–70% (зависит от контекста; для критичных операций — ниже).
  • «Открытое» состояние: 20–60 секунд.
  • Пробные запросы в «полуоткрытом»: 1–5, нужны несколько подряд успешных, чтобы вернуться к «закрытому».
  • Максимум параллельных: отталкивайтесь от реальной устойчивой пропускной способности зависимой системы. Консервативное правило: на 20–30% ниже наблюдаемого безопасного уровня.

Пусть лучше 5–10% запросов получат быстрый отказ в пике, чем 100% пользователей увидят зависания и отмены.

Реализация на Go: предохранитель + транспорт для HTTP‑клиента

Ниже — рабочий пример: предохранитель с окном ошибок, полуоткрытым состоянием и ограничением параллельности для HTTP‑клиента. В проде добавьте метрики (счетчики ошибок, отказов по лимиту, состояние предохранителя) и конфиг через переменные окружения.

package main

import (
	"io"
	"log"
	"net/http"
	"strings"
	"sync"
	"time"
)

// Состояния предохранителя

type breakerState int

const (
	stateClosed breakerState = iota
	stateOpen
	stateHalfOpen
)

type bucket struct {
	success int
	failure int
	start   time.Time
}

type rollingWindow struct {
	buckets []bucket
	bucketDur time.Duration
	mu      sync.Mutex
}

func newRollingWindow(window time.Duration, buckets int) *rollingWindow {
	if buckets <= 0 {
		buckets = 10
	}
	bd := window / time.Duration(buckets)
	if bd <= 0 {
		bd = time.Second
	}
	rw := &rollingWindow{
		buckets:   make([]bucket, buckets),
		bucketDur: bd,
	}
	now := time.Now()
	for i := range rw.buckets {
		rw.buckets[i].start = now.Add(-time.Duration(len(rw.buckets)-i) * bd)
	}
	return rw
}

func (rw *rollingWindow) rotate() {
	now := time.Now()
	last := &rw.buckets[len(rw.buckets)-1]
	if now.Sub(last.start) < rw.bucketDur {
		return
	}
	shift := int(now.Sub(last.start) / rw.bucketDur)
	if shift > len(rw.buckets) {
		shift = len(rw.buckets)
	}
	for i := 0; i < shift; i++ {
		// сдвигаем влево, крайний — новый пустой
		copy(rw.buckets, rw.buckets[1:])
		rw.buckets[len(rw.buckets)-1] = bucket{start: last.start.Add(time.Duration(i+1) * rw.bucketDur)}
	}
	// обновим времена после сдвига
	for i := range rw.buckets {
		rw.buckets[i].start = now.Add(time.Duration(i-len(rw.buckets)) * rw.bucketDur)
	}
}

func (rw *rollingWindow) addSuccess() {
	rw.mu.Lock()
	defer rw.mu.Unlock()
	rw.rotate()
	rw.buckets[len(rw.buckets)-1].success++
}

func (rw *rollingWindow) addFailure() {
	rw.mu.Lock()
	defer rw.mu.Unlock()
	rw.rotate()
	rw.buckets[len(rw.buckets)-1].failure++
}

func (rw *rollingWindow) counts() (succ, fail int) {
	rw.mu.Lock()
	defer rw.mu.Unlock()
	rw.rotate()
	for _, b := range rw.buckets {
		succ += b.success
		fail += b.failure
	}
	return
}

// Настройки предохранителя

type BreakerOptions struct {
	Window           time.Duration  // окно анализа
	Buckets          int            // число «ведер» в окне
	FailureThreshold float64        // порог ошибок (0.0..1.0)
	MinimumRequests  int            // минимум обращений для принятия решения
	OpenTimeout      time.Duration  // сколько держать «открытое» состояние
	ProbeRequests    int            // сколько параллельных проб в «полуоткрытом» состоянии
	ProbeSuccesses   int            // сколько успешных проб подряд, чтобы закрыть
	OnStateChange    func(from, to breakerState)
}

type Breaker struct {
	mu               sync.Mutex
	state            breakerState
	lastChange       time.Time
	rw               *rollingWindow
	opts             BreakerOptions
	halfOpenInFlight int
	halfOpenSuccess  int
}

func NewBreaker(opts BreakerOptions) *Breaker {
	if opts.Window == 0 {
		opts.Window = 10 * time.Second
	}
	if opts.Buckets <= 0 {
		opts.Buckets = 10
	}
	if opts.FailureThreshold <= 0 {
		opts.FailureThreshold = 0.5
	}
	if opts.MinimumRequests <= 0 {
		opts.MinimumRequests = 20
	}
	if opts.OpenTimeout <= 0 {
		opts.OpenTimeout = 30 * time.Second
	}
	if opts.ProbeRequests <= 0 {
		opts.ProbeRequests = 1
	}
	if opts.ProbeSuccesses <= 0 {
		opts.ProbeSuccesses = 1
	}
	return &Breaker{
		state:      stateClosed,
		lastChange: time.Now(),
		rw:         newRollingWindow(opts.Window, opts.Buckets),
		opts:       opts,
	}
}

func (b *Breaker) setState(to breakerState) {
	if b.state == to {
		return
	}
	from := b.state
	b.state = to
	b.lastChange = time.Now()
	b.halfOpenInFlight = 0
	b.halfOpenSuccess = 0
	if b.opts.OnStateChange != nil {
		go b.opts.OnStateChange(from, to)
	}
}

// Allow решает, можно ли сейчас делать вызов.
func (b *Breaker) Allow() bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	switch b.state {
	case stateOpen:
		if time.Since(b.lastChange) >= b.opts.OpenTimeout {
			b.setState(stateHalfOpen)
			// падаем дальше в half-open логику
		} else {
			return false
		}
	}
	if b.state == stateHalfOpen {
		if b.halfOpenInFlight < b.opts.ProbeRequests {
			b.halfOpenInFlight++
			return true
		}
		return false
	}
	return true // closed
}

func (b *Breaker) OnSuccess() {
	b.rw.addSuccess()
	b.mu.Lock()
	defer b.mu.Unlock()
	if b.state == stateClosed {
		// проверим, не пора ли закрыть (ничего не делаем — и так закрыт)
		return
	}
	if b.state == stateHalfOpen {
		b.halfOpenInFlight--
		b.halfOpenSuccess++
		if b.halfOpenSuccess >= b.opts.ProbeSuccesses {
			b.setState(stateClosed)
		}
	}
}

func (b *Breaker) OnFailure() {
	b.rw.addFailure()
	b.mu.Lock()
	defer b.mu.Unlock()
	if b.state == stateHalfOpen {
		b.halfOpenInFlight--
		// любая неуспешная проба — обратно в open
		b.setState(stateOpen)
		return
	}
	// closed — проверим пороги
	succ, fail := b.rw.counts()
	total := succ + fail
	if total < b.opts.MinimumRequests {
		return
	}
	frac := 0.0
	if total > 0 {
		frac = float64(fail) / float64(total)
	}
	if frac >= b.opts.FailureThreshold {
		b.setState(stateOpen)
	}
}

// Транспорт с предохранителем и ограничением параллелизма

type CBTransport struct {
	Base      http.RoundTripper
	Breaker   *Breaker
	Semaphore chan struct{} // ограничение одновременных запросов
}

func (t *CBTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	base := t.Base
	if base == nil {
		base = http.DefaultTransport
	}
	if t.Breaker != nil {
		if !t.Breaker.Allow() {
			// быстрый отказ — «предохранитель открыт»
			return &http.Response{
				StatusCode: http.StatusServiceUnavailable,
				Status:     http.StatusText(http.StatusServiceUnavailable),
				Body:       io.NopCloser(strings.NewReader("circuit open")),
				Request:    req,
				Header:     make(http.Header),
			}, nil
		}
	}
	// Ограничение параллелизма
	if t.Semaphore != nil {
		select {
		case t.Semaphore <- struct{}{}:
			defer func() { <-t.Semaphore }()
		default:
			// сброс нагрузки — слишком много одновременных
			return &http.Response{
				StatusCode: http.StatusTooManyRequests,
				Status:     http.StatusText(http.StatusTooManyRequests),
				Body:       io.NopCloser(strings.NewReader("load shedding")),
				Request:    req,
				Header:     make(http.Header),
			}, nil
		}
	}

	resp, err := base.RoundTrip(req)
	if err != nil {
		if t.Breaker != nil {
			t.Breaker.OnFailure()
		}
		return nil, err
	}
	// Считаем неуспехами сетевые/серверные ошибки и 429/408
	status := resp.StatusCode
	failed := status >= 500 || status == http.StatusTooManyRequests || status == http.StatusRequestTimeout
	if t.Breaker != nil {
		if failed {
			t.Breaker.OnFailure()
		} else {
			t.Breaker.OnSuccess()
		}
	}
	return resp, nil
}

func main() {
	br := NewBreaker(BreakerOptions{
		Window:           10 * time.Second,
		Buckets:          10,
		FailureThreshold: 0.6,
		MinimumRequests:  20,
		OpenTimeout:      20 * time.Second,
		ProbeRequests:    2,
		ProbeSuccesses:   2,
		OnStateChange: func(from, to breakerState) {
			log.Printf("breaker state: %v -> %v", from, to)
		},
	})

	client := &http.Client{
		Timeout: 5 * time.Second,
		Transport: &CBTransport{
			Base:      http.DefaultTransport,
			Breaker:   br,
			Semaphore: make(chan struct{}, 50), // максимум 50 одновременных вызовов
		},
	}

	// Демонстрация: часть запросов — успешные, часть — ошибки
	urls := []string{
		"https://httpbin.org/status/200",
		"https://httpbin.org/status/500",
		"https://httpbin.org/delay/2", // иногда упрется в таймаут клиента
	}

	for i := 0; i < 200; i++ {
		u := urls[i%len(urls)]
		resp, err := client.Get(u)
		if err != nil {
			log.Printf("req %d %s error: %v", i, u, err)
			continue
		}
		io.Copy(io.Discard, resp.Body)
		resp.Body.Close()
		log.Printf("req %d %s -> %d", i, u, resp.StatusCode)
		time.Sleep(100 * time.Millisecond)
	}
}

Как это работает:

  • CBTransport ограничивает количество одновременных запросов к зависимости и мгновенно отказывает новым, когда лимит исчерпан.
  • Предохранитель считает ошибки за скользящее окно и открывается при превышении порога.
  • Через OpenTimeout предохранитель делает ограниченное число проб и, если они успешны, закрывается.

Быстрый фолбэк

Вместо синтетического 503/429 вы можете подставлять «мягкий» ответ — из локального кэша, упрощенную версию, старые данные. Это особенно полезно для GET‑запросов, где «вчерашние данные лучше, чем ошибка». В транспорт легко добавить колбэк для фолбэка.

Отсеки (bulkheads): разделяем судьбу зависимостей

Не складывайте все вызовы в один «котел». Для каждой ключевой зависимости держите отдельный семафор и предохранитель:

  • платежи — лимит 50 одновременных;
  • каталог — лимит 100;
  • аналитика — лимит 10 (низкий приоритет).

Так деградация аналитики не заблокирует платежи. В HTTP‑шлюзе эту идею дополняют лимиты per‑route/per‑upstream.

Пример на уровне NGINX (дополнительный внешний контур):

# Ограничение скорости запросов и одновременных соединений
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_conn_zone $server_name zone=perhost:10m;

server {
    listen 443 ssl;

    location /payments/ {
        limit_req zone=perip burst=20 nodelay;
        limit_conn perhost 50;
        proxy_pass http://payments_upstream;
    }
}

Наблюдаемость и опыт ввода в прод

Что мониторить:

  • состояние предохранителя по зависимостям (closed/open/half_open);
  • долю ошибок в окне и количество запросов в нем;
  • отказы по ограничению параллелизма (load shedding);
  • текущие «в полете» запросы;
  • P95/P99 и распределение латентности.

Как выкатывать безопасно:

  • сухой режим: предохранитель считает и логирует, но не блокирует;
  • фича‑флаг на включение «жесткого» режима перезамыкания;
  • канареечная группа серверов с включенным механизмом;
  • нагрузочные тесты с искусственно замедленным стендом (например, tc/netem или прокси‑задержки) и fault‑инъекцией (ответы 500/429).

Частые ошибки и как их избежать

  • Слишком большая очередь ожидания. Очереди — главный враг предсказуемого SLA. Лучше быстрый отказ.
  • Слишком «нервный» предохранитель: маленькое окно и низкий минимум запросов → ложные срабатывания. Увеличьте окно и MinimumRequests.
  • Смешивание разных типов ошибок. Выделяйте сетевые/серверные (5xx, таймауты, 429) отдельно от бизнес‑ошибок (валидация, 4xx).
  • Одинаковые лимиты для всех. Делите по зависимостям и маршрутам.
  • Отказ без фолбэка там, где он возможен. Для чтений держите локальный кэш или упрощенную ветку ответа.

Чек‑лист внедрения

  • Выделили критичные зависимости и оценили их устойчивую пропускную способность.
  • Настроили предохранитель: окно, минимум запросов, порог ошибок, таймаут «open», число проб.
  • Ограничили параллелизм на каждую зависимость, очередь — нулевая или очень маленькая.
  • Добавили метрики и алерты на: состояние предохранителя, load shedding, долю ошибок, P95/P99.
  • Ввели сухой режим и канареечный rollout.
  • Проверили на стейдже с искусственными задержками и ошибками.
  • Подготовили фолбэки для безопасных GET‑операций.

Итог: предохранитель + ограничение параллелизма + контролируемый сброс нагрузки — простая и мощная защита от каскадных отказов. Вы стабилизируете SLA, сокращаете аварии и экономите на инфраструктуре, вместо того чтобы латать последствия шторма запросов.


circuit breakerнадёжностьограничение параллелизма