Распределённые блокировки и rate limiting
Когда сервис разворачивается в N инстансов, локальные sync.Mutex уже не помогают. Внутри одной базы конкуренцию за строку решает SELECT ... FOR UPDATE (см. уровни изоляции), но координировать нужно не только строки. Redis координирует работу всего кластера: distributed locks, rate limiting, leader election, очередь задач.
Distributed Lock через SET NX
func AcquireLock(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (string, error) {
token := uuid.New().String()
ok, err := rdb.SetNX(ctx, "lock:"+key, token, ttl).Result()
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("lock already held")
}
return token, nil
}
func ReleaseLock(ctx context.Context, rdb *redis.Client, key, token string) error {
script := redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
`)
result, err := script.Run(ctx, rdb, []string{"lock:" + key}, token).Int()
if err != nil {
return err
}
if result == 0 {
return fmt.Errorf("lock not held or expired")
}
return nil
}
<?php
declare(strict_types=1);
use Predis\Client;
use Symfony\Component\Uid\Uuid;
final readonly class DistributedLock
{
public function __construct(private Client $redis) {}
/**
* @return string token владения - нужен для release
* @throws \RuntimeException если lock уже занят
*/
public function acquire(string $key, int $ttlSeconds): string
{
$token = Uuid::v4()->toRfc4122();
$ok = $this->redis->set('lock:' . $key, $token, 'EX', $ttlSeconds, 'NX');
if ($ok === null) {
throw new \RuntimeException('lock already held: ' . $key);
}
return $token;
}
public function release(string $key, string $token): void
{
$script = <<<'LUA'
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
LUA;
$result = (int) $this->redis->eval($script, 1, 'lock:' . $key, $token);
if ($result === 0) {
throw new \RuntimeException('lock not held or expired: ' . $key);
}
}
}
Это «учебный» вариант для понимания механики. В реальном Symfony-коде бери symfony/lock с RedisStore - там уже всё это сделано, плюс auto-release через defer-эквивалент try/finally.
Ключевые моменты:
- SET NX - атомарная установка только если ключа нет (set if not exists)
- TTL - обязательно. Без TTL после краха процесса lock останется навсегда (deadlock)
- token (UUID) - защита от ситуации, когда вы дёрнули lock, его TTL истёк, кто-то другой захватил, а вы потом DEL - удалите чужой
- Lua script для release - атомарность compare-and-delete: в одном Redis-вызове проверить владение и удалить
Redlock - для нескольких инстансов Redis
SET NX надёжен только при одном Redis. При репликации master-slave есть гонка: master подтвердил SET, упал до репликации, slave стал master без lock-ключа - два процесса считают себя владельцами.
Алгоритм Redlock (Antirez):
- Получаем текущее время T1
- Пытаемся захватить lock на N (обычно 5) независимых Redis-инстансах
- Lock считается захваченным, если ≥ N/2+1 успешных и время прошло меньше TTL
- При release - отправляем DEL на все инстансы
import "github.com/bsm/redislock"
locker := redislock.New(rdb)
lock, err := locker.Obtain(ctx, "my-lock", 100*time.Millisecond,
&redislock.Options{
RetryStrategy: redislock.LinearBackoff(50 * time.Millisecond),
})
if err != nil {
return err
}
defer lock.Release(ctx)
<?php
declare(strict_types=1);
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\RedisStore;
use Symfony\Component\Lock\Exception\LockConflictedException;
$store = new RedisStore($redis);
$factory = new LockFactory($store);
$lock = $factory->createLock(resource: 'my-lock', ttl: 0.1); // 100ms
try {
$lock->acquire(blocking: true); // ждёт до получения, с автоматическим retry
// критическая секция
} catch (LockConflictedException) {
// блокировка не получена
return;
} finally {
$lock->release();
}
Для Redlock на нескольких независимых Redis - используй Symfony\Component\Lock\Store\RedisStore со списком подключений или Symfony\Component\Lock\Store\CombinedStore (объединяет несколько RedisStore с consensus quorum).
Lock gotchas
- Долгие операции под lock - TTL может истечь раньше окончания работы. Решения: heartbeat (продление TTL), лимит длительности.
- Lock leak при панике - без
defer Releaseлок останется до TTL. - GC паузы - Go может встать на десятки миллисекунд, лок истечёт, кто-то другой зайдёт. Защита: версионирование операций (fencing token).
- Кросс-сервис lock - все участники должны использовать одну и ту же стратегию (имя ключа, TTL).
Rate limiting: подходы
Fixed Window
key := fmt.Sprintf("ratelimit:%s:%d", userID, time.Now().Unix()/60)
count, _ := rdb.Incr(ctx, key).Result()
rdb.Expire(ctx, key, time.Minute)
if count > 60 { /* 429 Too Many Requests */ }
<?php
declare(strict_types=1);
$window = (int) floor(time() / 60);
$key = sprintf('ratelimit:%s:%d', $userId, $window);
$count = $redis->incr($key);
if ($count === 1) {
$redis->expire($key, 60);
}
if ($count > 60) {
throw new TooManyRequestsException();
}
Простой: счётчик в окне (минута). Проблема: на границе окон можно получить 2x лимит (последние 30 секунд предыдущего + первые 30 нового).
Sliding Window через Sorted Set
func IsAllowed(ctx context.Context, rdb *redis.Client, key string, limit int, window time.Duration) (bool, error) {
now := time.Now().UnixMilli()
pipe := rdb.Pipeline()
pipe.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", now-window.Milliseconds()))
pipe.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: fmt.Sprintf("%d", now)})
countCmd := pipe.ZCard(ctx, key)
pipe.Expire(ctx, key, window)
if _, err := pipe.Exec(ctx); err != nil {
return false, err
}
return countCmd.Val() <= int64(limit), nil
}
<?php
declare(strict_types=1);
final readonly class SlidingWindowRateLimiter
{
public function __construct(
private Predis\Client $redis,
private int $limit,
private int $windowSeconds,
) {}
public function isAllowed(string $key): bool
{
$now = (int) (microtime(true) * 1000);
$windowMs = $this->windowSeconds * 1000;
$responses = $this->redis->pipeline(function ($pipe) use ($key, $now, $windowMs): void {
$pipe->zremrangebyscore($key, '0', (string) ($now - $windowMs));
$pipe->zadd($key, [(string) $now => $now]);
$pipe->zcard($key);
$pipe->expire($key, $this->windowSeconds);
});
$count = (int) $responses[2];
return $count <= $this->limit;
}
}
В продакшене - symfony/rate-limiter с sliding_window policy и RedisStorage: даёт тот же sliding window + готовые X-RateLimit-* headers и обёртки для HTTP-firewall:
# config/packages/rate_limiter.yaml
framework:
rate_limiter:
api:
policy: 'sliding_window'
limit: 60
interval: '1 minute'
storage_service: 'limiter.storage.redis'
Точное окно: «не более N запросов за последние M секунд». Дороже по памяти (хранит timestamp каждого запроса), но даёт ровный rate без burst на границах.
Token Bucket
Bucket с ёмкостью N токенов, пополняется с rate R/sec. Каждый запрос забирает токен. Burst разрешён до ёмкости bucket.
-- Lua script для атомарного refill+consume
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local data = redis.call("HMGET", key, "tokens", "ts")
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
local elapsed = (now - ts) / 1000.0
tokens = math.min(capacity, tokens + elapsed * rate)
if tokens < requested then
return 0
end
tokens = tokens - requested
redis.call("HMSET", key, "tokens", tokens, "ts", now)
redis.call("EXPIRE", key, math.ceil(capacity / rate))
return 1
Token bucket любим за burst-friendliness: позволяет быстрые всплески активности при долгосрочном среднем под лимитом.
<?php
declare(strict_types=1);
final readonly class TokenBucketLimiter
{
public function __construct(
private Predis\Client $redis,
private float $rate, // токенов/сек
private int $capacity, // макс. ёмкость bucket
) {}
public function consume(string $key, int $requested = 1): bool
{
$script = <<<'LUA'
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local data = redis.call("HMGET", key, "tokens", "ts")
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
local elapsed = (now - ts) / 1000.0
tokens = math.min(capacity, tokens + elapsed * rate)
if tokens < requested then
return 0
end
tokens = tokens - requested
redis.call("HMSET", key, "tokens", tokens, "ts", now)
redis.call("EXPIRE", key, math.ceil(capacity / rate))
return 1
LUA;
$now = (int) (microtime(true) * 1000);
$result = (int) $this->redis->eval(
$script, 1, $key,
(string) $this->rate, (string) $this->capacity, (string) $now, (string) $requested,
);
return $result === 1;
}
}
В Symfony готовый token bucket - symfony/rate-limiter с policy: token_bucket. Симметрично sliding window, только в YAML меняется один параметр.
Lua scripts: атомарность нескольких команд
Lua-скрипт в Redis выполняется атомарно - никаких других команд между его шагами. Используется когда:
- Нужна condition + action атомарно (compare-and-swap)
- Нужно много операций без сетевых RTT
- Нужна транзакция со сложной логикой
script := redis.NewScript(`
local current = redis.call("GET", KEYS[1])
if current == ARGV[1] then
return redis.call("SET", KEYS[1], ARGV[2])
end
return nil
`)
// CAS: установить новое значение только если текущее = expected
result, err := script.Run(ctx, rdb, []string{"key"}, expected, newValue).Result()
<?php
declare(strict_types=1);
$script = <<<'LUA'
local current = redis.call("GET", KEYS[1])
if current == ARGV[1] then
return redis.call("SET", KEYS[1], ARGV[2])
end
return nil
LUA;
// CAS: установить новое значение только если текущее = expected
// В predis: eval($script, numKeys, ...keysAndArgs)
$result = $redis->eval($script, 1, 'key', $expected, $newValue);
В phpredis эквивалент - $redis->eval($script, [$expected, $newValue, 'key'], 1) (порядок аргументов: ключи и значения в одном массиве, последний параметр - число ключей). Для повторного запуска скрипта можно использовать evalsha() после script load - Redis закеширует bytecode по SHA1.
Возврат limits клиенту
REST best practice - заголовки X-RateLimit-*:
w.Header().Set("X-RateLimit-Limit", "60")
w.Header().Set("X-RateLimit-Remaining", "47")
w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(resetTime, 10))
if !allowed {
w.Header().Set("Retry-After", "30")
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
}
<?php
declare(strict_types=1);
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RateLimiter\RateLimiterFactory;
final readonly class RateLimitController
{
public function __construct(private RateLimiterFactory $apiLimiter) {}
public function __invoke(Request $request): Response
{
$limit = $this->apiLimiter->create($request->getClientIp())->consume(1);
$headers = [
'X-RateLimit-Limit' => (string) $limit->getLimit(),
'X-RateLimit-Remaining' => (string) $limit->getRemainingTokens(),
'X-RateLimit-Reset' => (string) $limit->getRetryAfter()->getTimestamp(),
];
if (!$limit->isAccepted()) {
$headers['Retry-After'] = (string) $limit->getRetryAfter()->getTimestamp();
return new Response('rate limit exceeded', Response::HTTP_TOO_MANY_REQUESTS, $headers);
}
return new Response('ok', Response::HTTP_OK, $headers);
}
}
symfony/rate-limiter отдаёт RateLimit объект - оттуда уже готовые getLimit(), getRemainingTokens(), getRetryAfter(). Не считай заголовки руками.
Это позволяет клиенту корректно бэкоффиться и не плодить retry-storm.
Типичные ошибки
SET key val NXбез TTL - процесс упал/завис → ключ висит вечно, никто не может взять lock. ВсегдаSET key val NX EX <ttl>атомарно одной командой, неSET NX+EXPIRE(между ними может упасть и lock останется без TTL).DEL keyдля release без проверки владельца - твой TTL истёк, lock взял другой процесс, ты завершил работу и удалил его lock. Запоминайuuidвладельца в значении и удаляй через Lua-скриптif GET == myUUID then DEL.- Lock как замена транзакции - критическая секция занимает дольше TTL. К концу работы lock уже не твой, два процесса работают одновременно с одними данными. Лекарство - короткие критические секции или lock с watchdog-renewal (
PEXPIREкаждые TTL/3). - Redlock на одном Redis-инстансе - Redlock работает только при 3-5 независимых мастерах. На одном инстансе он просто эквивалентен
SET NX EX+ сложности кода. Используй Redlock когда у тебя реально несколько Redis-кластеров. - Rate limit per-IP без X-Forwarded-For - за CDN/прокси все запросы идут с одного IP балансера, лимит срабатывает на всех пользователей. Бери
X-Forwarded-For/CF-Connecting-IP(с валидацией доверенного прокси), неRemoteAddr. - Token bucket из 3 команд (
GET,DECR,EXPIRE) - между ними race: два запроса увидят одинаковый бюджет и оба пройдут. Делай атомарно:INCR+EXPIREесли первый или Lua-скрипт на token bucket. Retry-Afterзабыт в 429-ответе - клиенты не знают, когда повторять, отвечают через 100ms и наматывают retry-storm. Всегда возвращайRetry-After: <seconds>+X-RateLimit-Reset: <unix>.
Мини-практика
Реализуй HTTP middleware для rate limiting: 60 запросов/минуту на IP через sliding window. Возвращай X-RateLimit-Remaining и Retry-After. Добавь distributed lock через redislock для критичной операции (например, обновление баланса). Покрой тестами с симуляцией параллельных запросов.