backend · intermediate · 4d
Распределённый rate limiter
Собери token-bucket лимитер, который держится поперёк многих инстансов приложения за счёт счётчика в Redis, а не в памяти процесса.
Результат
Middleware, который держит N запросов/окно на ключ, отдаёт 429 с Retry-After и остаётся корректным под конкурентной нагрузкой.
Этапы
0/6 · 0%- 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` корректно сдвигается и на отклонённом запросе.
- 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) всё ещё недостаточно без атомарного вычисления.
- 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), а время инжектится, а не читается.
- 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 на активный ключ.
- 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-ревьюер хочет, чтобы ты защитил выбранный компромисс, а не уклонился от него.
- 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
Распакуй, реализуй заглушки, затем гоняй тесты, пока не позеленеют: 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-узле против глобально координируемых.