Содержание урока · 13 разделов
- Зачем лимитер: fail fast вместо лавины
- Token bucket: модель с двумя ручками
- NewErroringLimiter: контракт ErrLimited
- NewDelayingLimiter: очередь вместо отказа
- Куда встать в цепочке обёрток
- Шесть стратегий лимитирования
- Per-endpoint, per-IP и честные числа
- Наблюдаемость лимитера: покажи, что сработал
- Пять правил rate limiting
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Зачем лимитер: 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
GOimport (
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 из коробки
GOimport (
"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: конверт ошибки в кодеке
GOtype 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 решает одну задачу — ограничить темп, и его не стоит нагружать чужими: он не отличает атаку от живого клиента, не заменяет авторизацию и не спасает от одного тяжёлого запроса, который один отклонённый темп и превысил. Per-IP лимиты врут за NAT и подделываются через X-Forwarded-For. Настоящая граница доверия ставится аутентификацией и валидацией входа, а лимитер — грубый, честный и очень дешёвый предохранитель поверх всего этого.
Урок требует установленного Go и модулей github.com/go-kit/kit, github.com/juju/ratelimit и golang.org/x/time/rate — в песочнице курса сторонние модули не исполняются, поэтому интерактивы построены на чтении кода. Локально: собери stringsvc с лимитером и кодеком 429, дёрни /uppercase двадцать раз подряд — увидишь, как после двадцати первых ответов начнут приходить 429 с Retry-After, а затем прогони ab или hey на минуту и посмотри на счётчик отказов.
Пять правил rate limiting#
- Лимит на endpoint'е: rate.NewLimiter или адаптер Allower над бакетом juju/ratelimit, сборка в main рядом с остальными middleware.
- Публичный API — NewErroringLimiter: мгновенный отказ вместо очереди из горутин; delaying — для фоновых внутренних потоков.
- ErrLimited проверяется errors.Is, а в HTTP превращается типом-носителем: 429 Too Many Requests с Retry-After через Headerer.
- Числа из метрик: rate — по среднему темпу с запасом, capacity — по допустимому всплеску; gauge Available() покажет запас токенов.
- Отказ — событие: счётчик 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.
Путь отклонённого запроса
Расставь путь лимитированного запроса по порядку: от прихода на порт до Retry-After у клиента.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Термины урока
Что дальше#
Лимитер встал в строй: 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 исчезли, а латентность выросла.
Личные заметкиПусто
Вопросы и ответы по уроку
С чего начать подбор 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 — отдельные обёртки с картой бакетов и чисткой; они нужны, когда трафик неоднороден по клиентам.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
gRPC-Go: высокопроизводительный RPC
Курс gRPC-Go: proto3, кодогенерация, unary и streaming вызовы, interceptors, TLS, дедлайны, load balancing. 25 уроков и итоговый проект.
- Писать .proto-схемы и генерировать код
- Реализовывать unary и streaming сервисы
- Управлять метаданными, дедлайнами и отменами
- Строить interceptors для логов и аутентификации