Защита API от чрезмерного количества запросов — критически важная задача для обеспечения стабильности и доступности сервиса. Без эффективного механизма ограничения запросов (rate limiting) злонамеренный клиент или даже просто активный пользователь может отправить десятки тысяч запросов в секунду, что приведёт к перегрузке базы данных и отказу системы. Redis, благодаря своей производительности и атомарным операциям, является идеальным инструментом для реализации таких механизмов.
Алгоритм Token Bucket на основе Redis Sorted Sets
Одним из наиболее популярных подходов является алгоритм Token Bucket, реализованный с использованием Redis Sorted Sets. Этот метод позволяет эффективно отслеживать количество запросов в заданном временном окне.
import Redis from "ioredis";
const RATE_LIMIT_SCRIPT = `
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
redis.call("ZREMRANGEBYSCORE", key, 0, now - window)
local count = redis.call("ZCARD", key)
if count >= limit then
return 0
end
redis.call("ZADD", key, now, now .. math.random())
redis.call("EXPIRE", key, window / 1000)
return 1
`;
async function checkRateLimit(redis: Redis, clientId: string, limit: number, windowMs: number): Promise<boolean> {
const key = `rate:${clientId}`;
const allowed = await redis.eval(RATE_LIMIT_SCRIPT, 1, key, limit, windowMs, Date.now());
return allowed === 1;
}
В этом подходе каждый запрос клиента добавляется в Sorted Set с меткой времени в качестве score. Перед проверкой лимита удаляются все записи, выходящие за пределы временного окна. Затем проверяется количество оставшихся запросов. Lua-скрипт обеспечивает атомарность операции, что исключает состояние гонки между параллельными запросами.
Практическая реализация в Express.js
Для интеграции механизма ограничения запросов в Node.js приложение на Express.js можно создать middleware, который будет проверять лимиты для каждого входящего запроса.
function rateLimiter(limit = 100, windowMs = 60000) {
return async (req: Request, res: Response, next: NextFunction) => {
const clientId = req.ip;
const allowed = await checkRateLimit(redis, clientId, limit, windowMs);
if (!allowed) {
res.set("Retry-After", String(Math.ceil(windowMs / 1000)));
return res.status(429).json({ error: "Too many requests" });
}
next();
};
}
Этот middleware можно применять с разными параметрами для различных маршрутов, что позволяет реализовать гибкую политику ограничений:
// Разные лимиты для разных маршрутов
app.use("/api/auth", rateLimiter(10, 60000)); // 10 запросов в минуту для аутентификации
app.use("/api/search", rateLimiter(30, 60000)); // 30 запросов в минуту для поиска
app.use("/api", rateLimiter(100, 60000)); // 100 запросов в минуту по умолчанию
Алгоритм Sliding Window как альтернатива
В отличие от Token Bucket, алгоритм Sliding Window обеспечивает более точный контроль, так как временное окно "скользит" вместе с запросами. Реализация также использует Redis Sorted Sets, но с другим подходом к вычислению доступных запросов.
Основное отличие заключается в том, что при использовании Sliding Window лимит применяется к любому скользящему интервалу заданной длины, а не к фиксированным временным блокам. Это предотвращает возможность "накопления" запросов в начале каждого периода, что может происходить при использовании некоторых реализаций Token Bucket.
Информативные заголовки ответов
Важным аспектом реализации ограничения запросов является предоставление клиентам информации об их текущих лимитах через HTTP-заголовки. Это позволяет клиентским приложениям самостоятельно регулировать частоту запросов и корректно обрабатывать ситуации превышения лимитов.
X-RateLimit-Limit: Максимальное количество разрешённых запросов
X-RateLimit-Remaining: Оставшееся количество запросов в текущем окне
X-RateLimit-Reset: Unix timestamp времени сброса окна
Retry-After: Количество секунд ожидания (при ответе 429)
Заголовок Retry-After особенно важен при возврате статуса 429 (Too Many Requests), так как он явно указывает клиенту, когда можно повторить запрос.
Перклиентские лимиты и масштабирование
Использование Redis позволяет легко реализовать перклиентские лимиты, где каждый клиент (определяемый по IP, API-ключу или другому идентификатору) имеет собственный счётчик запросов. Ключ в Redis формируется на основе идентификатора клиента, что обеспечивает изоляцию лимитов между разными пользователями.
Для распределённых систем, где несколько экземпляров приложения обрабатывают запросы, Redis выступает в роли единого источника истины для подсчёта запросов. Это гарантирует, что лимиты применяются ко всем запросам клиента независимо от того, на какой сервер они попадают.
При проектировании системы ограничения запросов важно учитывать возможность масштабирования. Слишком агрессивные лимиты могут ухудшить пользовательский опыт, тогда как слишком мягкие — не обеспечат достаточной защиты. Оптимальным подходом является многоуровневая стратегия с разными лимитами для различных типов операций и пользователей.