
Реклама выстрелила, на сайт пришёл пик трафика, а следом — ошибки оплаты и таймауты. Боты ломятся в API из-за промокода, и законопослушные покупатели получают 429. Интеграция партнёра даёт «бурст» из десятков тысяч запросов и сжигает ваш бюджет в облаке. Всё это симптомы одной проблемы: система не умеет дозировать входящий поток и защищать критические части.
Что получает бизнес от грамотных лимитов и защиты от перегруза:
Прежде чем ставить лимиты, приложим «базовую гигиену» устойчивости:
Как выбрать:
Идеально — многоуровневая защита:
# 10 запросов в секунду на IP, с всплеском до 20 без задержки
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conns:10m;
server {
listen 80;
server_name api.example.com;
location /api/ {
limit_req zone=perip burst=20 nodelay;
limit_conn conns 50; # не больше 50 одновременных соединений с одного IP
# Маршруты можно разделять по «дороговизне»
if ($request_uri ~* "/api/reports") {
limit_req zone=perip burst=5 nodelay; # отчёты — дороже, сжимаем сильнее
}
proxy_read_timeout 5s;
proxy_connect_timeout 1s;
proxy_pass http://app_backend;
}
}
Скрипт Lua для token bucket:
-- KEYS[1] = ключ ведра, например rl:{client}
-- ARGV[1] = ёмкость ведра (capacity)
-- ARGV[2] = скорость пополнения (tokens_per_second)
-- ARGV[3] = текущее время в миллисекундах (now_ms)
-- Возвращает: {allowed (0/1), tokens_left, retry_after_ms}
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if tokens == nil then
tokens = capacity
ts = now
else
if now > ts then
local delta = (now - ts) / 1000.0
local filled = math.floor(delta * rate)
if filled > 0 then
tokens = math.min(capacity, tokens + filled)
ts = ts + filled * 1000 / rate
end
end
end
local allowed = 0
local retry_after = 0
if tokens > 0 then
allowed = 1
tokens = tokens - 1
else
allowed = 0
local needed = 1 - tokens
retry_after = math.ceil(needed * 1000 / rate)
end
redis.call('HMSET', key, 'tokens', tokens, 'ts', ts)
redis.call('PEXPIRE', key, math.max(2000, math.ceil(1000 * capacity / rate)))
return {allowed, tokens, retry_after}
Мидлварь для Node.js (Express) с ioredis:
// npm i express ioredis
const express = require('express');
const Redis = require('ioredis');
const crypto = require('crypto');
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
const app = express();
// Загрузим Lua-скрипт и запомним SHA
const luaScript = `
-- KEYS[1] = ключ ведра, например rl:{client}
-- ARGV[1] = ёмкость ведра (capacity)
-- ARGV[2] = скорость пополнения (tokens_per_second)
-- ARGV[3] = текущее время в миллисекундах (now_ms)
-- Возвращает: {allowed (0/1), tokens_left, retry_after_ms}
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if tokens == nil then
tokens = capacity
ts = now
else
if now > ts then
local delta = (now - ts) / 1000.0
local filled = math.floor(delta * rate)
if filled > 0 then
tokens = math.min(capacity, tokens + filled)
ts = ts + filled * 1000 / rate
end
end
end
local allowed = 0
local retry_after = 0
if tokens > 0 then
allowed = 1
tokens = tokens - 1
else
allowed = 0
local needed = 1 - tokens
retry_after = math.ceil(needed * 1000 / rate)
end
redis.call('HMSET', key, 'tokens', tokens, 'ts', ts)
redis.call('PEXPIRE', key, math.max(2000, math.ceil(1000 * capacity / rate)))
return {allowed, tokens, retry_after}
`;
let scriptSha;
redis.script('LOAD', luaScript).then(sha => { scriptSha = sha; });
function hashId(id) {
return crypto.createHash('sha1').update(String(id)).digest('hex').slice(0, 12);
}
function rateLimit({ capacity, rate, keyFn }) {
return async (req, res, next) => {
try {
const id = keyFn(req);
const key = `rl:${id}`;
const now = Date.now();
const resp = await redis.evalsha(
scriptSha,
1,
key,
String(capacity),
String(rate),
String(now)
);
const allowed = Number(resp[0]);
const retryAfterMs = Number(resp[2]);
if (!allowed) {
res.set('Retry-After', Math.ceil(retryAfterMs / 1000));
return res.status(429).json({ error: 'Too Many Requests' });
}
return next();
} catch (e) {
// На сбое Redis не душим легитимный трафик, но логируем
console.error('rate limit error', e);
return next();
}
};
}
// Лимит по пользователю, берём из токена/заголовка, иначе — по IP
const userLimiter = rateLimit({
capacity: 30, // до 30 запросов «запаса»
rate: 10, // 10 запросов в секунду в среднем
keyFn: (req) => hashId(req.header('X-Client-Id') || req.ip)
});
// «Дорогой» маршрут — отчёты: жёстче
const reportsLimiter = rateLimit({
capacity: 5,
rate: 2,
keyFn: (req) => 'reports:' + hashId(req.header('X-Client-Id') || req.ip)
});
app.use('/api', userLimiter);
app.get('/api/ping', (req, res) => res.json({ ok: true }));
app.get('/api/reports', reportsLimiter, async (req, res) => {
// Дорогая операция
await new Promise(r => setTimeout(r, 150));
res.json({ status: 'report queued' });
});
app.listen(3000, () => console.log('API on :3000'));
Не все запросы равны:
Что делаем:
Итог — при перегрузе падает не весь сайт, а только второстепенные части. Выручка не страдает.
Статичный лимит хорош как старт. Дальше — адаптив:
Простейший алгоритм (каждые 10–30 секунд):
Работает как «круиз-контроль», удерживая систему в рабочей зоне без больших колебаний.
Опубликуйте SLO по ключевым сценариям: «p95 оплаты < 1200 мс, ошибки < 0.5%». Лимиты и отбрасывание нагрузки должны служить этим целям.
Пример скрипта k6 для проверки лимитов:
import http from 'k6/http';
import { sleep, check } from 'k6';
export const options = {
stages: [
{ duration: '10s', target: 50 },
{ duration: '20s', target: 200 },
{ duration: '10s', target: 0 },
],
};
export default function () {
const res = http.get('http://localhost:3000/api/reports', {
headers: { 'X-Client-Id': 'loadtest' },
});
check(res, {
'ok or limited': (r) => [200, 429].includes(r.status),
});
sleep(0.2);
}
Внедрив многоуровневые лимиты, приоритезацию и метрики, вы перестаёте бояться промо, PR-акций и ботов. Система работает ровно настолько, насколько вы ей позволяете, — и это экономит деньги и нервы.