Rate limiting и throttling: защита системы от перегрузки

Как ограничивать нагрузку: token bucket, leaky bucket, sliding window, распределённый rate limiting и чем throttling отличается от отказа

Любая система имеет потолок пропускной способности. Без явного ограничения этот потолок находят за вас — лавиной запросов, которая роняет сервис целиком вместо того, чтобы отказать части. Rate limiting — это контролируемый отказ: лучше честно сказать «слишком часто, повторите позже», чем упасть для всех.

Это первая статья серии «System design: resilience». Все числа ниже сняты с живого стенда architecture/resilience/ratelimit: пять алгоритмов на одном интерфейсе с искусственным временем, поэтому «на стыке окон прошло 200 при лимите 100» — воспроизводимый факт, а не рассуждение.

Гравюра в стиле «Полдень»: латунный автомат впуска. Из носика мерно капают одинаковые жетоны в ведро на весах, темп задаёт маховик с анкерным механизмом. Рядом турникет пропускает по одному человеку за жетон; ровная очередь идёт внутрь, а излишек отводится шарнирным рычагом в боковой проход, где каждый получает пустую бирку

В статье

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

У ограничения частоты три разные задачи, и их полезно не путать:

  • Защита от перегрузки. Система рассчитана на какой-то поток; за его пределом латентность растёт нелинейно, а потом сервис встаёт (почему именно нелинейно — в теории очередейСкоро). Лимит удерживает вход в рамках, где система ещё жива.
  • Защита от злоупотребления. Один клиент — по ошибке в цикле или намеренно — не должен выедать ресурс, рассчитанный на всех. Это не про сетевые атаки на доступность (объёмный флуд L3/L4 — тема защиты от DDoSСкоро), а про прикладной поток запросов.
  • Справедливость. Общий ресурс делится между потребителями так, чтобы жадный не вытеснил остальных. Лимит на пользователя/ключ/тариф — это про справедливость, а не только про выживание.

Три задачи требуют разных лимитов в разных местах, к этому вернёмся. Сначала — чем вообще считать.

Четыре алгоритма

Семейств алгоритмов по сути четыре — два ведра и два окна, — но скользящее окно бывает точным и приближённым, так что на стенде реализаций пять. Различаются они двумя свойствами: пропускают ли короткий всплеск и как ведут себя на границе интервала. На стенде все они реализованы на одном интерфейсе AllowAt(now) — время передаётся явно, поэтому поведение можно замерить точно.

Вёдра: token bucket и leaky bucket

Token bucket — ведро ёмкостью C, в которое капают токены с темпом R в секунду; запрос тратит токен. Пока в ведре есть накопленное, оно пропускает всплеск до ёмкости разом, а средний темп держит на R. Leaky bucket — наоборот, пейсер: на выходе ровный поток R, всплеск разом не проходит.

Разница видна на замере — 10 запросов в один момент:

алгоритм пропущено разом
token bucket (ёмкость 5) 5 — всплеск до ёмкости
leaky bucket (5/сек, строгий) 1 — остальное сглажено в темп

Выбор между ними — это ответ на вопрос «терпим ли мы короткие всплески». Публичный API обычно терпит (пользователь открыл страницу — прилетело десять запросов сразу): token bucket. Защита хрупкого бэкенда, которому важен именно ровный поток, — leaky bucket.

Окна: fixed и sliding

Fixed window — счётчик в интервале, выровненном по часам (сбрасывается каждую секунду/минуту). Прост и дёшев, но у него есть изъян на стыке окон. Замер, лимит 100/сек, всплеск на границе — 100 запросов в конце одного окна и 100 в начале следующего, всё внутри 100 мс:

алгоритм пропущено за стык
fixed-window 200 — вдвое больше лимита
sliding-log 100 — ровно лимит
sliding-counter 100 — ровно лимит

Фиксированное окно обнуляет счётчик на границе, поэтому за короткий интервал вокруг неё проходит лимита — а это ровно тот всплеск, от которого лимит и должен защищать. Sliding window держит лимит в любом интервале длиной окна. Точный вариант (log) хранит метки времени пропущенных запросов и выбрасывает те, что старше окна, — платит памятью (до limit меток на ключ). Приближённый (counter) взвешивает два соседних окна по одному числу — O(1) памяти, но на краю ошибается на доли лимита.

Практический выбор: fixed window берут, когда всплеск ×2 не страшен и важна дешевизна; sliding — когда лимит должен держаться строго.

Где ставить лимит

Один и тот же лимит на разных уровнях решает разные задачи:

  • На краю (edge/CDN/WAF) — грубый лимит по IP против явного флуда, до того как трафик дошёл до приложения.
  • В API-шлюзе — лимит на клиента/ключ/тариф, единая точка для всех сервисов за ним (подробнее про роль шлюза — в API gatewayСкоро).
  • В самом сервисе — лимит на дорогую операцию или на конкретного пользователя, где видно бизнес-контекст, недоступный шлюзу.

