Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 9 из 25 ~18 мин +50 XP

Rate limiting

Три урока наблюдаемости показали, что происходит в stringsvc, — теперь научимся это ограничивать. Под нагрузкой или при дурацком клиенте сервис честнее отклонить лишнее, чем утонуть: классический ход — token bucket на endpoint'е, а готовый лимитер go kit rate limit даёт пакет ratelimit в паре с juju/ratelimit. Ты соберёшь бакет с двумя ручками — скоростью и ёмкостью, обернёшь endpoint в NewErroringLimiter или NewDelayingLimiter, проведёшь ErrLimited через кодеки из урока 05 и вернёшь клиенту 429 Too Many Requests с Retry-After. И разберёшься, чем отказ от запроса отличается от постановки в очередь.

Содержание урока · 13 разделов

Зачем лимитер: fail fast вместо лавины#

Сервис без ограничения входа подписывает опасный контракт: сколько пришло запросов, столько и обслужим. При всплеске трафика горутины копятся, очереди растут, латентность ползёт вверх, дедлайны из урока 04 срабатывают у всех, клиентские ретраи умножают поток — классическая лавина, из которой сервис без admission control не выходит сам. Идея лимитера проста и древняя, как телефонный коммутатор: измерить допустимый темп заранее и отклонить всё сверх него мгновенно и дёшево. Отклонённый запрос не расходует ни CPU, ни память, ни соединения с базой — его цена измеряется микросекундами и одной проверкой числа. Клиенту отказ тоже полезнее ожидания: честный 429 Too Many Requests с заголовком Retry-After говорит «притормози и вернись через секунду» вместо тишины и таймаута. Именно поэтому fail fast — основной принцип этого урока: лучше быстрый явный отказ, чем медленная деградация всех запросов сразу. Заметь, где ставится контроль: не в сервисе — домен не обязан знать о трафике, — и не в декодере, а на endpoint'е, тем самым механизмом endpoint.Middleware из урока 02, которым мы уже собирали логи, метрики и спаны. Обёртка go kit rate limit — четвёртая того же сорта, и это не совпадение: в go-kit вся механика вокруг запроса живёт в middleware.

Token bucket: модель с двумя ручками#

Алгоритм token bucket — самая ходовая модель лимитирования, потому что описывается одной метафорой. Есть бакет ёмкостью capacity: в него с постоянной скоростью капают токены — раз в fillInterval или rate токенов в секунду; каждый запрос перед проходом берёт один токен; бакет полон — запросы идут свободно, бакет пуст — запросы отклоняются. Две ручки дают точную настройку: rate задаёт среднюю скорость, которую сервис готов переваривать, capacity — допустимый всплеск: накопившийся запас токенов позволяет пропустить burst из capacity запросов подряд, а потом темп упрётся в rate. Именно эта способность «сглаживать всплески, но разрешать их» отличает token bucket от тупого счётчика «не больше N в секунду»: API, который ходит циклами, не будет резаться на ровном месте. В Go эталонная реализация — github.com/juju/ratelimit: ratelimit.NewBucket(fillInterval, capacity) капает по таймеру, ratelimit.NewBucketWithRate(rate, capacity) принимает токенов в секунду float64 и сама вычисляет внутренний квант; методы Take и WaitMaxDuration умеют брать токены неблокирующе или с потолком ожидания, Available() показывает запас, Capacity() и Rate() возвращают настройки. Бакет потокобезопасен: все методы можно звать из любых горутин — для веб-сервиса это обязательное требование, запросы приходят параллельно. Подберёшь числа ниже, а пока — как этот бакет становится middleware.

Бакет juju/ratelimit и адаптер Allower

GO
import (
        kitratelimit "github.com/go-kit/kit/ratelimit"
        "github.com/juju/ratelimit"
)

bucket := ratelimit.NewBucketWithRate(10, 50) // 10 токенов/с, всплеск до 50

