Суть Читай реальные сниппеты лимитера — пополнение token bucket, гонка Redis INCR, sliding-window counter, sorted-set log — и выбирай поведение или самый высокорычажный фикс.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
Лимитер либо корректен, либо это театр, и разница живёт в нескольких строках математики пополнения и команд Redis. Читай каждый сниппет, предсказывай его поведение под конкурентностью и выбирай фикс, который senior сделает первым.
Цель
Отработай цикл, который ты гоняешь при ревью лимитера: проследи пополнение, заметь гонку, проверь граничную математику и потянись к атомарному фиксу с общим счётчиком.
Сниппет 1 — пополнение token bucket
# Per-request, in the app process. tokens/ts are read from Redis with GET, written back with SET.def allow(key, rate, capacity, cost=1): tokens, ts = read(key) # last stored tokens + timestamp now = time.time() tokens = min(capacity, tokens + (now - ts) * rate) # refill if tokens >= cost: tokens -= cost write(key, tokens, now) # GET ... then SET (two round trips) 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) # atomic increment if n == 1: redis.expire(key, window_s) # set TTL only on first hit 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. Two fixed-window counters: this minute and the previous one.def estimate(prev_count, curr_count, elapsed_in_window_s, window_s=60): overlap = (window_s - elapsed_in_window_s) / window_s # fraction of prev window still in view return curr_count + prev_count * overlap# Example: 18s into the current minute, 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. По мере продвижения текущей минуты предыдущее окно должно весить меньше, а не больше.
Сниппет 4 — sliding-window log в Redis
def allow(key, limit, window_s): now = time.time() pipe = redis.pipeline() pipe.zremrangebyscore(key, 0, now - window_s) # drop entries older than the window pipe.zadd(key, {str(uuid4()): now}) # record this request pipe.zcard(key) # count what remains pipe.expire(key, window_s) _, _, count, _ = pipe.execute() return count <= limit
Викторина
Completed
Этот 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 на запрос. Проследи конкурентность, проверь граничную математику и затолкай всё решение в один атомарный общий шаг.