HardПрактика12 min

Распределённые локи

Redis SETNX + Lua, Redlock critique, etcd lease, ZooKeeper ephemeral, PostgreSQL advisory locks, fencing tokens

Зачем

В кластере N worker'ов запускается cron. Без координации все N одновременно попытаются выполнить задачу. Последствия: лишняя нагрузка, дубли, race conditions.

Распределённый лок -- механизм, который гарантирует, что только один процесс имеет доступ к ресурсу.

Требования к хорошему локу

  1. Mutual exclusion: в любой момент времени максимум один holder
  2. Deadlock-free: если holder упал -- лок освобождается
  3. Fault-tolerant: сервис локов может потерять узел
  4. Fairness (опционально): очередь в порядке запросов
  5. Re-entrant (опционально): один и тот же процесс может захватывать лок повторно

Варианты реализации

Сервис Надёжность Latency Fencing Сложность
Redis SETNX (single) низкая 1 ms ручной низкая
Redlock (Redis N nodes) средняя (спорно) 10 ms ручной средняя
etcd lease высокая (Raft) 5-20 ms revision средняя
ZooKeeper ephemeral высокая (ZAB) 5-20 ms zxid высокая
PostgreSQL advisory высокая (если PG HA) 2 ms нет низкая

Redis SETNX + Lua release

Базовый рецепт: SET key value NX PX ttl. Успех = лок наш. TTL защищает от deadlock (холдер умер).

Почему нужен Lua для release

Наивный release:

GET lock:key -> "owner123"    # ok, we own it
# ... BUT if TTL expires right now, another process acquires lock
DEL lock:key                   # we deleted SOMEONE ELSE's lock!

Решение: atomic check-and-delete через Lua:

if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
end
return 0

Реализация

<?php

declare(strict_types=1);

namespace App\Lock;

/**
 * Redis-backed distributed lock.
 *
 * Gotchas:
 *  - Not safe in absence of fencing tokens if holder pauses > TTL.
 *  - Assumes a single Redis primary; failover can lose the lock.
 */
final class RedisLock
{
    public function __construct(
        private readonly \Redis $redis,
        private readonly int $defaultTtlMs = 30_000,
    ) {}

    /**
     * Try to acquire a lock. Returns lock handle or null.
     */
    public function acquire(string $resource, ?int $ttlMs = null): ?LockHandle
    {
        $ttl = $ttlMs ?? $this->defaultTtlMs;
        $token = bin2hex(random_bytes(16));
        $key = "lock:$resource";

        $ok = $this->redis->set($key, $token, ['NX', 'PX' => $ttl]);
        if (!$ok) {
            return null;
        }

        // Fencing token: monotonically increasing counter
        $fence = $this->redis->incr('lock:fence:' . $resource);

        return new LockHandle($this->redis, $key, $token, $fence);
    }

    /**
     * Block until lock is acquired or timeout elapsed.
     */
    public function acquireBlocking(string $resource, int $waitMs, ?int $ttlMs = null): ?LockHandle
    {
        $deadline = microtime(true) + $waitMs / 1000;
        $backoff = 10;
        do {
            $h = $this->acquire($resource, $ttlMs);
            if ($h !== null) {
                return $h;
            }
            usleep($backoff * 1000 + random_int(0, $backoff * 1000));
            $backoff = min($backoff * 2, 200);
        } while (microtime(true) < $deadline);

        return null;
    }
}

final class LockHandle
{
    public function __construct(
        private readonly \Redis $redis,
        public readonly string $key,
        public readonly string $token,
        public readonly int $fencingToken,
    ) {}

    /**
     * Atomic release: only delete if we still own the lock.
     */
    public function release(): bool
    {
        $script = <<<'LUA'
            if redis.call('GET', KEYS[1]) == ARGV[1] then
                return redis.call('DEL', KEYS[1])
            end
            return 0
        LUA;
        return (bool) $this->redis->eval($script, [$this->key, $this->token], 1);
    }

    /**
     * Extend TTL (keep lock alive for long-running operations).
     */
    public function extend(int $ttlMs): bool
    {
        $script = <<<'LUA'
            if redis.call('GET', KEYS[1]) == ARGV[1] then
                return redis.call('PEXPIRE', KEYS[1], ARGV[2])
            end
            return 0
        LUA;
        return (bool) $this->redis->eval(
            $script,
            [$this->key, $this->token, (string) $ttlMs],
            1,
        );
    }
}