allow := kitratelimit.AllowerFunc(func() bool {
        // взять токен, только если он есть прямо сейчас; без ожидания
        return bucket.WaitMaxDuration(1, 0)
})

upper = kitratelimit.NewErroringLimiter(allow)(upper)

Пакет go-kit/kit/ratelimit не связан жёстко ни с одной библиотекой: NewErroringLimiter принимает интерфейс Allower { Allow() bool } — «можно ли пройти». juju/ratelimit.Bucket метода Allow не имеет, но адаптер AllowerFunc закрывает разрыв: bucket.WaitMaxDuration(1, 0) берёт один токен и возвращает true, только если он доступен немедленно; иначе токены не снимаются и вернётся false. Именно такой неблокирующей проверкой и должен жить лимитер на входе HTTP-сервиса.

NewErroringLimiter: контракт ErrLimited#

Посмотрим, что делает middleware, когда Allower сказал нет. НовЕрный вариант — вернуть клиенту что-то осмысленное — требует, чтобы отказ был типизированной ошибкой, а не строкой. Go-kit делает ровно это: пакет объявляет var ErrLimited = errors.New("rate limit exceeded") — единственный экземпляр на весь процесс, — и NewErroringLimiter возвращает его в канал err: return nil, ErrLimited. Endpoint при этом не вызывается вовсе: сервис, база, декодеры — всё остаётся нетронутым, отказ стоил одной проверки. У единого ErrLimited два следствия. Первое — проверка на принимающей стороне: в encodeError из урока 05 отказ узнаётся через errors.Is(err, kitratelimit.ErrLimited) — errors.Is устойчив к обёрткам fmt.Errorf с %w, которые обязательно появятся, когда ошибки начнут обрастать контекстом. Второе — превращение в HTTP-ответ: сырой ErrLimited с DefaultErrorEncoder из урока 04 уехал бы в 500 text/plain — формально всё работает, но клиент не отличит лимит от бага. Честный ответ — 429 Too Many Requests с Retry-After, и для этого нужен тип-носитель из арсенала уроков 04-05: ошибка, реализующая StatusCode() и Header(), через которую кодек отдаст и статус, и заголовок. Ниже соберём такой конверт, а пока зафиксируй принцип: middleware возвращает каноническую ошибку, транспортное преобразование в статус — работа кодеков, как и для ErrEmpty.

x/time/rate: Allower и Waiter из коробки

GO
import (
        "golang.org/x/time/rate"
        kitratelimit "github.com/go-kit/kit/ratelimit"
)

limiter := rate.NewLimiter(rate.Limit(10), 50) // 10 событий/с, burst 50

upper = kitratelimit.NewErroringLimiter(limiter)(upper)  // Allow() уже есть
worker = kitratelimit.NewDelayingLimiter(limiter)(worker) // Wait(ctx) уже есть

golang.org/x/time/rate — вторая эталонная реализация token bucket, и она спроектирована под эти интерфейсы: type Allower interface { Allow() bool } и type Waiter interface { Wait(ctx context.Context) error } *rate.Limiter реализует сам, без адаптеров. rate.Limit(10) — токенов в секунду, 50 — burst. Одна и та же настройка обслуживает и отклоняющий, и откладывающий лимитер: выбор поведения — это выбор middleware, а не замена библиотеки.

NewDelayingLimiter: очередь вместо отказа#

