Читай реальные сниппеты лимитера — пополнение token bucket, гонка Redis INCR, sliding-window counter, sorted-set log — и выбирай поведение или самый высокорычажный фикс.
APISenior◷ 14 min
Уровень
ОсновыJuniorMiddleSenior
Лимитер либо корректен, либо это театр, и разница живёт в нескольких строках математики пополнения и команд Redis. Читай каждый сниппет, предсказывай его поведение под конкурентностью и выбирай фикс, который senior сделает первым.
Цель
Отработай цикл, который ты гоняешь при ревью лимитера: проследи пополнение, заметь гонку, проверь граничную математику и потянись к атомарному фиксу с общим счётчиком.
Сниппет 1 — пополнение token bucket
# На каждый запрос, в процессе приложения. tokens/ts читаются из Redis через GET, пишутся обратно через SET.def allow(key, rate, capacity, cost=1): tokens, ts = read(key) # последние сохранённые токены + временная метка now = time.time() tokens = min(capacity, tokens + (now - ts) * rate) # пополнение if tokens >= cost: tokens -= cost write(key, tokens, now) # GET ... затем SET (два round-trip) return True write(key, tokens, now) return False
Викторина
Completed
Математика пополнения изолированно верна. Что ломается, когда два запроса по одному ключу приходят конкурентно на двух app-нодах?
Heads-up min(capacity, ...) ограничивает пополнение, поэтому долгий простой просто доливает ведро до capacity. Реальный дефект — неатомарный read-modify-write, гоняющий между нодами.
Heads-up Малый перекос часов слегка возмущает пополнение, но это не корневой баг. Корневой баг — что два конкурентных цикла GET-затем-SET переплетаются и дважды тратят токен, независимо от часов.
Heads-up Redis сериализует каждую отдельную команду, но GET и SET здесь — два отдельных round trip с логикой приложения между ними. Другой запрос может прочитать устаревшее значение до того, как этот запишет — этот зазор и есть гонка.
Сниппет 2 — Redis fixed-window счётчик
def allow(key, limit, window_s): n = redis.incr(key) # атомарный инкремент if n == 1: redis.expire(key, window_s) # устанавливаем TTL только при первом обращении return n <= limit
Викторина
Completed
INCR атомарен, так в чём латентный сбой этого fixed-window лимитера?
Heads-up INCR полностью атомарен — эта часть в порядке. Баг — зазор между INCR и отдельным EXPIRE, который может оставить ключ без TTL навсегда.
Heads-up n <= limit корректно пропускает ровно limit запросов (первый INCR возвращает 1). Off-by-one не дефект; дефект — гонка с отсутствующим TTL.
Heads-up Он протекает 2x граничным всплеском, но это присуще fixed window, а не баг в этом коде. Дефект уровня кода — зазор атомарности INCR/EXPIRE, способный застрять ключ без TTL.
Сниппет 3 — sliding-window counter
# limit = 100 / 60s. Два fixed-window счётчика: текущая минута и предыдущая.def estimate(prev_count, curr_count, elapsed_in_window_s, window_s=60): overlap = (window_s - elapsed_in_window_s) / window_s # доля предыдущего окна, ещё видимая return curr_count + prev_count * overlap# Пример: 18s внутри текущей минуты, prev=80, curr=12
Викторина
Completed
Для значений примера (18s внутри, prev=80, curr=12, limit=100) что вернёт estimate и пропущен ли запрос?
Heads-up Добавление prev целиком игнорирует взвешивание по overlap, в котором весь смысл. Предыдущее окно вносит лишь долю, ещё внутри скользящего вида: 80*0.70 = 56, а не 80.
Heads-up Сброс prev целиком — это снова fixed window и снова вводит граничный всплеск. Точность counter'а — от взвешивания предыдущего окна остаточным overlap.
Heads-up Overlap — доля предыдущего окна, ещё в хвостовом виде, то есть (60-18)/60 = 0.70, а не 18/60. По мере продвижения текущей минуты предыдущее окно должно весить меньше, а не больше.
Этот log-лимитер точен и атомарен (пайплайн MULTI/EXEC). Какая реальная цена, на которую ты подписываешься, и тонкое поведение этого порядка?
Heads-up ZREMRANGEBYSCORE детерминированно сбрасывает всё старше now - window, поэтому счёт точен на скользящем окне. Цена — память на запрос, а не точность.
Heads-up Члены должны быть уникальны, иначе два запроса с одним timestamp слились бы в одну запись. UUID (или timestamp плюс счётчик) корректен именно чтобы избежать этого столкновения.
Heads-up Пайплайн Redis MULTI/EXEC выполняет команды атомарно без interleaving, поэтому set консистентен. Цена — память на запрос в log, поэтому counter предпочтительнее на масштабе.
Итог
Баги лимитера живут в нескольких строках: read-modify-write на стороне приложения дважды тратит токен между нодами, поэтому проверка должна выполняться атомарно внутри Redis; INCR-затем-EXPIRE может застрять ключ без TTL навсегда; sliding-window counter взвешивает предыдущее окно остаточным overlap, а не полностью; а sliding-window log точен и атомарен, но платит одной записью sorted set на запрос. Проследи конкурентность, проверь граничную математику и затолкай всё решение в один атомарный общий шаг.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.