Содержание урока · 10 разделов
- Rate limiter на Redis: фиксированное окно на INCR и EXPIRE
- Стык окон: где фиксированная схема врёт
- Rate limiter на Redis: скользящее окно на ZSET
- Цена точности: память и число команд
- Три схемы лимитера и их цена
- Ограничение запросов в Go: ключ на пользователя, IP и границы
- Коды и заголовки: как вежливо отказывать
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Продукт просит интеграцию с платным API, у провайдера лимит сто запросов в минуту на ключ, а вечерний пик нашего трафика — шестьсот. Или ближе к дому: разослали пуши по базе, часть пользователей получила по три одинаковых уведомления, потому что ретраи наехали на оригинальную отправку. Обе истории — про отсутствие контроля темпа: сервис не умеет ограничивать запросы, поэтому либо платит за перерасход, либо штормит чужие системы. Rate limiter на Redis — стандартное решение в Go-бэкенде: счётчик с TTL в общем хранилище, который видят все инстансы сразу и который невозможно обойти рестартом процесса. В уроке строим две реализации — фиксированное окно на INCR и EXPIRE и скользящее окно на ZSET — и разбираем, где у каждой граница применимости.
Rate limiter на Redis: фиксированное окно на INCR и EXPIRE#
Фиксированное окно — самая простая схема, которая закрывает большинство внутренних сценариев. Каждая минута — своя корзина: ключ вида ratelimit:sms:202506011407 содержит счётчик обращений, срок жизни равен длине окна. Запрос сначала инкрементирует счётчик, потом сверяет с лимитом: Incr вернул число выше разрешённого — отказ, ниже — пропуск. Всё атомарно: INCR выполняется сервером единым шагом, поэтому параллельные горутины и инстансы не потеряют увеличения, — тот же приём из урока про счётчики и INCR, только с отказом в конце. Отказ честнее сопровождать заголовками X-RateLimit-Limit и X-RateLimit-Remaining: приличный API сообщает клиенту, сколько попыток осталось, а Retry-After подскажет, когда откроется новое окно после истечения TTL.
Лимитер фиксированного окна
GOconst limit = 100
// allow проверяет квоту на окно в одну минуту.
func allow(ctx context.Context, rdb *redis.Client, key string) (bool, error) {
k := "ratelimit:" + key
n, err := rdb.Incr(ctx, k).Result()
if err != nil {
return false, err
}
if n == 1 {
rdb.Expire(ctx, k, time.Minute)
}
return n <= limit, nil
}INCR идёт первым: ответ единица означает первый запрос окна — только тогда ставится Expire, иначе каждая просьба продлевала бы счётчик бесконечно. Пара Incr и Expire не атомарна: если процесс умрёт между командами, ключ останется без TTL и однажды заблокирует клиента навсегда — TxPipeline из урока про транзакции отправляет обе команды одним рейсом. Возврат n <= limit делает запрос номер сто разрешённым, сто первый — нет.
Стык окон: где фиксированная схема врёт#
У фиксированного окна есть известная слепая зона — граница корзин. Лимит сто в минуту не означает «не больше ста за любые шестьдесят секунд»: на стыке окна клиент может провести сто запросов в последние секунды текущей корзины и ещё сто в первые секунды следующей — двести обращений за короткий интервал при формальном соблюдении лимита. Для защиты платного API этого мало, для большинства внутренних сценариев — достаточно. Классическая ошибка — «улучшить» схему укорачиванием окна: окно в одну секунду при всплеске от ботов пропускает пачки запросов между секундами и добавляет лишнюю нагрузку на Redis. Если честность квоты принципиальна, нужен следующий приём — скользящее окно, которое считает настоящие последние шестьдесят секунд, а не абстрактную корзину.
Типичная ошибка: поставить Expire после каждого Incr «для надёжности». TTL теперь отсчитывается заново с каждого запроса, окно никогда не истекает, и упёршийся в лимит клиент остаётся заблокированным навсегда — пока с его ключа идёт трафик. Expire ставится ровно один раз на окно, по ответу Incr равному единице; в пайплайне — в той же посылке.
Rate limiter на Redis: скользящее окно на ZSET#
Скользящее окно считает запросы честно: не «сколько инкрементов в текущей минуте», а «сколько отметок времени в последних шестидесяти секундах». Хранителем отметок выступает ZSET из урока про отсортированные множества: член — уникальный идентификатор запроса, score — время в миллисекундах. Перед подсчётом старые отметки выметаются ZRemRangeByScore с границей от нуля до now минус шестьдесят тысяч, затем ZCard отвечает числом живых отметок, и если оно ниже лимита — ZAdd записывает новую. Все три команды отправляются пайплайном из урока про пайплайны: один сетевой рейс вместо трёх, что на горячем пути решает. Результат — ровный лимит без границы корзин: сто запросов за любые шестьдесят секунд, не больше.
Лимитер скользящего окна
GOfunc allowSliding(ctx context.Context, rdb *redis.Client, key string, limit int) (bool, error) {
k := "ratelimit:z:" + key
now := time.Now()
edge := strconv.FormatInt(now.Add(-time.Minute).UnixMilli(), 10)
pipe := rdb.Pipeline()
pipe.ZRemRangeByScore(ctx, k, "0", edge) // вымести старые отметки
card := pipe.ZCard(ctx, k) // сколько живых
if _, err := pipe.Exec(ctx); err != nil {
return false, err
}
if int(card.Val()) >= limit {
return false, nil // окно полное: отказ без записи
}
member := strconv.FormatInt(now.UnixNano(), 10) // уникальный член
return true, rdb.ZAdd(ctx, k,
redis.Z{Score: float64(now.UnixMilli()), Member: member}).Err()
}ZRemRangeByScore выметает отметки старше минуты — ключ не растёт бесконечно, и память под окно пропорциональна трафику. ZCard решает отказ до записи: отклонённый запрос не попадает в окно и не двигает счётчик, в отличие от фиксированной схемы, где отказанный запрос всё равно инкрементирует. UnixNano делает члены уникальными: два запроса в одну миллисекунду иначе слились бы в одну отметку, и ZAdd промолчал бы для второго.
Цена точности: память и число команд#
Скользящее окно покупает точность памятью и командами: каждая попытка — запись в ZSET, а пиковый трафик в сто тысяч запросов в минуту держит столько же членов в ключе. Для лимитов в тысячи в минуту это копейки, для сотен тысяч — уже решение об архитектуре: на таких объёмах обычно берут локальные лимитеры в памяти каждого инстанса с Redis как вторым рубежом или token bucket на Lua-скрипте. Есть и средний путь — квантование: члены со score, округлённым до секунды, при конфликте инкрементируются ZIncrBy вместо ZAdd — окно помнит секунды, а не запросы, память падает в разы при погрешности в пределах одной секунды. Выбирайте по типу трафика: API для людей прощает квантование, тарификация платных вызовов — нет.
Три схемы лимитера и их цена#
| Схема | Память | Точность | Команды на запрос |
|---|---|---|---|
| Фиксированное окно | Один счётчик на ключ | Стык корзин пропускает до двух лимитов за короткий интервал | 1-2: INCR плюс разовый EXPIRE |
| Скользящее окно | Член на каждый запрос в окне | Ровный лимит на любых интервалах | 3-4 одним пайплайном |
| Token bucket | Пара полей на ключ | Плавный темп с накоплением запаса | Lua-скрипт за один рейс |
Ограничение запросов в Go: ключ на пользователя, IP и границы#
Ключ лимитера определяет, кого именно считаем, и ошибка здесь дороже ошибки алгоритма. Считать по IP — дефолт для публичных эндпоинтов, но за корпоративным NAT сотни людей делят один адрес, и честные пользователи ловят отказ за ботов. Считать по идентификатору пользователя — правильно для авторизованных API: после middleware из урока про сессии userID уже лежит в контексте запроса. Рабочая комбинация — двухуровневый лимит: глобальный на endpoint плюс персональный на клиента. Первый защищает Redis и базу от шторма, второй не даёт одному клиенту съесть общую квоту. И правило гигиены: единая функция построения ключа, как и для кэша из урока про кэширование запросов — рассыпанные конкатенации по хендлерам превращают аудит лимитов в археологию.
Коды и заголовки: как вежливо отказывать#
Отказ лимитера — не ошибка сервиса, а штатный ответ, и оформление решает, как его встретят клиенты. Статус правильный — 429 Too Many Requests, а не 403: первый говорит «подожди и повтори», второй — «тебе сюда нельзя вообще», и мониторинг клиента реагирует на них по-разному. Заголовок Retry-After с числом секунд до нового окна экономит и чужие попытки, и ваш Redis: без него клиент повторяет по своему таймеру, обычно чаще, чем нужно. На публичных эндпоинтах не раскрывайте детали квот: слишком точный Remaining подсказывает переборщикам, когда лимит кончится, — достаточно Limit и Reset. Внутреннему фронтенду, наоборот, честный Remaining позволяет спрятать кнопку до окна и не показывать пользователю ошибку.
Лимитер без метрик — чёрный ящик, который молча режет трафик при ошибке настройки. Три числа закрывают наблюдение: доля отказов по каждому ключу — стабильный рост означает заниженный лимит или нового тяжёлого клиента; число ключей лимитера — рост после релиза подсказывает, что кто-то начал считать по редким ключам вроде полного URL; ошибки Redis при вызовах Incr — лимитер не должен молча пропускать всё при сбое сети, это решение, а не сбой. Политика отказа при недоступности Redis выбирается осознанно: для защиты платного API — закрывать, для защиты своей базы — открывать и писать в лог. Заведите эту константу рядом с лимитом, чтобы через полгода не гадать, что имелось в виду.
Оба лимитера живут локальным Go с Redis в Docker: цикл из пятидесяти горутин по allow покажет ровно limit пропусков на окно. Песочница проекта сетевые вызовы не открывает, но логику сравнения n и limit или card и limit можно скопировать в песочницу на локальном Go и проверить на числах: где проходит запрос номер сто, а где начинается отказ.
Проверка: стык корзин
Клиент сделал 100 запросов в последнюю секунду минуты и ещё 100 в первую секунду следующей. Сколько пропустит фиксированное окно с лимитом 100?
Проверка: когда ставить Expire
Почему Expire ставится только когда Incr вернул единицу?
Что вернёт третий запрос?
Прочитайте код и выберите, что он выведет:
// окно чистое, limit = 2
allow(ctx, rdb, "user:7") // 1-й вызов
allow(ctx, rdb, "user:7") // 2-й вызов
third, _ := allow(ctx, rdb, "user:7") // 3-й вызов
fmt.Println(third)Соберите фиксированное окно
Допишите счётчик окна, срок жизни на первом запросе и условие пропуска.
Соберите скользящее окно
Расставьте шаги по порядку: пайплайн, чистка старых отметок, подсчёт живых, отказ или запись.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: rate limiter
Лимитер закрыт с обеих сторон: дешёвое фиксированное окно для внутренних сценариев и честное скользящее для тарифицируемых API. Куда идти дальше по курсу: пара Incr и Expire выросла из урока про счётчики и INCR, отметки времени — из урока про ZSET и лидерборды, а TxPipeline, который лечит неатомарность пары, разобран в уроке про транзакции. Для websocket-серверов тот же приём ограничивает частоту и размер сообщений — см. урок про лимиты SetReadLimit. В следующем уроке — стратегии инвалидации кэша: лимитеры и кэш-ключи живут в Redis по одинаковым правилам построения имён. Локальная проверка напоследок: уберите Expire из ветки n == 1 и посмотрите на TTL ключа после десятого запроса — этот эксперимент запоминается на всю карьеру.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Фиксированное окно — INCR плюс разовый EXPIRE на ключ-корзину: дёшево и атомарно, но на стыке корзин пропускает до двух лимитов.
- Expire ставится один раз на окно, по ответу Incr равному единице: TTL после каждого запроса делает окно бессрочным.
- Скользящее окно на ZSET считает отметки за настоящие последние N секунд: ровный лимит ценой памяти на члены и трёх команд пайплайном.
- Отказ в скользящей схеме не двигает счётчик: ZCard проверяется до ZAdd, отклонённый запрос не попадает в окно.
- Кого считаем — ключ: IP для публичных эндпоинтов, userID после middleware авторизации, двухуровневый лимит для защиты и справедливости.
- На больших объёмах — квантование отметок, token bucket на Lua или локальные лимитеры с Redis как вторым рубежом.
Домашнее задание
Соберите оба лимитера на локальном Go с Redis в Docker и нагрузите их циклом из пятидесяти горутин: убедитесь, что фиксированное окно пропускает ровно limit, а на стыке минут — почти вдвое больше. Переведите горячего клиента на скользящее окно и сравните числа. Затем уберите ветку n == 1 и найдите в redis-cli ключ, который пережил своё окно.
Личные заметкиПусто
Вопросы и ответы по уроку
Что делать, если Redis недоступен: пускать всех или никого?
Решение зависит от того, что защищает лимитер: для защиты платного API разумно отказывать закрыто — без лимитера уедут деньги; для защиты собственной базы — пропускать с пометкой в логах, потому что отказ на сбое Redis означает отказ всего сервиса.
Почему бы не хранить счётчик в памяти процесса?
Память процесса обнуляется рестартом и не видна другим инстансам: лимит умножается на число машин, а деплой сбрасывает счёт. Локальный лимитер оправдан как первый рубеж на огромных объёмах, но квоты пользователя и тарификация живут в общем хранилище.
Как выбрать длину окна и лимит?
От характера трафика: минутные окна скрывают пиковые всплески внутри минуты, секундные — честнее, но чувствительнее к дребезгу. Лимит берите чуть выше реального максимума здорового клиента и смотрите на метрику отказов: стабильные отказы означают, что лимит занижен или клиенту нужен свой тариф.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
JWT-аутентификация в Go: golang-jwt и bcrypt
Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.
- Понимать анатомию JWT и почему payload не зашифрован
- Выпускать и проверять токены через golang-jwt v5
- Хешировать пароли bcrypt и сравнивать безопасно
- Писать middleware аутентификации для chi и Gin