NewErroringLimiter — не единственный ответ на пустой бакет, и противопоставление двух middleware пакета — важнейший выбор этого урока. NewDelayingLimiter принимает Waiter { Wait(ctx context.Context) error } и вместо отказа ждёт: limiter.Wait(ctx) блокирует горутину, пока не накапает токен, и лишь потом пропускает запрос к endpoint'у. Это уже не admission control, а throttling — сглаживание: запросы не отклоняются, а выстраиваются в невидимую очередь с темпом rate. Когда что выбирать. Для публичного HTTP API — erroring, почти всегда: горутины ждущих клиентов — это память и соединения, всплеск трафика превращается в всплеск горутин, и лимитер от одной беды защищает, создавая другую; клиенту честнее Retry-After, чем подвисший ответ. Для внутренних фоновых потоков — delaying — в самый раз: воркер, который перекачивает задачи, спокойно подождёт токен, ему некуда спешить и обидно падать; загруженная очередь — штатный режим, а не инцидент. Есть и промежуточные сценарии: WaitForN с потолком ожидания — подожди, но не дольше X, потом откажи. Обрати внимание на ctx в Wait: дедлайн из запроса уважается, и ожидание прервётся ошибкой context.DeadlineExceeded, когда клиент уже уехал. И держи в голове предельный случай delaying: burst приходящего трафика больше capacity — очередь растёт неограниченно, латентность p90 из метрик урока 07 улетает в космос, хотя ни один запрос не получил отказ. Отсутствие 429 — ещё не здоровье.

429 с Retry-After: конверт ошибки в кодеке

GO
type rateLimitError struct{}

func (rateLimitError) Error() string   { return "rate limit exceeded" }
func (rateLimitError) StatusCode() int { return http.StatusTooManyRequests }
func (rateLimitError) Header() http.Header {
        return http.Header{"Retry-After": []string{"1"}}
}

// в encodeError из урока 05, рядом с codeFrom:
var rl *rateLimitError
if errors.As(err, &rl) {
        w.Header().Set("Retry-After", "1")
        w.WriteHeader(http.StatusTooManyRequests)
        json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
        return
}

Тот же приём, что badRequestError из урока 05: тип-носитель, через который ошибка сообщает кодеку свой статус. StatusCode() даёт 429, Header() — интерфейс Headerer, который DefaultErrorEncoder и наш encodeError умеют читать: Retry-After подсказывает клиенту, через сколько вернуться. Ошибка находится через errors.As — работает и для обёрток. Теперь цепочка замкнулась: NewErroringLimiter вернул ErrLimited, endpoint-адаптер перевёл его в rateLimitError, кодек отдал честный 429.

Куда встать в цепочке обёрток#

У тебя уже четыре endpoint-middleware, и порядок их сборки в main перестал быть мелочью. Правило простое: чем дешевле отказ, тем раньше обёртка. Лог из урока 06 полезен для всех исходов, включая отказы, — он снаружи. Дальше лимитер: отклонённый запрос не должен тратить ничего, кроме строки в логе. Метрики из урока 07 и спаны из урока 08 — вопрос вкуса с важным нюансом, который станет predict-задачей ниже: если instrumentingMiddleware стоит внутри лимитера, отклонённые запросы не попадают в request_count и график нагрузки рисует только пропущенное — согласованное решение стоит задокументировать в коде, например отдельным счётчиком rate_limited_total. Трейсер из урока 08 внутрь лимитера ставить стоит: отказ 429 — тоже событие, которое хочется видеть в Jaeger, и спан на него обойдётся дешевле, чем на сервис. Итоговый порядок: loggingMiddleware снаружи, затем лимитер, затем трейсер, затем instrumentingMiddleware и endpoint. Отдельно про транспорт: ServerBefore и декодер отрабатывают до endpoint-обёрток, поэтому лимитированный запрос всё равно потратит чуть-чуть — на чтение заголовков; если этого мало, переноси лимит в ServerBefore как RequestFunc, но тогда он перестанет быть endpoint-логикой и потеряет транспортную независимость. Для stringsvc честная цена в декодере — копейки, endpoint-обёртка правильнее.

Шесть стратегий лимитирования#

