Распределённые блокировки: SET NX, Redlock и fencing
Когда сервис разворачивается в N инстансов, локальные sync.Mutex уже не помогают: каждый процесс блокирует только сам себя, а соседний инстанс об этом не знает. Внутри одной базы конкуренцию за строку решает SELECT ... FOR UPDATE (см. уровни изоляции), но координировать нужно не только строки - ещё запуск задачи по расписанию, выбор лидера, обработку одного заказа ровно один раз.
Redis для этого подходит: он один на всех и выполняет команды по очереди. Дальше вся сложность в деталях - кто владелец лока, что будет при истёкшем TTL и почему DEL без проверки владельца однажды удалит чужой лок.
lock ограничивает конкуренцию, но не гарантирует исключительное выполнение после истечения аренды.
Почему так и что с этим делать - в разделе про fencing ниже. Если операция под lock двигает деньги или делает необратимый внешний вызов, читать его надо раньше, чем писать код.
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).
Redlock решает одну задачу: убирает зависимость от единственного мастера, чтобы падение одного Redis не приводило к двум владельцам. Он не решает и не может решить другую - ту, что разобрана в разделе про fencing: процесс с истёкшей арендой всё равно способен продолжить работу. Кворум из пяти инстансов от stop-the-world паузы не спасает.
Плюс у алгоритма есть допущение, которое легко не заметить: он опирается на ограниченный дрейф часов и на то, что задержки в сети не превышают некоторой величины. В окружении, где виртуалку могут заморозить на секунды, это допущение не выполняется.
Поэтому формулировка «нужны абсолютные гарантии - берите Zookeeper/etcd»
тоже неточная: consensus даёт надёжную выдачу аренды, но исключительность
выполнения по-прежнему требует fencing на стороне ресурса. Правильный
порядок вопросов другой: что случится, если операция выполнится дважды?
Если ничего страшного - хватит SET NX EX. Если страшное - нужен fencing
или перенос решения в транзакцию базы, а выбор между одним Redis, Redlock
и etcd влияет только на частоту таких случаев.
Fencing: чего lock не умеет в принципе
Всё, что мы делали выше - токен владельца, TTL, Lua-release, - защищает от одной вещи: от того, чтобы удалить чужой lock. От другой не защищает ничего, и это следует понимать до того, как lock попадёт в код, охраняющий деньги.
Lock ограничивает конкуренцию, но не гарантирует исключительное выполнение после истечения lease.
Разложим по шагам:
t=0 процесс A взял lock, TTL 30s
t=5 A ушёл в stop-the-world паузу / его контейнер заморозили /
он ждёт ответа от медленного API
t=30 TTL истёк, Redis удалил ключ
t=31 процесс B взял lock и начал работу
t=40 A проснулся - он всё ещё считает себя владельцем -
и пишет в тот же ресурс
В момент t=40 в критической секции два процесса. Redis ни в чём
не виноват: он выдал аренду на 30 секунд и честно её закончил. Не виноват
и код A - у него нет способа узнать, что аренда истекла, пока он не сходит
в Redis снова, а между проверкой и записью снова будет окно.
Единственное реальное лекарство - fencing token: монотонно растущее число, которое выдаётся вместе с блокировкой и которое проверяет защищаемый ресурс.
A получил lock с токеном 33 → пишет с токеном 33
B получил lock с токеном 34 → пишет с токеном 34
A проснулся, пишет с токеном 33 → хранилище отвергает: 33 < 34
Ключевое здесь - последняя строка: проверку делает не клиент, а сторона,
которую мы защищаем. Поэтому fencing применим не всегда: нужно, чтобы
у ресурса была версия или условная запись. Для PostgreSQL это
WHERE version = $1 (оптимистическая блокировка), для S3 - If-Match,
для собственного API - явный параметр версии.
Утверждение «lock ограничивает конкуренцию, но не гарантирует исключительное
выполнение после истечения аренды» проверяется прогоном, а не чтением:
examples/reliability/lease/. Там разобранная последовательность выполняется
как тест, и рядом лежит ресурс без проверки токена - на нём та же
последовательность заканчивается потерей работы B. Пока падает второй
вариант и проходит первый, известно, что защиту даёт именно проверка
на стороне ресурса, а не наличие блокировки.
- сделать операцию идемпотентной - тогда двойное выполнение безвредно, и lock нужен только для экономии ресурсов, а не для корректности (как);
- вынести решение в одну транзакцию базы -
SELECT ... FOR UPDATEилиUPDATE ... WHEREвместо внешнего lock. База даёт настоящую взаимную исключительность, потому что видит и данные, и блокировки; - согласиться с рисками и держать критическую секцию короткой, зная, что гарантии нет.
Чего делать не стоит - считать, что «взяли lock, значит одни». Разница между «конкуренция снижена» и «исключительное выполнение гарантировано» и есть та самая, из-за которой в проде появляются двойные списания.
Lock gotchas
- Долгие операции под lock - TTL может истечь раньше окончания работы. Решения: heartbeat (продление TTL), лимит длительности.
- Lock leak при панике - без
defer Releaseлок останется до TTL. - GC паузы - Go может встать на десятки миллисекунд, лок истечёт, кто-то другой зайдёт. Защита: версионирование операций (fencing token).
- Кросс-сервис lock - все участники должны использовать одну и ту же стратегию (имя ключа, TTL).
Типичные ошибки
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-кластеров.
Мини-практика
Реализуй Lock(ctx, key, ttl) / Unlock(ctx, key) на SET NX EX с uuid
владельца и освобождением через Lua-скрипт. Проверь тестом на двух горутинах,
что второй захват не проходит, пока первый держит лок, и что после истечения
TTL лок берётся заново.
Дальше - ограничение частоты запросов.