---
title: "Rate limiter на Redis"
description: "Rate limiter на Redis в Go: фиксированное окно на INCR и EXPIRE, скользящее окно на ZSET. Ограничение запросов по пользователю и IP, ловушки схемы и пайплайн."
course: "Redis в Go: кэш, сессии и очереди на go-redis"
url: "https://golangmaster.ru/courses/go-redis/lessons/22-rate-limiter/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Rate limiter на Redis

*Курс «Redis в Go: кэш, сессии и очереди на go-redis», урок 22 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-redis/lessons/22-rate-limiter/*

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

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

### Rate limiter на Redis: фиксированное окно на INCR и EXPIRE

Фиксированное окно — самая простая схема, которая закрывает большинство внутренних сценариев. Каждая минута — своя корзина: ключ вида ratelimit:sms:202506011407 содержит счётчик обращений, срок жизни равен длине окна. Запрос сначала инкрементирует счётчик, потом сверяет с лимитом: Incr вернул число выше разрешённого — отказ, ниже — пропуск. Всё атомарно: INCR выполняется сервером единым шагом, поэтому параллельные горутины и инстансы не потеряют увеличения, — тот же приём из урока про [счётчики и INCR](/courses/go-redis/lessons/09-schetchiki-i-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 из урока про [транзакции](/courses/go-redis/lessons/17-transakcii/) отправляет обе команды одним рейсом. Возврат n <= limit делает запрос номер сто разрешённым, сто первый — нет.

### Стык окон: где фиксированная схема врёт

У фиксированного окна есть известная слепая зона — граница корзин. Лимит сто в минуту не означает «не больше ста за любые шестьдесят секунд»: на стыке окна клиент может провести сто запросов в последние секунды текущей корзины и ещё сто в первые секунды следующей — двести обращений за короткий интервал при формальном соблюдении лимита. Для защиты платного API этого мало, для большинства внутренних сценариев — достаточно. Классическая ошибка — «улучшить» схему укорачиванием окна: окно в одну секунду при всплеске от ботов пропускает пачки запросов между секундами и добавляет лишнюю нагрузку на Redis. Если честность квоты принципиальна, нужен следующий приём — скользящее окно, которое считает настоящие последние шестьдесят секунд, а не абстрактную корзину.

> **Expire после каждого запроса делает окно бессрочным.** Типичная ошибка: поставить Expire после каждого Incr «для надёжности». TTL теперь отсчитывается заново с каждого запроса, окно никогда не истекает, и упёршийся в лимит клиент остаётся заблокированным навсегда — пока с его ключа идёт трафик. Expire ставится ровно один раз на окно, по ответу Incr равному единице; в пайплайне — в той же посылке.

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

Скользящее окно считает запросы честно: не «сколько инкрементов в текущей минуте», а «сколько отметок времени в последних шестидесяти секундах». Хранителем отметок выступает ZSET из урока про [отсортированные множества](/courses/go-redis/lessons/13-zset-reitingi/): член — уникальный идентификатор запроса, score — время в миллисекундах. Перед подсчётом старые отметки выметаются ZRemRangeByScore с границей от нуля до now минус шестьдесят тысяч, затем ZCard отвечает числом живых отметок, и если оно ниже лимита — ZAdd записывает новую. Все три команды отправляются пайплайном из урока про [пайплайны](/courses/go-redis/lessons/16-pipy-lineinye/): один сетевой рейс вместо трёх, что на горячем пути решает. Результат — ровный лимит без границы корзин: сто запросов за любые шестьдесят секунд, не больше.

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

```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 из урока про [сессии](/courses/go-redis/lessons/21-sessii-web/) userID уже лежит в контексте запроса. Рабочая комбинация — двухуровневый лимит: глобальный на endpoint плюс персональный на клиента. Первый защищает Redis и базу от шторма, второй не даёт одному клиенту съесть общую квоту. И правило гигиены: единая функция построения ключа, как и для кэша из урока про [кэширование запросов](/courses/go-redis/lessons/06-keshirovanie-zaprosov/) — рассыпанные конкатенации по хендлерам превращают аудит лимитов в археологию.

### Коды и заголовки: как вежливо отказывать

Отказ лимитера — не ошибка сервиса, а штатный ответ, и оформление решает, как его встретят клиенты. Статус правильный — 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?

- ✅ 200: обе корзины считаются независимо
- 100: окно считает честные шестьдесят секунд
- 101
- 0: клиент заблокирован за всплеск

Корзины независимы, поэтому на стыке пропускается до двух лимитов за короткий интервал. Именно эту слепую зону закрывает скользящее окно на ZSET, которое считает отметки за настоящие последние шестьдесят секунд.

### Проверка: когда ставить Expire

**Вопрос:** Почему Expire ставится только когда Incr вернул единицу?

- ✅ Иначе каждый запрос продлевал бы TTL, и окно никогда не истекало
- Expire дороже Incr, экономим команду
- Так требует протокол RESP
- Чтобы счётчик не обнулился

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

### Что вернёт третий запрос?

```go
// окно чистое, limit = 2
allow(ctx, rdb, "user:7")   // 1-й вызов
allow(ctx, rdb, "user:7")   // 2-й вызов
third, _ := allow(ctx, rdb, "user:7") // 3-й вызов

fmt.Println(third)
```

**Что выведет программа?**

- ✅ false: третий запрос сверх лимита
- true: первые два отказали
- Панику
- false, но счётчик стал 4

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

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

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

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

Подсказка: Счётчик растёт командой Incr, единица означает начало окна, а пропуск — пока счётчик не превысил limit.

Правильные ответы в пропусках: `Incr`, `1`, `limit`

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

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

Правильный порядок строк:

1. `func allowSliding(ctx context.Context, rdb *redis.Client, key string) (bool, error) {`
2. `    pipe := rdb.Pipeline()`
3. `    pipe.ZRemRangeByScore(ctx, key, "0", edge)`
4. `    card := pipe.ZCard(ctx, key)`
5. `    if _, err := pipe.Exec(ctx); err != nil { return false, err }`
6. `}`

### Карточки: rate limiter

- **Фиксированное окно** — INCR плюс разовый EXPIRE на ключ-корзину. Просто и дёшево, но на стыке корзин пропускает до двух лимитов за короткий интервал.
- **Скользящее окно** — ZSET с отметками времени: ZRemRangeByScore выметает старые, ZCard считает живые, ZAdd пишет новую. Ровный лимит ценой памяти на члены.
- **Expire только при n == 1** — Ставить TTL после каждого запроса нельзя: окно станет бессрочным, а упёршийся в лимит клиент — заблокированным навсегда.
- **Ключ лимитера** — Кого считаем: IP для публичных эндпоинтов, userID для авторизованных, двухуровневая связка глобального и персонального лимита. Одна функция построения ключа на весь сервис.
- **Пайплайн в лимитере** — Чистку, подсчёт и запись отправляют одним рейсом: три-четыре сетевые поездки на каждый запрос убили бы выгоду Redis.

Лимитер закрыт с обеих сторон: дешёвое фиксированное окно для внутренних сценариев и честное скользящее для тарифицируемых API. Куда идти дальше по курсу: пара Incr и Expire выросла из урока про [счётчики и INCR](/courses/go-redis/lessons/09-schetchiki-i-incr/), отметки времени — из урока про [ZSET и лидерборды](/courses/go-redis/lessons/13-zset-reitingi/), а TxPipeline, который лечит неатомарность пары, разобран в уроке про [транзакции](/courses/go-redis/lessons/17-transakcii/). Для websocket-серверов тот же приём ограничивает частоту и размер сообщений — см. урок про [лимиты SetReadLimit](/courses/websocket/lessons/14-limity/). В следующем уроке — [стратегии инвалидации кэша](/courses/go-redis/lessons/23-strategii-invali-dacii/): лимитеры и кэш-ключи живут в Redis по одинаковым правилам построения имён. Локальная проверка напоследок: уберите Expire из ветки n == 1 и посмотрите на TTL ключа после десятого запроса — этот эксперимент запоминается на всю карьеру.

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

- Фиксированное окно — 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 означает отказ всего сервиса.

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

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

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

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