Зачем
В кластере N worker'ов запускается cron. Без координации все N одновременно попытаются выполнить задачу. Последствия: лишняя нагрузка, дубли, race conditions.
Распределённый лок -- механизм, который гарантирует, что только один процесс имеет доступ к ресурсу.
Требования к хорошему локу
- Mutual exclusion: в любой момент времени максимум один holder
- Deadlock-free: если holder упал -- лок освобождается
- Fault-tolerant: сервис локов может потерять узел
- Fairness (опционально): очередь в порядке запросов
- 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();
// }
Проблема: 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:
- Redlock не защищает от GC-паузы длиннее TTL (нужен fencing token)
- Полагается на примерно синхронизированные часы -- если на одной ноде 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:
- Создать ephemeral sequential node:
/locks/resource_00001 - Получить список всех детей
/locks/ - Если моя нода -- с минимальным номером → я holder
- Иначе watch предыдущую по порядку ноду, ждать её удаления
- 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;
}
}
Для долгих операций мало одноразового 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|timeoutlock_hold_duration_seconds(histogram)lock_extend_failures_total(counter) -- критичная метрика, означает racelock_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.