СтратегияЧто ограничиваетОтвет при превышенииКогда уместна
Token bucket на endpointтемп одного метода429 + Retry-Afterканон go-kit: у каждого endpoint свой бакет в main
Общий бакет на сервиссуммарный темп429 + Retry-Afterпростая защита всех методов одной квотой
NewDelayingLimiterтемп через очередьответ ждёт токенфоновые воркеры, внутренние вызовы
Per-IP карта бакетовтемп каждого клиента429 + Retry-Afterпубличные API; нужна своя обёртка с картой и чисткой
Per-API-keyквоты тарифных планов429 + Retry-Afterплатные API; бакеты живут по ключу клиента
Лимит на балансировщикевходящий поток целиком429 на edgeзащита до сервиса; настраивает не Go-код

Per-endpoint, per-IP и честные числа#

Бакет в примере выше один и обслуживает endpoint целиком — это канон go-kit и правильная точка старта: у каждого endpoint свои ресурсы и свой допустимый темп. Но числа не берутся с потолка, и вот честная методика. Средний rate бери из метрик урока 07: если p90 латентности приемлема при 8 запросах в секунду, ставь 10 с запасом; capacity считай как допустимый всплеск — сколько запросов подряд сервис переварит без деградации: для быстрых in-memory методов это десятки, для походов в базу — единицы. Пер-клиентские лимиты — отдельная дисциплина: juju/ratelimit не знает про IP и ключи, поэтому per-IP лимитер — это твоя обёртка над endpoint'ом: карта «ключ клиента → свой бакет», mutex вокруг неё, чистка давних записей по таймеру, иначе карта растёт вечно, — всё как в любом лимитере на HTTP-фреймворке. Ключ честно брать из заголовков с осторожностью: X-Forwarded-For подделывается, а NAT складывает сотни людей в один адрес — per-IP на публичном API всегда компромисс, реальный идентификатор клиента — аутентификация, о которой пойдёт речь в уроке про безопасность. И ещё одна честная деталь: бакет с rate 10 пропускает ровно 10 токенов в секунду в среднем, но не гарантирует равномерность — заполнение квантуется, и burst может прийти в одну миллисекунду; если нужна строгая равномерность, это уже задача leaky bucket или планировщика, а не token bucket.

Наблюдаемость лимитера: покажи, что сработал#

Лимитер, который молча режет трафик, хуже отсутствие лимитера: деградация клиента будет выглядеть загадкой. Поэтому отказ — это событие, и его нужно сделать видимым во всех трёх слоях, которые ты уже построил. В логах из урока 06: оберни лимитер в endpoint-middleware, которая при errors.Is(err, kitratelimit.ErrLimited) пишет logger.Log("msg", "rate limited", "method", ...) — или доверь это loggingMiddleware, если он снаружи и видит err: там же появится и took, показывающий, что отказ стоил микросекунды. В метриках из урока 07: объяви kitprometheus.NewCounterFrom с метками method и ограниченным набором исходов — limited/allowed — и инкрементируй его в той же обёртке; алерт на rate этого счётчика расскажет об атаке или кривом клиенте раньше, чем расскажет недовольный заказчик. Полезен и gauge Available() бакета: запас токенов, который изнутри сервиса показывает, насколько близко ты к отказам, — Set каждый период и дашборд готов. В трейсе из урока 08: спан лимитированного запроса обязан существовать — поэтому трейсер и стоит внутри лимитера; событие в спане с текстом ошибки появится само, если обёртка вернёт err в канал. Такая связка даёт полный цикл: метрика позвала — трейс показал, где — лог объяснил, кто. Без неё ты узнаешь о работе лимитера только из писем пользователей.

Пять правил rate limiting#

  1. Лимит на endpoint'е: rate.NewLimiter или адаптер Allower над бакетом juju/ratelimit, сборка в main рядом с остальными middleware.
  2. Публичный API — NewErroringLimiter: мгновенный отказ вместо очереди из горутин; delaying — для фоновых внутренних потоков.
  3. ErrLimited проверяется errors.Is, а в HTTP превращается типом-носителем: 429 Too Many Requests с Retry-After через Headerer.
  4. Числа из метрик: rate — по среднему темпу с запасом, capacity — по допустимому всплеску; gauge Available() покажет запас токенов.
  5. Отказ — событие: счётчик rate_limited_total, строка в логе, спан в трейсе; молчащий лимитер хуже отсутствующего.

