Распределенные блокировки
1) Зачем (и когда) нужны распределенные блокировки
Распределенная блокировка — механизм, гарантирующий взаимоисключение для критической секции между несколькими узлами кластера. Типовые задачи:- Лидерство (leader election) для фоновой задачи/шедулера.
- Ограничение единственного исполнителя над общим ресурсом (перемещение файлов, миграции схемы, эксклюзивный платежный шаг).
- Последовательная обработка агрегата (wallet/order) если невозможно добиться идемпотентности/упорядочивания иначе.
- Если можно сделать идемпотентный upsert, CAS (compare-and-set) или очередь по ключу (per-key ordering).
- Если ресурс допускает коммутативные операции (CRDT, счетчики).
- Если проблема решается транзакцией в одном хранилище.
2) Модель угроз и свойства
Отказы и сложности:- Сеть: задержки, разделение (partition), потеря пакетов.
- Процессы: пауза GC, stop-the-world, крэш после взятия замка.
- Время: дрейф часов и смещение ломают TTL-подходы.
- Повторное владение: «зомби»-процесс после сети может думать, что все еще владеет замком.
- Безопасность: не более одного действительного владельца (safety).
- Живучесть: замок освобождается при сбое владельца (liveness).
- Справедливость: отсутствует голодание.
- Независимость от часов: корректность не зависит от wall-clock (или компенсируется fencing tokens).
3) Основные модели
3.1 Lease (арендный замок)
Замок выдается с TTL. Владелец обязан продлеваць его до истечения (heartbeat/keepalive).
Плюсы: самоснятие при крэше.
Риски: если владелец «завис» и продолжает работать, но потерял продление, может возникнуть двойное владение.
3.2 Fencing token (токен ограждения)
При каждом успешном захвате выдается монотонно растущий номер. Потребители ресурса (БД, очередь, файловое хранилище) проверяют токен и отвергают операции со старым номером.
Это крайне важно при TTL/lease и сетевых разделениях — защищает от «старого» владельца.
3.3 Quorum-замки (CP-системы)
Используют распределенный консенсус (Raft/Paxos; etcd/ZooKeeper/Consul), запись связана с логом консенсуса → нет сплит-брейна при большинстве узлов.
Плюс: сильные гарантии безопасности.
Минус: чувствительность к кворуму (при его потере живучесть хромает).
3.4 AP-замки (in-memory/кэш + репликация)
Например, Redis-кластер. Высокая доступность и скорость, но без жестких гарантий безопасности при сетевых разделениях. Требуют fencing на стороне синка.
4) Платформы и паттерны
4.1 etcd / ZooKeeper / Consul (рекомендуемые для strong locks)
Эфемерные узлы (ZK) или сессии/leases (etcd): ключ существует, пока жива сессия.
Сессионный keepalive; потеря кворума → сессия истекает → замок освобождается.
Порядковые узлы (ZK `EPHEMERAL_SEQUENTIAL`) для очереди ожидания → справедливость.
go cli, _:= clientv3. New(...)
lease, _:= cli. Grant(ctx, 10) // 10s lease sess, _:= concurrency. NewSession(cli, concurrency. WithLease(lease. ID))
m:= concurrency. NewMutex(sess, "/locks/orders/42")
if err:= m. Lock(ctx); err!= nil { / handle / }
defer m. Unlock(ctx)
4.2 Redis (аккуратно)
Классика — `SET key value NX PX ttl`.
Проблемы:- Репликация/фейловер могут допустить одновременных владельцев.
- Redlock из нескольких инстансов уменьшает риск, но не устраняет; спорен в средах с ненадежной сетью.
Безопаснее применять Redis как быстрый координационный слой, но всегда дополнять fencing token в целевом ресурсе.
Пример (Lua-unlock):lua
-- release only if value matches if redis. call("GET", KEYS[1]) == ARGV[1] then return redis. call("DEL", KEYS[1])
else return 0 end
4.3 БД-замки
PostgreSQL advisory locks: лок в рамках кластера Postgres (процесс/сессия).
Хорошо, когда все критические секции и так в одной БД.
sql
SELECT pg_try_advisory_lock(42); -- take
SELECT pg_advisory_unlock(42); -- let go
4.4 Файловые/облачные «замки»
S3/GCS + объектный метаданный лок с условиями `If-Match` (ETag) → по сути CAS.
Подходит для бэкапов/миграций.
5) Дизайн безопасного замка
5.1 Идентичность владельца
Храните `owner_id` (узел#процесс#pid#start_time) + случайный токен для сверки при unlock.
Повторный unlock не должен снимать чужой замок.
5.2 TTL и продление
TTL < T_fail_detect (время обнаружения сбоя) и ≥ p99 работы критической секции × запас.
Продление — периодически (например, каждые `TTL/3`), с deadline.
5.3 Fencing token на синке
Секция, изменяющая внешний ресурс, должна передать `fencing_token`.
Синк (БД/кэш/хранилище) хранит `last_token` и отвергает меньшие:sql
UPDATE wallet
SET balance = balance +:delta, last_token =:token
WHERE id =:id AND:token > last_token;
5.4 Очередь ожидания и справедливость
В ZK — `EPHEMERAL_SEQUENTIAL` и наблюдатели: клиент ждет освобождение ближайшего предшественника.
В etcd — ключи с ревизией/версионированием; очередность по `mod_revision`.
5.5 Поведение при split-brain
CP-подход: без кворума нельзя взять замок — лучше простоять, чем сломать safety.
AP-подход: допускается прогресс в разделенных островах → необходим fencing.
6) Лидерство (leader election)
В etcd/ZK — «лидер» это эксклюзивный эпемерный ключ; остальные подписаны на изменения.
Лидер пишет heartbeats; потеря — переизбрание.
Все операции лидера сопровождайте fencing token (номер эпохи/ревизии).
7) Ошибки и их обработка
Клиент взял замок, но крэш до работы → норм, никто не пострадает; TTL/сессия освободят.
Замок истек посередине работы:- Обязателен watchdog: если продление неуспешно — прервать критическую секцию и откатить/компенсировать.
- Никаких «доделать потом»: без замка критическую секцию продолжать нельзя.
Долгая пауза (GC/stop-the-world) → продление не случилось, другой взял замок. Рабочий процесс должен обнаружить потерю владения (канал keepalive) и прервать.
8) Дедлоки, приоритеты и инверсия
Дедлоки в распределенном мире редки (замок обычно один), но если замков несколько — придерживайтесь единого порядка взятия (lock ordering).
Инверсия приоритетов: низкоприоритетный владелец держит ресурс, пока высокоприоритетные ждут. Решения: TTL-лимиты, preemption (если бизнес допускает), sharding ресурса.
Голодание: используйте очереди ожидания (ZK-подпорядковые узлы) для справедливости.
9) Наблюдаемость
Метрики:- `lock_acquire_total{status=ok|timeout|error}`
- `lock_hold_seconds{p50,p95,p99}`
- `fencing_token_value` (монотонность)
- `lease_renew_fail_total`
- `split_brain_prevented_total` (сколько попыток отказано из-за отсутствия кворума)
- `preemptions_total`, `wait_queue_len`
- `lock_name`, `owner_id`, `token`, `ttl`, `attempt`, `wait_time_ms`, `path` (для ZK), `mod_revision` (etcd).
- Спаны «acquire → critical section → release» с результатом.
- Рост `lease_renew_fail_total`.
- `lock_hold_seconds{p99}` > SLO.
- «Орфанные» замки (без heartbeat).
- Раздутые очереди ожидания.
10) Практические примеры
10.1 Безопасный Redis-замок с fencing (псевдо)
1. Храним счетчик токенов в надежном сторе (например, Postgres/etcd).
2. При успешном `SET NX PX` читаем/инкрементируем токен и все изменения ресурса делаем с проверкой токена в БД/сервисе.
python acquire token = db. next_token ("locks/orders/42") # monotone ok = redis. set("locks:orders:42", owner, nx=True, px=ttl_ms)
if not ok:
raise Busy()
critical op guarded by token db. exec("UPDATE orders SET... WHERE id=:id AND:token > last_token",...)
release (compare owner)
10.2 etcd Mutex + watchdog (Go)
go ctx, cancel:= context. WithCancel(context. Background())
sess, _:= concurrency. NewSession(cli, concurrency. WithTTL(10))
m:= concurrency. NewMutex(sess, "/locks/job/cleanup")
if err:= m. Lock(ctx); err!= nil { /... / }
// Watchdog go func() {
<-sess. Done ()//loss of session/quorum cancel ()//stop working
}()
doCritical (ctx )//must respond to ctx. Done()
_ = m. Unlock(context. Background())
_ = sess. Close()
10.3 Лидерство в ZK (Java, Curator)
java
LeaderSelector selector = new LeaderSelector(client, "/leaders/cron", listener);
selector. autoRequeue();
selector. start(); // listener. enterLeadership() с try-finally и heartbeat
10.4 Postgres advisory lock с дедлайном (SQL+app)
sql
SELECT pg_try_advisory_lock(128765); -- attempt without blocking
-- if false --> return via backoff + jitter
11) Тест-плейбуки (Game Days)
Потеря кворума: отключить 1–2 узла etcd → попытка взять замок должна не проходить.
GC-пауза/stop-the-world: искусственно задержать поток владельца → проверить, что watchdog прерывает работу.
Split-brain: эмуляция сетевого разделения между владельцем и стором замка → новый владелец получает более высокий fencing token, старый — отвергнут синком.
Clock skew/drift: увести часы у владельца (для Redis/lease) → убедиться, что корректность обеспечивается токенами/проверками.
Crash before release: падение процесса → замок освобождается по TTL/сессии.
12) Анти-паттерны
Чистый TTL-замок без fencing при доступе к внешнему ресурсу.
Полагаться на локальное время для корректности (без HLC/fencing).
Раздача замков через один Redis-мастер в среде с фейловером и без подтверждения реплик.
Бесконечная критическая секция (TTL «на века»).
Снятие «чужого» замка без сверки `owner_id`/token.
Отсутствие backoff+jitter → «шторм» попыток.
Единый глобальный замок «на все» — мешок конфликтов; шардинг по ключу лучше.
13) Чек-лист внедрения
- Определен тип ресурса и можно ли обойтись CAS/очередью/идемпотентностью.
- Выбран механизм: etcd/ZK/Consul для CP; Redis/кэш — только с fencing.
- Реализованы: `owner_id`, TTL + продление, watchdog, корректный unlock.
- Внешний ресурс проверяет fencing token (монотонность).
- Есть стратегия лидерства и failover.
- Настроены метрики, алерты, логирование токенов и ревизий.
- Предусмотрены backoff+jitter и таймауты на acquire.
- Проведены game days: кворум, split-brain, GC-паузы, clock skew.
- Документация порядка взятия нескольких замков (если требуется).
- План деградации (brownout): что делать при недоступности замка.
14) FAQ
Q: Достаточно ли Redis-замка `SET NX PX`?
A: Только если ресурс проверяет fencing token. Иначе при сетевом разделении возможны два владельца.
Q: Что выбрать «по умолчанию»?
A: Для строгих гарантий — etcd/ZooKeeper/Consul (CP). Для легких задач внутри одной БД — advisory locks Postgres. Redis — лишь с fencing.
Q: Какой TTL ставить?
A: `TTL ≥ p99 длительности критической секции × 2` и достаточно короткий для быстрой очистки «зомби». Продление — каждые `TTL/3`.
Q: Как избежать голодания?
A: Очередь ожидания по порядку (ZK sequential) или fairness-алгоритм; лимит попыток и справедливое планирование.
Q: Нужна ли синхронизация времени?
A: Для корректности — нет (используйте fencing). Для эксплуатационной предсказуемости — да (NTP/PTP), но не полагайтесь на wall-clock в логике замка.
15) Итоги
Надежные распределенные блокировки строятся на кворумных сторах (etcd/ZK/Consul) с lease + keepalive, и обязательно дополняются fencing token на уровне изменяемого ресурса. Любые TTL/Redis-подходы без ограждения — риск сплит-брейна. Думайте сначала о каузальности и идемпотентности, используйте блокировки там, где без них нельзя, измеряйте, тестируйте отказные режимы — и ваши «критические секции» останутся критичными только по смыслу, а не по количеству инцидентов.