Общее правило: чем ближе к краю, тем дешевле отбить лишнее (не тратим на него ресурсы вглубь), но тем меньше контекста. Тонкий лимит «этому пользователю на эту операцию» ставится там, где этот контекст есть, — обычно в сервисе.

Распределённый лимит

Пока инстанс один, лимит — локальная переменная. Как только инстансов несколько, встаёт вопрос: лимит на каждого по отдельности или один общий на всех? Если каждый считает сам, глобальный лимит нарушается кратно числу инстансов. Замер — три «инстанса», общий лимит 100/сек, каждый шлёт 100 запросов разом:

где считаем пропущено
локально (у каждого своё ведро) 300 — глобальный лимит нарушен втрое
общий ключ в Redis 100 — лимит соблюдён

Локальные лимитеры не знают друг о друге — три инстанса пропускают 3× лимита. Чтобы держать глобальный лимит, нужен общий счётчик; на стенде это token bucket целиком внутри атомарного Lua-скрипта в Redis (атомарность важна: наивное «прочитал–проверил–записал» разъезжается под гонкой).

Цена — round-trip к Redis на каждый запрос (на стенде порядка миллисекунды; число машинозависимо). Отсюда практика: на краю ставят дешёвый локальный лимит с запасом (грубо отсечь очевидный избыток), а точный глобальный — в общем счётчике, и только там, где он действительно нужен. Ещё одна тонкость: у общего лимита должен быть один источник времени. Если каждый инстанс присылает своё, рассинхрон часов ломает учёт — отставший сдвигает отметку назад, и следующий запрос начисляет токены повторно (на стенде это воспроизведено: лимит пропускает вдвое больше). Поэтому время берут из самого хранилища, а не с часов инстанса. И ещё общий счётчик сам становится зависимостью: если Redis недоступен, решают заранее, лимитер «размыкается» (пропускает всё) или «закрывается» (отклоняет), и это выбор между доступностью и защитой.

Корректный отказ

Ограничение только тогда полезно, когда отказ честный. Отклонённый запрос должен получить 429 Too Many Requests и заголовок Retry-After — сколько ждать до осмысленного повтора. На стенде это делает middleware: на отклонении ставит Retry-After в секундах и отдаёт 429, а не роняет запрос молча и не держит зависший коннект.

Почему это важно: тихая потеря запроса неотличима для клиента от сбоя, и он повторит немедленно — усилив нагрузку ровно тогда, когда её надо снизить. Явный Retry-After превращает отказ в управляемый: корректный клиент подождёт и вернётся. (Как клиенту повторять правильно — с backoff и джиттером, чтобы отклонённые не вернулись синхронной волной, — в resilience-паттернахготовится, с 14 сентября.)

Здесь же граница между throttling и отказом. Отказ (429) — «не сейчас, приходи позже». Throttling — «поставлю в очередь и обслужу медленнее». Throttling мягче для клиента, но у него есть предел: очередь не может быть бесконечной, иначе латентность растёт неограниченно и вы возвращаетесь к исходной проблеме. Что делать, когда очередь и так полна, а система не тянет, — это уже не про лимит на клиента, а про то, как система защищает себя изнутри: backpressure и load sheddingготовится, с 16 сентября.

Место в общей защите

Rate limiting — первый слой, не единственный. Он ограничивает вход: сколько запросов вообще впустить. Но впущенные запросы всё равно могут перегрузить систему, если зависимость замедлилась или поток превысил расчёт, — и тогда работают уже другие механизмы:

  • Resilience-паттерныготовится, с 14 сентября — timeout, retry, circuit breaker, bulkhead: локализуют отказ уже внутри, когда запрос принят.
  • Backpressure и load sheddingготовится, с 16 сентября — что делать, когда система не тянет сама, независимо от лимита на клиента.

Лимит частоты и эти механизмы не заменяют друг друга: первый решает «кого впустить», вторые — «как не обрушиться на впущенном». Полная картина слоёв защиты — в карте защиты от перегрузки.

Checklist

  • Разделили задачи: защита от перегрузки, от злоупотребления, справедливость — им нужны разные лимиты.
  • Выбрали алгоритм осознанно: терпим всплеск (token bucket) или сглаживаем (leaky); нужна строгость на границе (sliding) или хватит дешёвого fixed.
  • Лимит стоит там, где есть нужный контекст: грубый — на краю, точный на пользователя — в сервисе.
  • Для нескольких инстансов решили: локальный лимит на каждого или общий счётчик; учли его цену и то, что он сам зависимость.
  • Отказ честный: 429 + Retry-After, не тихая потеря.
  • Заранее решили поведение при недоступности общего счётчика: пропускать или отклонять.

Первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии