open atlas
↑ К треку
Основы System Design SD · 08 · 01

Rate limiter

Rate limiter ограничивает, как быстро клиент бьёт по тебе. Token bucket и leaky bucket сглаживают всплески; фиксированное окно простое, но даёт спайк на границе; скользящее окно это чинит. На масштабе счётчик живёт в общем хранилище, и гонка read-modify-write — это вся проблема.

SD Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

В ночном скрипте клиента был цикл ретраев без backoff. Один неудачный деплой на их стороне — и он застучал по эндпоинту оформления заказа со скоростью 9 000 запросов в секунду — для одного аккаунта. Эндпоинт был тяжёлым по CPU, пул воркеров насытился, задержка очереди ушла в вертикаль, и оформление у всех остальных клиентов начало отваливаться по таймауту. Починка была не «больше серверов» — один арендатор всегда обгонит любой фиксированный флот. Починка была rate limiter: маленький дешёвый шлюз, говорящий «именно ты получаешь N в секунду», ещё до того, как запрос дойдёт до дорогого кода. Интересна не идея — а то, что счётчик обязан быть корректным на всех серверах флота одновременно, и вот тут становится сложно.

Зачем вообще ограничивать

Rate limiter защищает общий ресурс от монополизации — случайной (зацикленный ретрай) или намеренной (абьюз, перебор учёток, скрейпинг). Без него спайк одного клиента становится сбоем для всех: дорогой эндпоинт насыщается, кривая очередей уходит в вертикаль, и хорошие клиенты едят задержку, вызванную одним плохим. Stripe описывает ровно это — «сбойный скрипт пользователя, случайно шлющий кучу запросов» — и считает request rate limiter самым важным, который надо строить первым.

Лимитер — это шлюз, а не ёмкость. Если клиентам реально нужна эта пропускная способность и разнесение запросов меняет их результат (события реального времени), лимитер — не тот инструмент: нужна ёмкость или другой протокол. Задача лимитера — отвергнуть нагрузку, которую ты не можешь обслужить дёшево, с чётким сигналом, до того как она стоила тебе дорогой работы.

Четыре алгоритма, два поведения

Алгоритмы делятся на два семейства: те, что сглаживают всплески, и те, что считают по окну.

Token bucket. Ведро хранит до B токенов и пополняется на R токенов/секунду. Каждый запрос берёт один токен; если ведро пусто — отказ. Ведро позволяет клиенту накопить неиспользованный лимит и потратить его всплеском до B, затем осесть на устойчивом темпе R. Это алгоритм, который Stripe использует в проде (централизованный bucket-хост, токены капают внутрь). Это дефолт для API, потому что он совпадает с тем, как ведёт себя реальный трафик: всплесковый, но ограниченный.

Leaky bucket. Запросы входят в очередь (ведро) и вытекают с фиксированным темпом, как вода через дырку. Переполнение отбрасывается. Он даёт идеально ровный выходной темп — хорошо для защиты downstream, ненавидящего всплески, — но добавляет задержку очереди и, в отличие от token bucket, не пропускает всплески. Token bucket пускает всплеск до B; leaky bucket его сглаживает.

Fixed window (фиксированное окно). Считай запросы за фиксированный интервал часов (например, в минуту); сбрасывай счётчик на границе. Тривиально — одно целое на клиента — но у него острый изъян, разобранный ниже.

Sliding window (скользящее окно). Два дешевле-чем-точных варианта чинят изъян фиксированного окна. Скользящий лог хранит метку времени на запрос и считает те, что в хвостовом окне — точно, но хранит каждый запрос. Счётчик скользящего окна смешивает счёт текущего и предыдущего фиксированных окон по тому, как далеко ты внутри окна — приближённо, но O(1) памяти. Cloudflare популяризовал приближение счётчиком именно потому, что хранить лог на клиента не масштабируется на миллионы ключей.

Граничные случаи

Граничный спайк фиксированного окна. Лимит «100 запросов/минуту» с фиксированными окнами позволяет клиенту послать 100 в 11:00:59 и ещё 100 в 11:01:00 — 200 запросов за одну секунду, оседлав границу, при этом технически не нарушив правило «в минуту». Счётчик каждой минуты невиновен; всплеск живёт в шве между ними. Этот 2× спайк — ровно та нагрузка, ради которой ты покупал лимитер. Скользящее окно (лог или счётчик) убирает шов, измеряя окно, что движется с сейчас, а не прыгает по часам. Если запомнить лишь одну причину опасности фиксированных окон — то эту.

Где ставить шлюз

Лимитер живёт как можно дальше наружу и как можно раньше — в идеале на краю (CDN/WAF) или на API-шлюзе, до того как запрос займёт слот соединения, воркер или запрос к БД на защищаемом сервисе. Внутри сервиса лимитер всё ещё защитит БД, но потратит дешёвые ресурсы (TLS-рукопожатие, поток-воркер) на запросы, которые ты всё равно отвергнешь. Общее правило: отвергай как можно ближе к клиенту, чтобы отказ был как можно дешевле.

Но самый внешний слой часто не имеет контекста для лимита по нужному ключу (ID пользователя, API-ключ, арендатор), поэтому реальные системы слоят лимитеры: грубый per-IP лимит на краю для поглощения потопов, затем точный per-API-key лимит на шлюзе, затем иногда лимитер конкурентности на сервисе для дорогих эндпоинтов. Stripe гоняет четыре вида в связке ровно по этой причине.

