Дайджесты новостей
Инфографическая схема алгоритма token bucket в Go с переполненным резервуаром burst и шлюзом параллелизма.

Как устроен rate.NewLimiter в Go: почему параметр burst не ограничивает параллелизм

Каждый бэкенд-инженер рано или поздно сталкивается с задачей защиты своего сервиса от перегрузки. Входящий трафик растет, внешние интеграции начинают сбоить, а база данных стонет под шквалом запросов. Первое, что приходит в голову разработчику на Go — взять проверенный инструмент из стандартного квази-официального пакета golang.org/x/time/rate и повесить лимитер на входящий HTTP-эндпоинт.

И вот здесь кроется одна из самых распространенных и коварных архитектурных иллюзий. Инженер пишет limiter := rate.NewLimiter(100, 10), предполагая: «Теперь у меня одновременно работает не больше 10 запросов, а база защищена от шторма». А через неделю в продакшене случается инцидент: пул соединений PostgreSQL исчерпан, задержки p99 улетают в космос, а сервис перестает отвечать.

Давай заглянем внутрь реализации rate.Limiter и разберем эту механику по винтикам.

Математика токен-бакета: частота против параллелизма

В основе пакета golang.org/x/time/rate лежит классический алгоритм Token Bucket (корзина токенов). Механика предельно проста:

  1. Корзина имеет максимальную емкость $b$ токенов — это параметр burst.
  2. В корзину с постоянной скоростью $r$ капают новые токены — это параметр limit (токены в секунду).
  3. Приходящий запрос обязан извлечь токен из корзины. Если токен есть — запрос выполняется немедленно. Если корзина пуста — запрос либо сразу отклоняется (метод 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 — это твой амортизатор сетевых всплесков, а не предохранитель от параллельной нагрузки. Разделяй эти понятия в коде, и твоя система никогда не упадет от неожиданного шторма запросов.