Distributed Locking Deep Dive — production pattern showing Redlock (Antirez), ZooKeeper sequential ephemeral znodes, Postgres advisory locks, fencing tokens, Kleppmann vs Antirez safety debate, GC pause split-brain, lease+heartbeat (Chubby/K8s). Includes 2 ADRs explaining performance-vs-correctness tradeoff and Kleppmann/Antirez 2016 debate.
Распределённая блокировка появляется там, где несколько процессов на разных машинах конкурируют за одно действие: обработать job, отправить письмо один раз, провести выплату, обновить общий ресурс, выбрать leader-а. Локальный mutex не работает, потому что процессы живут в разных адресных пространствах и могут падать независимо. Хочется сказать «возьмём Redis SET NX и готово», но именно здесь начинаются неприятные гарантии распределённых систем: network partition, GC pause, clock drift, failover, зависшие VM, потерянные heartbeat-ы.
Главная причина изучать этот паттерн — отличать performance lock от correctness lock. Если дубль работы неприятен, но безопасен, Redis lock может быть нормальным решением. Если дубль означает двойное списание денег, нарушение sequence number или повреждение инвентаря, обычный TTL-lock не защищает. Для correctness нужен fencing token: монотонно возрастающий номер владения, который проверяет сам защищаемый ресурс.
Эта диаграмма полезна как карта решений: Redlock на независимых Redis-инстансах, ZooKeeper sequential ephemeral znodes, Postgres advisory locks, lease + heartbeat, fencing. Она также объясняет дебат Kleppmann vs Antirez: вопрос не «плохой ли Redis», а «какую гарантию вы от него требуете».
Распределённый лок — это lease, а не магический mutex. Клиент получает право действовать на ограниченное время или пока жива сессия. Но клиент может замереть дольше TTL, сеть может разделиться, а другой клиент может получить новый lease. Старый владелец может проснуться и продолжить писать, если storage не умеет отвергать устаревшие операции.
Fencing token закрывает эту дыру. Каждый новый владелец получает token больше предыдущего. Storage хранит последний принятый token и отклоняет записи с меньшим значением. Тогда даже если client A проснулся после GC pause с устаревшим lock-ом, его write с token 33 будет отвергнут, потому что client B уже записал token 34.
Практическое правило: lock service может сказать «кто сейчас владелец», но correctness обеспечивается только там, где выполняется защищаемая запись. Если storage не проверяет fencing token, lock остаётся best-effort координацией.
Диаграмма строит несколько вариантов вокруг двух клиентов, которые конкурируют за lock. В Redlock-секции показаны пять независимых Redis-инстансов. Это важно: Redlock предполагает независимые узлы, а не master-replica, потому что репликация с failover может потерять факт взятия lock-а. Клиент пытается выполнить SET key value NX PX ttl на большинстве узлов и считает lock взятым, если получил quorum быстрее TTL.
Отдельная часть показывает ZooKeeper/etcd-подход: клиенты создают sequential ephemeral nodes, порядок определяется монотонной последовательностью, а session semantics освобождают lock при потере клиента. Такой подход дороже операционно, но даёт основу для fencing token: zxid/revision/sequencer можно передавать в storage.
Показан и Postgres advisory lock как прагматичная опция. Если весь спорный ресурс уже находится в Postgres, pg_try_advisory_xact_lock часто проще и безопаснее отдельного Redis. Лок освобождается вместе с транзакцией, connection close снимает session lock, а operational surface меньше. Но это не masterless-решение и не подходит для глобальной координации между независимыми storage.
Redlock happy path учит базовой механике: client A получает большинство Redis acknowledgements, выполняет работу и освобождает lock через compare-and-delete Lua script. Этот сценарий подходит для задач вроде дедупликации фоновой работы, cache warmer-а или периодического job-а, где редкий дубль не разрушает данные.
Redlock + GC pause = data race показывает слабое место TTL-lock-а. Client A взял lock на 30 секунд, затем процесс остановился на 35 секунд из-за GC pause или scheduler freeze. TTL истёк, client B взял lock и записал новые данные. Client A проснулся и тоже записал, потому что локальная память всё ещё «помнит», что lock был взят. ADR-вывод: TTL с запасом не является доказательством безопасности. В распределённых системах паузы могут быть длиннее любого разумного TTL.
Fencing token защищает показывает корректную защиту. Client A получает token 33, client B позже получает token 34. Storage принимает запись B и запоминает 34. Когда A просыпается и пытается писать с token 33, storage отклоняет операцию. Это ключевой урок диаграммы: защита не в lock manager-е, а в проверке token-а на ресурсе.
Postgres advisory показывает лок, привязанный к транзакции. Client A берёт advisory lock, client B получает busy и возвращается позже. При commit lock освобождается автоматически. Такой сценарий хорош для job queue, scheduler-а или бизнес-операций, которые уже живут в одной Postgres primary.
ADR: performance optimization или correctness primitive? Контекст: нужно предотвратить одновременную работу нескольких worker-ов. Если дубль безопасен, лучше выбрать простой lock с TTL и идемпотентным handler-ом. Если дубль недопустим, нужен consensus-backed lease и fencing. Решение зависит от ущерба при split-brain. Для email-дубля можно принять Redis lock; для платежа нужен fencing token или transactional constraint.
ADR: Redis SET NX, Redlock или ZooKeeper/etcd? Одиночный Redis прост и быстр, но failover может потерять lock. Redlock снижает риск за счёт большинства независимых Redis, но зависит от времени и не даёт fencing token сам по себе. ZooKeeper/etcd дороже, медленнее и требует кластера, зато даёт упорядоченную историю владения и session semantics. Для correctness выбирайте ZK/etcd/Chubby-подобный сервис плюс проверку token-а в storage.
ADR: Postgres advisory locks или отдельный lock service? Если спорный ресурс и так в Postgres, advisory lock уменьшает количество moving parts и связывает lock с транзакцией. Но он централизует contention на БД и не работает как общий coordination layer для разных систем. Для небольших команд и монолитов Postgres часто выигрывает. Для cross-service leader election лучше etcd/ZooKeeper.
ADR: fixed TTL или lease + heartbeat? Fixed TTL прост, но вынуждает угадывать длительность работы. Большой TTL ухудшает recovery после crash, маленький TTL создаёт ложное истечение. Lease + heartbeat быстрее освобождает lock при падении и поддерживает долгие операции, но требует мониторинга heartbeat-а и epoch/fencing при смене владельца.
ZooKeeper использует ephemeral sequential znodes и Zab consensus; он стал классическим coordination layer для Kafka старых версий, HBase и Hadoop-экосистемы. etcd использует Raft и лежит в основе Kubernetes control plane; Kubernetes leader election строится на lease-объектах и resourceVersion-подобной семантике. Chubby в Google сформировал идею fault-tolerant lock service и sequencer/fencing. Consul даёт sessions + KV на Raft. Redis Redlock описан как рекомендация для распределённых lock-ов, но его надо применять с пониманием границ.
Postgres advisory locks активно используются в приложениях, где БД уже является coordination point: background jobs, миграционные lock-и, одиночные cron tasks, синхронизация обработчиков. DynamoDB conditional writes могут играть роль lock-а через ConditionExpression, но для correctness всё равно нужно проектировать token/version checks.
Самая опасная ошибка — считать SET NX PX доказательством эксклюзивности для денег, инвентаря или внешних side effects. Это только lease с TTL, а не линейризуемый mutex.
Вторая ошибка — освобождать lock простым DEL key. Если TTL истёк и другой клиент уже взял lock с новым value, старый владелец удалит чужой lock. Минимум нужен compare value then delete через Lua script.
Третья ошибка — строить Redlock на Redis replicas. Алгоритму нужны независимые masters. Master-replica failover может потерять запись lock-а, и новый master разрешит второму клиенту войти в critical section.
Четвёртая ошибка — выбирать TTL «с большим запасом» и считать задачу решённой. Stop-the-world паузы, kernel stalls, hibernate, overloaded nodes и network partitions не обязаны укладываться в ваш запас.
Пятая ошибка — добавлять lock вместо идемпотентности. Lock снижает вероятность дубля, но handler всё равно должен иметь idempotency key, unique constraints или version checks, иначе любой редкий split-brain превращается в corruption.
Шестая ошибка — не мониторить contention. Если lock становится горячей точкой, latency растёт, retries синхронизируются, а система начинает сама создавать thundering herd.
Не используйте распределённый lock, если задачу можно решить локальной транзакцией, unique constraint, optimistic concurrency control или идемпотентным consumer-ом. Часто правильный ответ для «только один обработчик должен создать запись» — INSERT ... ON CONFLICT DO NOTHING, а не отдельный Redis lock.
Не используйте lock для согласования долгих бизнес-процессов, которые длятся минуты или часы. Лучше разбить процесс на state machine, saga, workflow engine или explicit ownership в БД. Долгий lock ухудшает availability и recovery.
Не используйте Redis/Redlock для correctness-критичных операций без fencing. Если защищаемый ресурс не умеет проверять token, вы не можете доказать безопасность при паузах и partition. Не используйте ZooKeeper/etcd только ради моды, если проблема локальна для одной Postgres-транзакции.
consensus-overview — почему ZooKeeper/etcd дают более сильные гарантии, чем обычный cache.leader-election — lease, heartbeat и epoch в выборе активного владельца.replication — как failover и lag ломают наивные lock-и.transactional-outbox — как не заменять надежную доставку событий глобальным lock-ом.