// Usage:
// $lock = new RedisLock($redis);
// $h = $lock->acquireBlocking('cron:nightly-report', waitMs: 5000, ttlMs: 60_000);
// if ($h === null) { return; }
// try {
//     runReport(fencingToken: $h->fencingToken);
// } finally {
//     $h->release();
// }
## Fencing tokens

Проблема: lock TTL = 30 сек. Holder получил лок, начал работать. GC pause 40 сек. TTL истёк, другой процесс получил лок, начал работать. Holder приходит в себя и... продолжает писать в ресурс. Теперь их двое.

Client A:  acquire (fence=5) | -------- GC pause 40s -------- | write(data, fence=5) [STALE!]
Redis:     [A holds, TTL 30s] ... [TTL expired] [B holds, TTL 30s]
Client B:  -------- waits -------- | acquire (fence=6) | write(data, fence=6)

Fencing token -- монотонно растущий счётчик, привязанный к локу. Ресурс (БД, API) отвергает запись с меньшим fence, чем последний виденный.

Resource storage:
  last_fence = 6
  write(fence=5) -> REJECT (stale holder)
  write(fence=7) -> OK, update last_fence=7

Fencing обязателен в корректном распределённом локе. Redis сам по себе fencing не даёт -- вы должны реализовать его на стороне ресурса:

UPDATE resource
SET data = :new, last_fence = :fence
WHERE id = :id AND last_fence < :fence
RETURNING *;
-- If 0 rows updated -> stale lock holder, abort operation

Redlock: критика

Antirez предложил Redlock -- взять лок на N/2+1 из 5 Redis нод. Идея: переживает падение меньшинства.

Martin Kleppmann показал, что Redlock не безопасен в асинхронных сетях с GC pauses:

  1. Redlock не защищает от GC-паузы длиннее TTL (нужен fencing token)
  2. Полагается на примерно синхронизированные часы -- если на одной ноде skew, порядок нарушен

Выводы:

  • Для efficiency lock (не хотим дублировать работу) -- Redis достаточно
  • Для correctness lock (ресурс нельзя писать одновременно) -- нужен fencing ИЛИ etcd/ZooKeeper

etcd lease

etcd предоставляет lease -- TTL-based грант. Key автоматически удаляется при истечении lease.

lease, _ := cli.Grant(ctx, 10) // 10 sec lease
cli.Put(ctx, "my-lock", "holder-1", clientv3.WithLease(lease.ID))

// Keep alive in background
ch, _ := cli.KeepAlive(ctx, lease.ID)
go func() { for range ch {} }()

Плюсы:

  • Raft-consensus -- high availability
  • Revision -- каждое изменение ключа получает растущий revision, можно использовать как fencing token
  • Watch API -- ждать освобождения

etcd concurrency package:

import "go.etcd.io/etcd/client/v3/concurrency"

session, _ := concurrency.NewSession(cli, concurrency.WithTTL(10))
defer session.Close()

mutex := concurrency.NewMutex(session, "/my-lock/")
if err := mutex.Lock(ctx); err != nil { ... }
defer mutex.Unlock(ctx)

// Fencing token = session revision
rev := mutex.Header().Revision

ZooKeeper ephemeral nodes

ZooKeeper поддерживает ephemeral ноды -- удаляются при дисконнекте клиента. Классический recipe:

  1. Создать ephemeral sequential node: /locks/resource_00001
  2. Получить список всех детей /locks/
  3. Если моя нода -- с минимальным номером → я holder
  4. Иначе watch предыдущую по порядку ноду, ждать её удаления
  5. Goto 2

Это даёт FIFO fairness + автоматическое освобождение. zxid (ZooKeeper transaction ID) служит fencing token.

Используется в HDFS, Kafka (legacy), Solr. В PHP/Go обычно заменяют на etcd.

PostgreSQL advisory locks

Если у вас уже есть PG -- добавлять отдельный сервис локов не обязательно. PG предоставляет pg_advisory_lock:

-- Blocking (waits)
SELECT pg_advisory_lock(hashtext('cron:nightly-report'));

-- Non-blocking
SELECT pg_try_advisory_lock(hashtext('cron:nightly-report'));

-- Release
SELECT pg_advisory_unlock(hashtext('cron:nightly-report'));

-- Auto-release at session end (unlock-on-disconnect)
SELECT pg_try_advisory_xact_lock(hashtext('cron:nightly-report')); -- released on commit/rollback

Плюсы:

  • Нет новой инфраструктуры
  • Auto-release при дисконнекте клиента (как ephemeral ZK)
  • Fast, in-process locking

Минусы:

  • Lock живёт только пока connection открыт -- проблема с pooler'ами (PgBouncer в transaction mode)
  • Нет нативного fencing token (нужен свой counter)

Когда что выбирать

  Нужен correctness + fencing
  ├─ Есть etcd/ZK → использовать
  ├─ Есть только Redis → Redis + fencing на ресурсе
  └─ Есть PostgreSQL → pg_advisory_xact_lock + fence column

  Efficiency lock (не критично, если случайно дубль)
  └─ Redis SETNX + Lua достаточно

  Single-node cron coordinator
  └─ PostgreSQL advisory lock

Пример: cron в кластере

<?php

declare(strict_types=1);

final class NightlyReportCommand
{
    public function __construct(
        private readonly RedisLock $lock,
        private readonly ReportService $report,
        private readonly LoggerInterface $log,
    ) {}

    public function run(): int
    {
        $h = $this->lock->acquire('cron:nightly-report', ttlMs: 60 * 60 * 1000);
        if ($h === null) {
            $this->log->info('Another worker is running this job; exiting');
            return 0;
        }

        try {
            // Long-running job: extend lock periodically via a heartbeat thread/goroutine
            $this->report->generate(fence: $h->fencingToken);
        } finally {
            $h->release();
        }
        return 0;
    }
}
## Heartbeat / keep-alive

Для долгих операций мало одноразового TTL -- его надо продлевать:

// Extend lock every TTL/3 while work is in progress
ticker := time.NewTicker(ttl / 3)
go func() {
    for {
        select {
        case <-done:
            return
        case <-ticker.C:
            if err := handle.Extend(ctx, ttl); err != nil {
                // Lost ownership! Abort work immediately
                cancelWork()
                return
            }
        }
    }
}()

При потере лока (extend failed) -- немедленно прерывать работу. Иначе два holder'а.

Pitfalls

  • Release без проверки owner (простой DEL) -- удаляем чужой лок. Всегда через Lua compare-and-delete.
  • GC pause > TTL -- два holder'а. Lock без fencing = корректность нарушена.
  • Один Redis instance = SPOF + data loss при failover (последняя запись может не реплицироваться). Для correctness -- etcd/ZK.
  • Lock TTL слишком короткий -- мерцающие holders. Длинный TTL -- долгое восстановление после краша.
  • Нет jitter в retry -- thundering herd при попытке захватить популярный лок.
  • PostgreSQL advisory + PgBouncer transaction mode -- session lock теряется при возврате connection в pool. Используйте xact_lock.
  • Забыли release в catch/finally -- лок висит до TTL. try/finally обязателен.

Мониторинг

  • lock_acquire_total (counter) labels: resource, outcome=got|timeout
  • lock_hold_duration_seconds (histogram)
  • lock_extend_failures_total (counter) -- критичная метрика, означает race
  • lock_waiting_clients (gauge)

Alerts: rate(lock_extend_failures_total[5m]) > 0, lock_hold_duration p99 > SLO.

Выводы

  • Redis SETNX + Lua release -- для efficiency locks. Быстро, просто, но без fencing.
  • Для correctness -- fencing token обязателен на стороне ресурса.
  • etcd/ZooKeeper дают native fencing (revision/zxid) и consensus.
  • PostgreSQL advisory -- бесплатный вариант если PG уже в стеке (осторожнее с pooler'ами).
  • Redlock спорный; если используете -- всё равно нужен fencing.
  • TTL + heartbeat extend для долгих операций, при потере extend -- abort работы.
  • Всегда Lua compare-and-delete при release.