Бакет пуст, NewErroringLimiter получил false от Allower. Что вернёт обёрнутый endpoint?

Почему для внешнего HTTP API выбирают NewErroringLimiter, а не NewDelayingLimiter?

encodeError должен узнать лимит-отказ. Какая проверка правильная?

Токен не взят: что с Available()?

Прочитайте код и выберите, что он выведет:

bucket := ratelimit.NewBucketWithRate(10, 50)
bucket.Wait(50) // вычерпали всё

// через 40 мс (токены ещё не натекли):
allow := kitratelimit.AllowerFunc(func() bool {
        return bucket.WaitMaxDuration(1, 0)
})
ok := allow.Allow() // false

// вопрос: Available() до и после Allow()?

Порядок обёрток: растёт ли request_count при 429?

Прочитайте код и выберите, что он выведет:

// порядок A:
upper = loggingMiddleware(logger)(upper)
upper = kitratelimit.NewErroringLimiter(allow)(upper)
upper = instrumentingMiddleware(requestCount, requestLatency)(upper)

// порядок B:
upper = loggingMiddleware(logger)(upper)
upper = instrumentingMiddleware(requestCount, requestLatency)(upper)
upper = kitratelimit.NewErroringLimiter(allow)(upper)

// бакет пуст, клиент шлёт запрос

Собери лимитер и его кодек

Восстанови сборку. Пропуски — по порядку: конструктор бакета juju/ratelimit, адаптер функции под интерфейс go-kit, erroring-middleware, каноническая ошибка пакета и статус-код для константы http.

bucket := ratelimit.(10, 50) // 10 токенов/с, burst 50 allow := kitratelimit.(func() bool { return bucket.WaitMaxDuration(1, 0) }) upper = kitratelimit.(allow)(upper) // в encodeError: if errors.Is(err, kitratelimit.) { w.WriteHeader(http.) }

Путь отклонённого запроса

Расставь путь лимитированного запроса по порядку: от прихода на порт до Retry-After у клиента.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Термины урока

Термин

juju/ratelimit.Bucket

нажмите, чтобы перевернуть
Определение

Token bucket: NewBucketWithRate(rate, capacity); WaitMaxDuration(1, 0) — неблокирующая попытка взять токен; Available/Capacity/Rate; потокобезопасен.

1 / 6

Что дальше#

Лимитер встал в строй: token bucket с двумя ручками, неблокирующая проверка на endpoint'е, каноническая ErrLimited и честный 429 с Retry-After в кодеке из урока 05. Оборачивания из уроков 02-endpoint и 04-http-transport снова показали универсальность: ни сервис, ни транспорт не узнали о появлении лимита. Освежить кодеки ошибок — в уроке 05-codeki, счётчики и гейджи — в уроке 07-metriki, карту курса — на странице курса. Но лимитер защищает сервис от клиентов, а кто защитит клиентов от зависимостей? Если соседний сервис лёг, твой прокси будет честно ждать таймауты на каждом вызове и копить горутины. Дальше — урок 10-circuit-breaker: предохранитель на endpoint'е, состояния closed/open/half-open и мгновенный отказ, пока зависимость чинят.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Token bucket: rate — средний темп, capacity — допустимый всплеск; juju/ratelimit.NewBucketWithRate(10, 50) — потокобезопасный бакет.
  • go-kit ratelimit принимает интерфейсы: NewErroringLimiter(Allower) отклоняет с ErrLimited, NewDelayingLimiter(Waiter) ждёт токен.
  • juju-бакет не умеет Allow — адаптер AllowerFunc с bucket.WaitMaxDuration(1, 0) даёт неблокирующую проверку; x/time/rate реализует оба интерфейса сам.
  • ErrLimited проверяется errors.Is; HTTP-лицо отказа — тип-носитель из урока 05: StatusCode() 429 и Header() с Retry-After.
  • Для внешнего API — erroring (fail fast), для фоновых потоков — delaying (очередь); burst больше capacity в delaying = бесконечный рост латентности.
  • Порядок обёрток: лог снаружи, лимитер раньше тяжёлых middleware; отклонённые запросы в request_count не попадают — покажи их отдельным счётчиком.
  • Числа — из метрик: rate по среднему темпу, capacity по всплеску; gauge Available() и алерт на rate_limited_total закрывают наблюдаемость.

