Репликация в PostgreSQL: streaming replication, logical replication и failover
В высоконагруженных микросервисах rate limiting — это не просто защита от DDoS, а инструмент управления ресурсами. Он предотвращает перегрузку сервисов, защищает от злоупотреблений API и обеспечивает справедливое распределение нагрузки между клиентами. Без rate limiting один клиент может исчерпать все ресурсы системы.
Выбор алгоритма влияет на поведение системы под нагрузкой. Рассмотрим три основных подхода и разберём, когда какой использовать.
Token Bucket: гибкость и burst-трафик
Token bucket — самый популярный алгоритм. Представьте ведро с токенами: каждый запрос забирает токен, токены пополняются с фиксированной скоростью. Если токены есть — запрос проходит, если нет — отклоняется.
Ключевое преимущество — поддержка burst-трафика. Если клиент долго не делал запросов, токены накопились, и он может отправить несколько запросов подряд.
class TokenBucket {
private tokens: number;
private lastRefill: number;
constructor(
private capacity: number,
private refillRate: number
) {
this.tokens = capacity;
this.lastRefill = Date.now();
}
tryConsume(): boolean {
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return true;
}
return false;
}
private refill(): void {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
const tokensToAdd = elapsed * this.refillRate;
this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
this.lastRefill = now;
}
}
Недостаток: сложнее предсказать пиковую нагрузку. Если у вас 1000 клиентов с capacity=100, теоретически все могут одновременно отправить 100 запросов.
Leaky Bucket: стабильность и сглаживание
Leaky bucket работает как очередь с фиксированной скоростью обработки. Запросы попадают в ведро, а вытекают с постоянной скоростью. Если ведро переполнено — новые запросы отклоняются.
Главное отличие от token bucket — выходная скорость всегда постоянна. Даже если клиент долго молчал, он не может отправить burst. Это идеально для защиты downstream-сервисов с жёсткими ограничениями по throughput.
class LeakyBucket {
private queue: number[] = [];
constructor(
private capacity: number,
private leakRate: number
) {
setInterval(() => this.leak(), 1000 / leakRate);
}
tryAdd(): boolean {
if (this.queue.length < this.capacity) {
this.queue.push(Date.now());
return true;
}
return false;
}
private leak(): void {
if (this.queue.length > 0) {
this.queue.shift();
}
}
}
Sliding Window: точность без провалов
Sliding window считает запросы в скользящем временном окне. Например, не более 100 запросов за последние 60 секунд. В отличие от fixed window (который сбрасывается каждую минуту), sliding window не создаёт провалов на границах окон.
Проблема fixed window: клиент может отправить 100 запросов в 00:00:59 и ещё 100 в 00:01:00 — формально не нарушая лимит, но создав пик в 200 запросов за секунду.
class SlidingWindow {
private requests: number[] = [];
constructor(
private limit: number,
private windowMs: number
) {}
tryRequest(): boolean {
const now = Date.now();
const cutoff = now - this.windowMs;
this.requests = this.requests.filter(time => time > cutoff);
if (this.requests.length < this.limit) {
this.requests.push(now);
return true;
}
return false;
}
}
Реализация на Redis для distributed rate limiting
В микросервисной архитектуре с несколькими инстансами нужен централизованный rate limiter. Redis — стандартный выбор благодаря атомарным операциям и TTL.
Реализация token bucket на Redis через Lua-скрипт (атомарность гарантирована):
import Redis from 'ioredis';
const redis = new Redis();
const tokenBucketScript = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now
local elapsed = now - last_refill
local tokens_to_add = elapsed * rate
tokens = math.min(capacity, tokens + tokens_to_add)
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 3600)
return 1
else
return 0
end
`;
async function rateLimitTokenBucket(
userId: string,
capacity: number,
rate: number
): Promise<boolean> {
const result = await redis.eval(
tokenBucketScript,
1,
`rate_limit:${userId}`,
capacity,
rate,
Date.now() / 1000
);
return result === 1;
}
Интеграция с Express
Для production используем express-rate-limit с Redis store:
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';
const redis = new Redis();
const limiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: 'rl:',
}),
windowMs: 60 * 1000,
max: 100,
handler: (req, res) => {
res.status(429).json({
error: 'Too many requests',
retryAfter: req.rateLimit.resetTime
});
}
});
app.use('/api/', limiter);
Подводные камни distributed rate limiting
Синхронизация между инстансами не мгновенная. Если Redis недоступен, нужна fallback-стратегия: либо пропускать все запросы (риск перегрузки), либо использовать локальный rate limiter (риск превышения лимита).
Race condition при высокой конкурентности: два инстанса одновременно читают счётчик, оба видят "99 запросов", оба пропускают — получается 101. Решение — Lua-скрипты или Redis transactions с WATCH.
Выбор ключа для rate limiting: по IP (легко обойти через прокси), по user ID (требует аутентификации), по API key (стандарт для публичных API). Для защиты от DDoS комбинируйте: грубый лимит по IP + точный по user ID.
Выводы
Token bucket — универсальный выбор для большинства API. Leaky bucket — когда критична стабильная нагрузка на backend. Sliding window — для точного контроля без провалов. В production используйте Redis с Lua-скриптами для атомарности и готовые библиотеки вроде express-rate-limit. Логируйте rate limit hits и анализируйте паттерны — возможно, лимиты слишком жёсткие или клиент ведёт себя подозрительно.