Распределённый счётчик и гонка

Один сервер с одним счётчиком в памяти — легко. Флот из пятидесяти серверов за балансировщиком — реальная проблема: каждый запрос по одному API-ключу может приземлиться на любой сервер, поэтому счёт должен быть общим, иначе пятьдесят серверов каждый держат «100/мин» и клиент реально получает 5 000/мин. Стандартный ответ — общее хранилище, обычно Redis, держащее по счётчику на ключ.

Теперь гонка. Наивная последовательность — read-modify-write:

n = GET key          # сервер A читает 99
n = GET key          # сервер B тоже читает 99 (параллельно)
if n < 100:          # оба видят 99 < 100
    SET key n+1      # оба пишут 100 — один инкремент ПОТЕРЯН
    allow

Два параллельных запроса оба читают 99, оба решают, что они под лимитом, оба пускают — клиент получил 101. Под высокой конкурентностью утечка сильная. Починка — атомарность: сделать инкремент-и-проверку единой неделимой операцией. INCR в Redis атомарен, поэтому частый паттерн — INCR затем сравнение, или маленький Lua-скрипт, делающий весь refill-and-decrement token bucket на стороне сервера за один round trip (Redis исполняет скрипт атомарно). Гонка — вся причина, почему распределённый rate limiting сложнее, чем кажется: алгоритм тривиален; сделать его корректно без потерянного обновления — нет.

Частая ошибка

Что происходит, когда хранилище лимитов падает? Если твой лимитер делает INCR к Redis на критическом пути, а Redis недоступен, наивная реализация либо ошибётся на каждом запросе (ты положил себя сам ради соблюдения лимита), либо повиснет на таймауте (хуже). Правило Stripe: fail open (отказывай в сторону «пропустить»). Оберни лимитер так, чтобы при ошибке или таймауте хранилища запрос пропускался, а не блокировался — лимитер это ремень безопасности, а не несущая стена, и он никогда не должен быть причиной падения API. Дополни kill switch (фича-флаг, чтобы отключить сбойный лимитер) и коротким таймаутом, чтобы медленный Redis не добавлял задержку каждому вызову.

Выбери лучший вариант

Публичный REST API обслуживает мобильных клиентов, которые шлют пачечный трафик: при открытии приложения пользователь отправляет 12 запросов за первую секунду, затем 1–2 в минуту. API должен разрешать этот всплеск, но ограничивать устойчивый темп до 60 запросов/мин. Какой алгоритм подходит?

Сказать клиенту: 429 и Retry-After

Когда отвергаешь — скажи точно. Стандарт — HTTP 429 Too Many Requests с заголовком Retry-After, говорящим клиенту, сколько секунд подождать перед повтором (некоторые API также шлют X-RateLimit-Remaining / -Reset, чтобы хороший клиент сам притормозил до удара в стену). Это важно, потому что альтернатива — голая ошибка без подсказки — толкает клиентов в тесные циклы ретраев, которые добавляют нагрузку ровно тогда, когда ты уже перегружен, превращая brownout в полный сбой. Точный 429 с Retry-After даёт хорошему клиенту откатиться чисто; вместе с экспоненциальным backoff и jitter на стороне клиента это и держит ограниченную систему стабильной, а не дёргающейся.

Викторина

Лимит 100 запросов/минуту использует фиксированное окно, сбрасывающееся на минуте часов. Каков худший всплеск, который клиент может легитимно послать, и за какой промежуток?

Викторина

На флоте из 50 серверов каждый сервер читает общий счётчик, проверяет, что он ниже лимита, затем пишет инкрементированное значение. Под конкурентностью клиент превышает лимит. В чём починка?

Закончи аналогию

_______ хранит до B токенов и пополняется на R в секунду; каждый запрос тратит один токен и отвергается, если ведро пусто — поэтому клиент может всплеснуть до B после тишины, но держит лишь R во времени.

Вспомните перед уходом
  1. 01
    Сравни token bucket, leaky bucket, fixed window и sliding window.
  2. 02
    Почему распределённый rate limiting сложен и в чём починка?
  3. 03
    Как лимитер должен вести себя при падении хранилища и как отвергать?
Итог

Rate limiter — дешёвый шлюз, ограничивающий темп каждого клиента до того, как запрос дойдёт до дорогой работы, защищая общий ресурс от спайка одного арендатора — случайного или враждебного. Четыре алгоритма, два поведения: token bucket (ёмкость B = макс. всплеск, пополнение R = устойчивый темп; продакшен-дефолт) и leaky bucket сглаживают всплески; fixed window тривиален, но допускает 2× граничный спайк (100 прямо перед сбросом + 100 сразу после), который sliding window (лог или счётчик) закрывает. Ставь его как можно дальше наружу и как можно раньше — грубо per-IP на краю, точно per-key на шлюзе, конкурентность на сервисе — чтобы отказ был дёшев. На масштабе счётчик должен быть общим (один счётчик Redis на ключ), а наивный read-modify-write гоняется в потерянные обновления, поэтому инкремент-и-проверка должны быть атомарными (INCR или атомарный Lua-скрипт). Наконец, fail open при падении хранилища и отвергай через 429 + Retry-After, чтобы хорошие клиенты откатывались, а не дёргались. Теперь, когда встретишь сервис, где один клиент валит всех остальных — сначала проверь, есть ли rate limiter, а затем — действительно ли счётчик общий на весь флот.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.