Распределённые системы
Распределённые блокировки
Цели урока
- Понимать когда нужна распределённая блокировка и когда от неё следует отказаться
- Реализовать безопасный Redis-лок с уникальным токеном и Lua-скриптом
- Знать алгоритм Redlock и его ограничения при GC-паузах
- Применять fencing tokens для защиты от устаревших lock holders
- Выбирать между Redis, Redlock, ZooKeeper и etcd по требованиям задачи
Предварительные знания
- Понимание Redis: команды SET NX PX, Lua eval
- Концепция race condition и critical section
- Базовые знания о кворуме и консенсусе в распределённых системах
Один Redis-лок без уникального токена - и GC-пауза в 2 секунды превращает mutex в решето: два процесса одновременно в критической секции, двойное списание, потеря данных.
- **Stripe, PayPal** - idempotency keys на каждый платёж фактически распределённая блокировка на уровне бизнес-логики
- **Kubernetes** - etcd-блокировки для leader election controller-manager и scheduler
- **Apache Kafka** - ZooKeeper (исторически) для координации брокеров и partition leadership
- **Cron в кластере** - без distributed lock каждая задача запускается на всех Pod-ах одновременно
- **GitHub Actions self-hosted runners** - блокировки на уровне workflow для предотвращения параллельных деплоев
Дискуссия Antirez vs Kleppmann (2016)
В феврале 2016 Martin Kleppmann опубликовал статью 'How to do distributed locking', где доказал что Redlock небезопасен при GC-паузах и clock skew. Antirez (автор Redis и Redlock) ответил детальным возражением. Дискуссия длилась несколько недель, привлекла Kyle Kingsbury (aphyr) и других. Итог: Redlock достаточно безопасен для большинства задач, но не для систем где корректность критична - там нужны fencing tokens. Эта дискуссия стала учебником по тому, как думать о safety в distributed systems.
Зачем нужны распределённые блокировки
**2012 год. Stripe обрабатывает платежи на нескольких инстансах. Два Pod-а одновременно получают webhook от банка об успешной оплате - и оба начинают начислять бонусы пользователю. Двойное начисление. 100 000 транзакций в день. Цена ошибки - миллионы долларов.** Локальный mutex не поможет: Pod-ы не видят память друг друга. Нужен mutex на уровне кластера.
**Распределённая блокировка** - механизм, гарантирующий что только один процесс в кластере выполняет критическую секцию в любой момент времени. В отличие от локального mutex, живёт в общем хранилище (Redis, ZooKeeper, etcd).
Когда нужна распределённая блокировка
| Сценарий | Проблема без блокировки | Решение |
|---|---|---|
| Обработка платежей | Двойное списание/начисление при race condition | Лок на идентификатор транзакции |
| Cron-задачи в кластере | 5 Pod-ов запускают одну задачу одновременно | Лок на имя задачи перед запуском |
| Доступ к внешнему API с rate limit | Суммарный rate кластера превышает лимит API | Лок или distributed rate limiter |
| Leader election | Несколько сервисов считают себя лидером | Кто захватил лок - тот лидер |
**Главное правило:** распределённая блокировка - последний выбор, не первый. Сначала проверить: операция идемпотентна? Можно использовать CRDT или optimistic locking? Нужен один writer - рассмотреть leader election. Лок добавляет latency, single point of failure и сложность.
Распределённые блокировки работают как обычный mutex - захватил, сделал, отпустил, всё безопасно
В распределённой системе между захватом лока и его использованием может произойти GC-пауза, network partition или crash - и другой процесс захватит тот же лок
Локальный mutex живёт в памяти процесса - нет паузы между проверкой и захватом. В сети каждый шаг - отдельный запрос с потенциальной задержкой. Поэтому нужны дополнительные механизмы (fencing tokens, TTL, уникальные идентификаторы) чтобы обнаружить устаревший holder.
Два Pod-а одновременно обрабатывают один и тот же webhook события оплаты. Какой механизм корректнее всего предотвращает двойную обработку?
Redis-блокировка: от простой к безопасной
**Redis - первый выбор для distributed locks в 90% случаев.** Команда `SET key value NX PX ttl` атомарна: записывает значение только если ключа нет, с автоматическим истечением. Один round-trip вместо транзакции. Но простая реализация содержит опасный баг.
**Сценарий атаки (GC pause):** Process A захватывает лок. JVM делает GC-паузу на 2 секунды. TTL истекает. Process B захватывает тот же лок. Process A выходит из паузы, думает что держит лок, и при `release()` удаляет лок Process B. Теперь оба процесса одновременно работают в критической секции.
Безопасная реализация: уникальный токен
**Почему Lua-скрипт?** GET + DEL - два отдельных запроса. Между ними другой процесс может захватить лок и TTL истечёт именно тогда. Lua-скрипт выполняется атомарно в одном Redis-command.
Почему при release() нужно использовать Lua-скрипт вместо последовательных GET + DEL?
Redlock: кворум на 5 нодах
**Единственная Redis-нода - single point of failure.** Если она упала или делает AOF-запись с fsync, лок недоступен. Antirez (автор Redis) в 2013 предложил Redlock - алгоритм блокировки на N независимых Redis-нодах. Захват считается успешным если лок получен на большинстве (N/2 + 1) нод за время меньше TTL.
| Шаг | Действие | Условие продолжения |
|---|---|---|
| 1 | Запомнить startTime = Date.now() | Всегда |
| 2 | Последовательно SET NX на каждой ноде с коротким timeout | Собрать все ответы |
| 3 | Подсчитать successCount и elapsed = Date.now() - startTime | Всегда |
| 4 | Проверить: successCount >= N/2+1 AND ttl - elapsed > clockDrift | Лок захвачен - можно работать |
| 5 | Если условие не выполнено - DEL на всех нодах | Нет лока - retry или error |
**Критика Martin Kleppmann (2016):** Redlock не безопасен при GC-паузах и clock skew. Сценарий: процесс A захватывает Redlock, уходит в GC-паузу на 10 секунд, TTL истекает, процесс B захватывает тот же Redlock, процесс A выходит из паузы и оба работают в критической секции. Antirez возразил, но дискуссия не закрыта. Для финансовых систем - использовать fencing tokens.
Redlock на 5 нодах полностью безопасен - даже если 2 ноды упали, большинство держит лок
Redlock безопасен при сбоях нод, но небезопасен при GC-паузах и длинных задержках - GC может случиться уже после успешного захвата
Проблема не в том, сколько нод держат лок, а в том что между захватом лока и реальным использованием ресурса может пройти произвольное время (GC, CPU scheduling). За это время TTL может истечь на всех нодах и другой клиент захватит тот же лок. Fencing tokens решают это независимо от механизма блокировки.
Redlock на 5 нодах: захвачено 2 из 5. Что происходит?
Fencing Tokens и ZooKeeper
**Martin Kleppmann предложил fencing tokens в 2016 - единственный способ гарантировать корректность при GC-паузах.** Идея: сервер блокировок выдаёт монотонно возрастающий номер при каждом захвате. Ресурс отклоняет запросы со старым номером. Процесс A захватил лок с token=33, завис, token=34 выдан процессу B, A проснулся - ресурс видит 33 < 34 и отклоняет запрос A.
ZooKeeper: ephemeral sequential nodes
ZooKeeper предоставляет примитивы для блокировок на уровне консенсуса (ZAB протокол). Клиент создаёт **эфемерный последовательный узел** - ZooKeeper автоматически добавляет монотонный суффикс (lock-0000001, lock-0000002). Кто создал узел с наименьшим суффиксом - тот держит лок. При потере сессии эфемерный узел удаляется автоматически.
| Механизм | Надёжность | Latency | Fencing tokens | Когда использовать |
|---|---|---|---|---|
| Redis single node | Низкая (SPOF) | < 1 мс | Нет (нужно реализовать) | Dev/staging, некритичные задачи |
| Redlock (5 нод) | Средняя (кворум) | 3-10 мс | Нет | Высокая доступность, не финансы |
| ZooKeeper | Высокая (ZAB) | 5-20 мс | Да (sequential nodes) | Координация кластера, leader election |
| etcd (Raft) | Высокая (Raft) | 5-15 мс | Да (revision) | Kubernetes-окружения, service mesh |
В чём преимущество ZooKeeper ephemeral sequential nodes перед Redis-блокировкой для реализации fencing tokens?
Вопросы для размышления
- Представьте систему обработки финансовых транзакций на 10 Pod-ах. Определите: 1. для каких операций достаточно идемпотентности без блокировки 2. для каких нужен Redis-лок 3. для каких нужен ZooKeeper или etcd с fencing tokens.
Связанные уроки
- dist-07-transactions — Locks implement pessimistic transaction isolation
- dist-09-raft — Lock services are built on consensus (etcd/ZooKeeper)
- dist-12-consistency — Locks provide mutual exclusion underpinning strong consistency
- db-13-transactions