Домашнее задание

Добавь в stringsvc полный контур лимитирования. Первое: собери два бакета в main — для /uppercase 10 токенов в секунду с ёмкостью 50, для /count 2 в секунду с ёмкостью 10 — через juju/ratelimit.NewBucketWithRate и адаптер AllowerFunc с WaitMaxDuration(1, 0). Второе: оберни каждый endpoint в kitratelimit.NewErroringLimiter, поставь обёртку после loggingMiddleware и до instrumentingMiddleware; прокомментируй в main, почему именно так, с оглядкой на predict про порядок. Третье: сделай rateLimitError с StatusCode() 429 и Header() Retry-After: 1, подключи его в encodeError рядом с badRequestError из урока 05, проверь errors.Is(err, kitratelimit.ErrLimited) ветку. Четвёртое: объяви счётчик rate_limited_total через kitprometheus.NewCounterFrom с меткой method и инкрементируй его в обёртке при отказе; смонтируй gauge Available() и собери запрос Prometheus для доли отказов. Пятое: прогони hey на минуту и опиши, как соотносятся request_count и rate_limited_total; затем замени лимитер /count на NewDelayingLimiter с rate.NewLimiter и сравни поведение по p90 из гистограммы урока 07 — объясни, почему 429 исчезли, а латентность выросла.

Личные заметкиПусто
Хранятся только в вашем браузере (go-kit · Rate limiting)

Вопросы и ответы по уроку

С чего начать подбор rate и capacity?

Rate — из среднего трафика с запасом: посмотри rate(request_count[5m]) из урока 07 на пике и добавь двадцать-тридцать процентов. Capacity — это допустимый burst: сколько запросов подряд endpoint переварит без деградации латентности; быстрым методам хватит десятков, походам в базу — единиц. После запуска корректируй по gauge Available(): если запас нулевой в моменты пиковой нагрузки — rate занижен.

Чем x/time/rate отличается от juju/ratelimit?

Обе реализации token bucket, разница в API и экосистеме: x/time/rate — официальный supplementary-пакет, его Limiter реализует интерфейсы go-kit Allower и Waiter напрямую; juju/ratelimit даёт более тонкий набор (Take, TakeMaxDuration, TakeAvailable, часы-квант) и требует адаптера. Для типовой задачи с go-kit удобнее x/time/rate; juju хорош, когда нужны неблокирующие Take с задержкой в ответе.

Лимит на endpoint или на весь сервис?

Начни с per-endpoint: у методов разные ресурсы и стоимость, общий бакет позволит горячему endpoint съесть квоту соседей. Общий лимит имеет смысл как вторая линия — грубый предохранитель от суммарной перегрузки, когда даже легитимные потоки вместе опасны. Per-IP и per-key — отдельные обёртки с картой бакетов и чисткой; они нужны, когда трафик неоднороден по клиентам.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

gRPC25 уроков~14 чпродвинутый25 уроков уже доступны

gRPC-Go: высокопроизводительный RPC

Курс gRPC-Go: proto3, кодогенерация, unary и streaming вызовы, interceptors, TLS, дедлайны, load balancing. 25 уроков и итоговый проект.

  • Писать .proto-схемы и генерировать код
  • Реализовывать unary и streaming сервисы
  • Управлять метаданными, дедлайнами и отменами
  • Строить interceptors для логов и аутентификации