open atlas
← Все проекты

backend · intermediate · 4d

Распределённый rate limiter

Собери token-bucket лимитер, который держится поперёк многих инстансов приложения за счёт счётчика в Redis, а не в памяти процесса.

Распределённый rate limiter — самый маленький проект, который разом заставляет пройти каждый трудный урок распределённых систем: почему счётчик в памяти процесса начинает врать в момент запуска второго инстанса, почему read-modify-write, разбитый по сети, — это гонка, и почему единственный честный фикс — сделать всё обновление ведра атомарным. Математика token-bucket остаётся чистой и юнит-тестируемой в одном процессе; Redis затем превращает её в задачу конкуренции, которую надо сериализовать Lua-скриптом; а протокол 429/Retry-After плюс выбор fail-open против fail-closed превращают её из алгоритма в продуктовое решение с реальными компромиссами защиты от злоупотреблений и доступности.

Результат

Middleware, который держит N запросов/окно на ключ, отдаёт 429 с Retry-After и остаётся корректным под конкурентной нагрузкой.

Этапы

0/6 · 0%
  1. 01Token bucket в памяти процесса

    Начни там, где алгоритм чист: один процесс, без сети, вся тонкость — в математике пополнения. Token bucket держит до `burst` токенов и пополняется со скоростью `rate` токенов/сек; каждый запрос стоит один токен, и запрос разрешён, только если ведро непусто. Хитрость в том, что ты не запускаешь фоновый таймер пополнения — ты считаешь пополнение лениво на каждом запросе из `elapsed = now - lastRefill`, затем зажимаешь до `burst`. Это позволяет клиенту простаивать, накопить полный burst и потратить его одним всплеском (скажем, 100 запросов в ведре на 100 при пополнении 10/с) — ровно то bursty-but-bounded поведение, что тебе нужно, и ровно поэтому наивные счётчики fixed-window ощущаются неправильно. Доведи математику и зажим до точности здесь, в одном потоке, до того как всё это должно пережить Redis round trip в 0.5–2 мс или гонку двух инстансов.

    Критерии готовности
    • Скорость пополнения и размер burst настраиваются, а пополнение считается лениво из прошедшего времени, а не фоновым таймером.
    • Юнит-тест доказывает, что полный burst пропускается, следующий запрос сверх ёмкости тормозится, а токены возвращаются ровно со скоростью `rate` после измеренной паузы простоя.
    Самопроверка

    Пройди вычисление пополнения для клиента, простаивавшего 30 с, на ведре с пополнением 10/с и burst 100: senior-ревьюер проверяет, что ты зажимаешь до `burst` (а не копишь без границы) и что `lastRefill` корректно сдвигается и на отклонённом запросе.

  2. 02Перенеси счётчик в Redis

    В момент, когда ты запускаешь два инстанса приложения за балансировщиком, состояние в памяти процесса врёт: каждый инстанс держит своё ведро, так что N инстансов пропускают N× от лимита. Счётчик должен жить в одном общем месте, и Redis — обычный выбор, потому что однопоточный сервер даёт точку сериализации и операции за доли миллисекунды. Но read-modify-write, разбитый по сети (GET, вычислить, SET), — это гонка: между твоими GET и SET другой инстанс может декрементировать, и два запроса дважды потратят последний токен. Этот этап — про то, чтобы перенести состояние ведра (tokens, lastRefill) в Redis и доказать, что опасность конкуренции существует, до того как ты починишь её правильно в Lua — замерь over-admission под конкурентным молотом, чтобы у следующего этапа была базовая линия для побития.

    Критерии готовности
    • Состояние ведра (tokens + метка lastRefill) живёт в Redis с ключом на клиента, с TTL, чтобы простаивающие ключи освобождались, а не текли по памяти.
    • Тест конкуренции с M инстансами, бьющими в один ключ, показывает, что наивный путь GET/вычислить/SET переразрешает (пропускает больше лимита), и переучёт зафиксирован как базовая линия для фикса.
    Самопроверка

    Покажи свою последовательность read-modify-write и назови точное переплетение, где два инстанса оба видят один оставшийся токен и оба разрешают; senior-ревьюер проверяет, что ты понимаешь, почему многокомандной транзакции (или WATCH/MULTI) всё ещё недостаточно без атомарного вычисления.

  3. 03Сделай атомарным через Lua-скрипт

    Почини гонку, схлопнув read-modify-write в одну атомарную единицу. Lua-скрипт Redis (через EVAL/EVALSHA) выполняется до конца без переплетения, потому что Redis однопоточен — так что всё обновление ведра (прочитать tokens+lastRefill, пополнить из elapsed, зажать до burst, декрементировать если разрешено, записать назад) становится одним неделимым шагом, и многоузловой double-spend просто не может случиться. На senior-глубине важны два правила дизайна: (1) передавай `now` в скрипт аргументом, а не вызывай `redis.call('TIME')`, чтобы скрипт был детерминированным и безопасным для репликации/AOF на узлах с расхождением часов; (2) держи скрипт крошечным и O(1) — он держит единственный поток, так что медленный скрипт стопорит каждого другого клиента. Перезапусти конкурентный молот из прошлого этапа и смотри, как over-admission уходит в ноль.

    Критерии готовности
    • Всё пополнение-и-декремент — один Lua-скрипт, вызываемый через EVALSHA, а `now` передаётся аргументом, чтобы скрипт был детерминированным и безопасным для репликации.
    • Повторный запуск M-инстансного молота на одном ключе теперь никогда не превышает заданный лимит (over-admission равен нулю относительно базовой линии этапа 2).
    Самопроверка

    Объясни, почему вызов `redis.call('TIME')` внутри скрипта был бы неправильным при репликации, и почему долго выполняющийся Lua-скрипт — это самонанесённый инцидент латентности на однопоточном сервере; senior-ревьюер проверяет, что скрипт O(1), а время инжектится, а не читается.

  4. 04Sliding window против fixed window против bucket

    Теперь обоснуй алгоритм, потому что у выбора есть реальные режимы отказа. Счётчик fixed-window (инкремент ключа, истекающего на границе окна) — самый дешёвый, один INCR, но допускает 2× burst через границу: клиент может выпустить `limit` запросов в последнюю секунду окна A и ещё `limit` в первую секунду окна B, удвоив задуманную скорость на один миг. Sliding-window log чинит это точно, храня метки времени по каждому запросу, но его расход памяти растёт с частотой запросов (sorted set из N записей на ключ). Sliding-window counter дёшево аппроксимирует его, взвешивая предыдущее окно. Реализуй хотя бы одну альтернативу рядом со своим token bucket и нагрузи их все при всплесках трафика, чтобы сформулировать, с числами, компромисс точность-против-памяти, а не утверждать его.

    Критерии готовности
    • Хотя бы два алгоритма (token bucket + один оконный вариант) реализованы за одним интерфейсом и выбираются конфигом.
    • Нагрузочный тест воспроизводит граничный burst fixed-window (≈2× лимита) и показывает, что твой sliding/bucket-вариант его ограничивает, с зафиксированным расходом памяти на ключ для каждого.
    Самопроверка

    При лимите 100 запросов/мин продемонстрируй граничную атаку на fixed-window численно (200 запросов за 2 секунды через границу), затем покажи, какой из твоих вариантов её предотвращает и во что это обходится в памяти Redis на активный ключ.

  5. 05429, Retry-After, заголовки и борьба со злоупотреблением

    Лимитер, который просто роняет запросы, — плохой сосед; воспитанный точно говорит клиенту, как отступить. Возвращай 429 Too Many Requests с `Retry-After` (секунды до доступности токена, вычисленные из дефицита ведра и скорости пополнения) и стандартными заголовками `RateLimit-Limit / -Remaining / -Reset`, чтобы клиенты сами тормозили до блокировки. Затем закали политику: выбирай ключ лимита осознанно (API-ключ лучше голого IP, потому что NAT/CDN ставит тысячи пользователей за один IP, а единственный подделываемый заголовок подделать легко), и реши, что происходит, когда сам Redis недоступен. Fail-open держит сайт живым, но даёт атакующему тривиальный обход (урони Redis — лимитер исчезает); fail-closed защищает бэкенд, но превращает мигание Redis в полный простой. Senior-ответ обычно — ограниченный локальный фолбэк, а не бинарный выбор.

    Критерии готовности
    • Затроттленные ответы возвращают 429 с корректным `Retry-After` плюс `RateLimit-Limit/-Remaining/-Reset`, а ключ лимита труднее подделать, чем голый IP.
    • Тест сбоя Redis деградирует по задокументированной политике (ограниченный локальный фолбэк или явный fail-open/closed), и ты можешь сформулировать, как атакующий может обратить этот выбор в оружие.
    Самопроверка

    Если твой фолбэк при сбое Redis — fail-open, покажи, как атакующий валит Redis, чтобы отключить лимитер и затопить бэкенд; если fail-closed — покажи, как 2-секундное мигание Redis становится клиентским простоем; senior-ревьюер хочет, чтобы ты защитил выбранный компромисс, а не уклонился от него.

  6. 06Нагрузи, наблюдай и отработай инцидент

    Докажи это под реальной нагрузкой и сделай читаемым, когда оно ведёт себя плохо. Нагрузи лимитер как развёрнутый (несколько инстансов, настоящий Redis, смешанный разрешённый/затроттленный трафик) и найди QPS, при котором узким местом становится Redis round trip, а не твоё приложение: при ~0.5–2 мс на EVALSHA синхронная проверка лимитера ограничивает пропускную способность каждого инстанса, а медленный или горячий Redis добавляет эту латентность к каждому запросу. Снимай RED-метрики (rate запросов, throttle/error rate, длительность проверки лимитера) и трейс-span на вызов Redis, чтобы видеть собственную латентность лимитера в водопаде запроса. Затем отработай инцидент: горячий ключ (один злоупотребляющий тенант или все запросы по одному значению из-за бага) концентрирует нагрузку на одном слоте Redis, p99 проверки лимитера взлетает, и лимитер начинает тормозить трафик, который должен был защищать. Обнаружь это по своему дашборду, смягчи и найди корневую причину.

    Критерии готовности
    • Нагрузочный тест сообщает QPS, при котором Redis round trip ограничивает пропускную способность, а дашборд показывает rate лимитера, throttle rate и p50/p99 длительности проверки.
    • Ты воспроизвёл инцидент горячего ключа, локализовал его через трейс-span Redis, смягчил (шардирование ключа, локальный кэш решения с коротким TTL или проверки с jitter) и написал короткий пост-мортем, называющий превенцию, которая не «добавь ещё Redis».
    Самопроверка

    Вставь корневую причину и превенцию из пост-мортема; senior-ревьюер проверяет, что ты опознал проверку лимитера как горячий путь (а не логику приложения), что трейс-span локализовал латентность Redis и что фикс адресует распределение ключей, а не просто «масштабируй Redis».

