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

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

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

Урок 22 из 25 ~20 мин +20 XP

Rate limiter на Redis

Rate limiter на Redis — обязательная деталь Go-сервиса, у которого есть платные API, ретраи или публичные эндпоинты: без контроля темпа вечерний пик превращается в перерасход и шторм чужих систем. Строим две реализации — фиксированное окно на INCR и EXPIRE и скользящее окно на ZSET — и разбираем ограничение запросов в Go вместе с границей применимости каждой схемы.

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

Продукт просит интеграцию с платным 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.

Лимитер фиксированного окна

GO
const 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. Если честность квоты принципиальна, нужен следующий приём — скользящее окно, которое считает настоящие последние шестьдесят секунд, а не абстрактную корзину.

Rate limiter на Redis: скользящее окно на ZSET#

Скользящее окно считает запросы честно: не «сколько инкрементов в текущей минуте», а «сколько отметок времени в последних шестидесяти секундах». Хранителем отметок выступает ZSET из урока про отсортированные множества: член — уникальный идентификатор запроса, score — время в миллисекундах. Перед подсчётом старые отметки выметаются ZRemRangeByScore с границей от нуля до now минус шестьдесят тысяч, затем ZCard отвечает числом живых отметок, и если оно ниже лимита — ZAdd записывает новую. Все три команды отправляются пайплайном из урока про пайплайны: один сетевой рейс вместо трёх, что на горячем пути решает. Результат — ровный лимит без границы корзин: сто запросов за любые шестьдесят секунд, не больше.

Лимитер скользящего окна

GO
func 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 — закрывать, для защиты своей базы — открывать и писать в лог. Заведите эту константу рядом с лимитом, чтобы через полгода не гадать, что имелось в виду.

Проверка: стык корзин

Клиент сделал 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)

Соберите фиксированное окно

Допишите счётчик окна, срок жизни на первом запросе и условие пропуска.

n, err := rdb.(ctx, "ratelimit:"+key).Result() if err != nil { return false, err } if n == { rdb.Expire(ctx, "ratelimit:"+key, time.Minute) } return n <=

Соберите скользящее окно

Расставьте шаги по порядку: пайплайн, чистка старых отметок, подсчёт живых, отказ или запись.

Строки

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

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

Карточки: rate limiter

Термин

Фиксированное окно

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

INCR плюс разовый EXPIRE на ключ-корзину. Просто и дёшево, но на стыке корзин пропускает до двух лимитов за короткий интервал.

1 / 5

Лимитер закрыт с обеих сторон: дешёвое фиксированное окно для внутренних сценариев и честное скользящее для тарифицируемых 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 · Rate limiter на Redis)

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

Что делать, если Redis недоступен: пускать всех или никого?

Решение зависит от того, что защищает лимитер: для защиты платного API разумно отказывать закрыто — без лимитера уедут деньги; для защиты собственной базы — пропускать с пометкой в логах, потому что отказ на сбое Redis означает отказ всего сервиса.

Почему бы не хранить счётчик в памяти процесса?

Память процесса обнуляется рестартом и не видна другим инстансам: лимит умножается на число машин, а деплой сбрасывает счёт. Локальный лимитер оправдан как первый рубеж на огромных объёмах, но квоты пользователя и тарификация живут в общем хранилище.

Как выбрать длину окна и лимит?

От характера трафика: минутные окна скрывают пиковые всплески внутри минуты, секундные — честнее, но чувствительнее к дребезгу. Лимит берите чуть выше реального максимума здорового клиента и смотрите на метрику отказов: стабильные отказы означают, что лимит занижен или клиенту нужен свой тариф.

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

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

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

JWT25 уроков~12 чпрактика25 уроков уже доступны

JWT-аутентификация в Go: golang-jwt и bcrypt

Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.

  • Понимать анатомию JWT и почему payload не зашифрован
  • Выпускать и проверять токены через golang-jwt v5
  • Хешировать пароли bcrypt и сравнивать безопасно
  • Писать middleware аутентификации для chi и Gin