Каждый бэкенд-инженер рано или поздно сталкивается с задачей защиты своего сервиса от перегрузки. Входящий трафик растет, внешние интеграции начинают сбоить, а база данных стонет под шквалом запросов. Первое, что приходит в голову разработчику на Go — взять проверенный инструмент из стандартного квази-официального пакета golang.org/x/time/rate и повесить лимитер на входящий HTTP-эндпоинт.
И вот здесь кроется одна из самых распространенных и коварных архитектурных иллюзий. Инженер пишет limiter := rate.NewLimiter(100, 10), предполагая: «Теперь у меня одновременно работает не больше 10 запросов, а база защищена от шторма». А через неделю в продакшене случается инцидент: пул соединений PostgreSQL исчерпан, задержки p99 улетают в космос, а сервис перестает отвечать.
Давай заглянем внутрь реализации rate.Limiter и разберем эту механику по винтикам.
Математика токен-бакета: частота против параллелизма
В основе пакета golang.org/x/time/rate лежит классический алгоритм Token Bucket (корзина токенов). Механика предельно проста:
- Корзина имеет максимальную емкость $b$ токенов — это параметр
burst. - В корзину с постоянной скоростью $r$ капают новые токены — это параметр
limit(токены в секунду). - Приходящий запрос обязан извлечь токен из корзины. Если токен есть — запрос выполняется немедленно. Если корзина пуста — запрос либо сразу отклоняется (метод
Allow()), либо блокируется и засыпает в ожидании пополнения (методWait(ctx)).
Почувствуй принципиальную разницу: Токен-бакет контролирует частоту событий во времени (rate limit), но он абсолютно ничего не знает о количестве одновременно выполняющихся задач (concurrency limit)!
Представь ситуацию: корзина полна, в ней накопилось 10 токенов (burst = 10). На вход сервиса мгновенно прилетают 10 параллельных запросов. Лимитер добросовестно выдает всем 10 запросам зеленый свет. Все 10 горутин одновременно ломятся в базу данных. А теперь самое главное: если каждый из этих запросов выполняет тяжелый SELECT и работает 5 секунд, то в течение этих пяти секунд твоя база испытывает параллельную нагрузку от всех 10 сессий! А если лимитер пополняется со скоростью 100 запросов в секунду, то уже через секунду в базу прилетит следующая пачка.
Когда нужен rate.Limiter, а когда — семафор
Чтобы защитить нисходящие сервисы (базы данных, внешние API с жестким лимитом одновременных сокетов), одного только rate.Limiter недостаточно. Тебе нужна связка двух независимых механизмов:
rate.Limiter: отсекает всплески частоты вызовов в единицу времени.- Взвешенный семафор (
semaphore.Weightedиз пакетаgolang.org/x/sync/semaphore): строго ограничивает число параллельно работающих воркеров.
Давай посмотрим на правильный инженерный паттерн совместного использования этих инструментов:
package main
import (
"context"
"fmt"
"net/http"
"time"
"golang.org/x/sync/semaphore"
"golang.org/x/time/rate"
)
type ProtectedService struct {
rateLimiter *rate.Limiter
concurrency *semaphore.Weighted
}
func NewProtectedService(rps float64, burst int, maxConcurrent int64) *ProtectedService {
return &ProtectedService{
rateLimiter: rate.NewLimiter(rate.Limit(rps), burst),
concurrency: semaphore.NewWeighted(maxConcurrent),
}
}
func (s *ProtectedService) HandleTask(ctx context.Context, taskID int) error {
// 1. Проверяем частотный лимит: ждем появления токена во времени
if err := s.rateLimiter.Wait(ctx); err != nil {
return fmt.Errorf("превышен таймаут ожидания токена: %w", err)
}
// 2. Ограничиваем параллелизм: захватываем слот в семафоре
if !s.concurrency.TryAcquire(1) {
return fmt.Errorf("все слоты параллельной обработки заняты")
}
defer s.concurrency.Release(1)
// Имитация полезной нагрузки (обращение к СУБД)
time.Sleep(200 * time.Millisecond)
return nil
}
Обрати внимание на метод TryAcquire(1): если все параллельные слоты уже заняты долгоживущими операциями, сервис мгновенно возвращает ошибку клиенту (HTTP 429 или 503), не создавая очередь зависших горутин в памяти. Если вспомнить, как устроена конкурентность Go, то управление жизненным циклом горутин через контекст и семафоры является базовым фундаментом надежности.
Тонкости методов Allow, Wait и Reserve
В пакете rate есть три основных метода взаимодействия с корзиной, и у каждого — своя зона ответственности:
// 1. Метод Allow(): неблокирующая проверка (для API шлюзов)
if !limiter.Allow() {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
// 2. Метод Wait(ctx): блокирующее ожидание с поддержкой отмены по контексту
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := limiter.Wait(ctx); err != nil {
// Контекст отменен или истек таймаут до появления свободного токена
return err
}
// 3. Метод Reserve(): резервирование токена с получением задержки (Reservation)
r := limiter.Reserve()
if !r.OK() {
return fmt.Errorf("запрос превышает максимальный burst")
}
time.Sleep(r.Delay()) // Засыпаем на точное время до готовности токена
Смотри, в чем нюанс: если ты вызываешь Wait(ctx) с запросом большего числа токенов, чем размер burst (limiter.WaitN(ctx, n) где n > burst), метод сразу вернет ошибку, потому что корзина физически не может вместить больше токенов, чем ее емкость.
Параметр burst — это твой амортизатор сетевых всплесков, а не предохранитель от параллельной нагрузки. Разделяй эти понятия в коде, и твоя система никогда не упадет от неожиданного шторма запросов.