Стартер

  • README.md
  • src/bucket.ts
  • test/bucket.test.ts
Скачать стартер (.zip)

Распакуй, реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test

Рубрика

Джуниор Миддл Сеньор
Корректность пополнения Токены тратятся на каждый запрос, ведро стартует полным, фиксированная задержка пополняет их. Работает в одном процессе при лёгкой нагрузке. Пополнение считается из прошедшего времени (а не таймером), ограничено вместимостью, дробные токены накапливаются между вызовами — и всплеск, и установившийся режим ведут себя верно. Та же математика держится с инъектируемыми часами без накопления ошибок округления; ты можешь назвать худший всплеск и долгосрочную скорость ведра и показать тест, который фиксирует каждую.
Безопасность при конкуренции Счётчик живёт в памяти процесса, и ты признаёшь, что он не держится между инстансами. Счётчик переезжает в общее хранилище; ты опознаёшь гонку read-modify-write и сериализуешь её Lua-скриптом, атомарной операцией или транзакцией. Пополнение-и-списание — один атомарный шаг под конкуренцией; ты учитываешь рассинхрон часов между узлами и под нагрузкой проверяешь, что два инстанса держат единый глобальный потолок.
Защита от злоупотреблений и наблюдаемость Запросы сверх лимита возвращают HTTP 429. 429 несёт Retry-After, а ключ лимита — правильная идентичность: на API-ключ, а не на IP, который схлопывает всех за прокси. Ты инструментируешь лимитер как горячий путь (RED-метрики плюс трейс-span), воспроизводишь инцидент горячего ключа и смягчаешь его пост-мортемом, чья превенция — не «добавь ещё Redis».
Эталонный разбор (спойлер)

