Распределённые блокировки и 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.

Ключевые моменты:

  1. SET NX - атомарная установка только если ключа нет (set if not exists)
  2. TTL - обязательно. Без TTL после краха процесса lock останется навсегда (deadlock)
  3. token (UUID) - защита от ситуации, когда вы дёрнули lock, его TTL истёк, кто-то другой захватил, а вы потом DEL - удалите чужой
  4. Lua script для release - атомарность compare-and-delete: в одном Redis-вызове проверить владение и удалить

Redlock - для нескольких инстансов Redis

SET NX надёжен только при одном Redis. При репликации master-slave есть гонка: master подтвердил SET, упал до репликации, slave стал master без lock-ключа - два процесса считают себя владельцами.

Алгоритм Redlock (Antirez):

  1. Получаем текущее время T1
  2. Пытаемся захватить lock на N (обычно 5) независимых Redis-инстансах
  3. Lock считается захваченным, если ≥ N/2+1 успешных и время прошло меньше TTL
  4. При 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).

Martin Kleppmann критиковал Redlock, Antirez отвечал. Если нужны абсолютные гарантии - берите Zookeeper/etcd с consensus-алгоритмом. Redlock - компромисс между простотой и надёжностью, для большинства задач достаточно.

Lock gotchas

  1. Долгие операции под lock - TTL может истечь раньше окончания работы. Решения: heartbeat (продление TTL), лимит длительности.
  2. Lock leak при панике - без defer Release лок останется до TTL.
  3. GC паузы - Go может встать на десятки миллисекунд, лок истечёт, кто-то другой зайдёт. Защита: версионирование операций (fencing token).
  4. Кросс-сервис 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 для критичной операции (например, обновление баланса). Покрой тестами с симуляцией параллельных запросов.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.