Почему token bucket: он разрешает короткие всплески до вместимости, ограничивая долгосрочную скорость, и хранит всего два числа — текущие токены и время последнего пополнения — что дешевле sliding-window-log и глаже всплесков на границах фиксированного окна.

Атомарное пополнение под конкуренцией: пополнение-и-списание — это read-modify-write. Между инстансами эти шаги переплетаются, поэтому вся операция должна быть атомарной — Lua-скрипт Redis или атомарный INCR-с-истечением — иначе два запроса оба видят токены и перерасходуют потолок.

Fail-open против fail-closed: когда общее хранилище недоступно, fail-open пропускает трафик (доступность важнее защиты), а fail-closed закрывает origin (защита важнее доступности). Правильный дефолт зависит от того, охраняет ли лимитер хрупкий бэкенд или лишь сглаживает нагрузку.

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

Сделай по-сеньорски

  • Добавь вариант sliding-window-log и сравни его расход памяти с bucket при всплесках трафика.
  • Сделай fail-open vs fail-closed настраиваемым и под нагрузкой проверь, какой режим лучше защищает бэкенд при падении Redis.
  • Замени token bucket на GCRA (generic cell rate algorithm): храни единственное «теоретическое время прибытия» вместо tokens+timestamp и обоснуй, когда его более гладкое, безаллокационное шейпинг-поведение выигрывает у классического ведра.
  • Добавь справедливость по тенантам, чтобы один шумный тенант не голодал других под общим потолком — реализуй взвешенные или иерархические лимиты и проверь изоляцию под нагрузкой.
  • Вынеси лимитер на edge (CDN-воркер / API-шлюз), чтобы злоупотребляющий трафик отбивался до origin, и порассуждай о компромиссе консистентности счётчиков на каждом edge-узле против глобально координируемых.

Навыки

token bucketRedis atomicsLua scriptingHTTP 429 + Retry-After