Распределённые системы Координация: распределённые блокировки, лизы, etcd и ZooKeeper
0%

Координация: распределённые блокировки, лизы, etcd и ZooKeeper

Координация: распределённые блокировки, лизы, etcd и ZooKeeper

В однопроцессной программе взаимное исключение стоит несколько наносекунд и одну строчку кода. mutex.Lock() работает потому, что под ним лежат три вещи, которые мы никогда не проговариваем вслух: общая память, в которой все видят одно и то же значение; надёжный планировщик, который знает, жив поток или нет; и отсутствие частичных отказов — поток не может умереть так, чтобы мьютекс остался захваченным навсегда, потому что вместе с потоком умирает весь процесс.

Уберите любую из трёх опор — и мьютекс превращается в тыкву. Распределённая система убирает все три сразу. Общей памяти нет, есть сообщения с непредсказуемой задержкой. Планировщика нет, есть детектор отказов, который по построению ошибается. Частичные отказы — норма: узел, который держит лок, может быть жив, здоров и просто не отвечать пятнадцать секунд.

Отсюда главный тезис статьи: распределённой блокировки в смысле мьютекса не существует и существовать не может. Существует другой примитив — лиза (lease), аренда с истечением, — и он даёт заметно более слабую гарантию. Вся инженерия координации — про то, как построить корректную систему поверх этой слабой гарантии, вместо того чтобы делать вид, что она сильная.

Разберём: зачем вообще нужна блокировка (два разных ответа с разными последствиями), почему лиза не спасает и что спасает, как устроены etcd и ZooKeeper под капотом, рабочие рецепты локов и выборов лидера с кодом и анализом сложности, и в конце — как обойтись без координации, потому что самая дешёвая координация — та, которой нет. Предполагается знакомство с моделями отказов, временем и часами и консенсусом: координационный сервис снаружи выглядит как key-value-хранилище, но внутри это реплицированный конечный автомат поверх Raft или ZAB.

Два мотива для блокировки, и они не равнозначны

Прежде чем брать лок, ответьте на один вопрос: что случится, если лок возьмут двое? Различение принадлежит Мартину Клеппманну («How to do distributed locking», 2016) и является самым практически полезным в этой теме.

Эффективность Корректность
Что случится при двух владельцах лишняя работа: два раза перекодируется видео, дважды прогреется кэш порча данных: двойное списание, испорченный файл, потерянная транзакция
Цена ошибки деньги на CPU инцидент, ручное восстановление, иногда невосстановимо
Достаточно ли лизы да, вероятностной гарантии хватает нет, нужна проверка на стороне ресурса
Подойдёт ли Redis-лок да нет
Нужен ли fencing-токен нет обязателен

Если задача про эффективность — расслабьтесь, возьмите SET NX PX в Redis и живите счастливо: изредка задача выполнится дважды, вы потеряете немного CPU. Если задача про корректность — никакой лок сам по себе вас не спасёт, и остаток статьи объясняет почему.

Опаснее всего — задачи, которые выглядят как эффективность, а являются корректностью. «Крон раз в сутки шлёт письма о просроченном платеже, возьмём лок, чтобы не задвоить» — это корректность: клиент получит два письма и напишет в поддержку. «Воркер компактит сегменты в объектном хранилище» — это корректность: два воркера перепишут один и тот же объект и потеряют часть данных.

Лиза: блокировка, у которой есть срок годности

Первая проблема любого лока в сети: владелец может умереть, не отпустив лок. Если лок вечный, одна упавшая машина навсегда останавливает шард. Значит, у лока должен быть срок.

Понятие ввели Cary Gray и David Cheriton в «Leases: An Efficient Fault-Tolerant Mechanism for Distributed File Cache Consistency» (SOSP ‘89). Лиза — это контракт с истечением: сервер обещает не отдавать ресурс никому другому до момента T, а после T имеет право действовать без связи с клиентом. Именно последнее свойство делает лизу отказоустойчивой: чтобы освободить ресурс, не нужно ничьё подтверждение, достаточно подождать.

И ровно то же свойство делает лизу небезопасной. Сервер снимает лизу по своим часам. Клиент считает, что владеет ресурсом, по своим. Между этими двумя мнениями есть зазор, и в этом зазоре живут инциденты.

Временная шкала истечения лизы: пауза GC порождает двух владельцев, fencing-токен спасает ресурс

Сценарий на картинке разбирается по секундам:

  1. t=0. Клиент A берёт лизу на 15 секунд, координатор отдаёт ему владение шардом 7. A начинает писать.
  2. t=4. В JVM клиента A начинается stop-the-world-пауза сборщика мусора. Не «медленно работает» — не выполняется ни одна инструкция. Поток keepalive тоже стоит: он живёт в том же процессе.
  3. t=15. Координатор не получил ни одного продления и снимает лизу. Эфемерный ключ удалён. С точки зрения кластера владельца нет.
  4. t=16. Клиент B берёт лизу, получает владение шардом 7, начинает писать.
  5. t=18. Пауза GC заканчивается. Клиент A продолжает выполнение с того места, где остановился. Его код уверен, что до истечения ещё секунда — он проверял время до паузы.
  6. t=19. A пишет в тот же шард. Двое пишут одновременно.

Ключевая мысль: паузу нельзя убрать увеличением TTL. Паузы в 10–20 секунд наблюдаются в реальности регулярно — full GC на большой куче, своп, fork() большого процесса, живая миграция ВМ, перегруженный CPU в контейнере с cpu.max, зависший NFS-маунт. Строка из GC-лога, которую видел каждый, кто эксплуатировал JVM:

[2026-07-12T09:41:12.447+0300] Total time for which application threads were stopped: 14.203 seconds

Увеличение TTL до 60 секунд не решает проблему, а меняет её характер: теперь зазор редкий, но восстановление после честного падения узла занимает минуту. Вы обменяли частоту инцидента на длительность простоя, а не устранили класс отказа.

Смотрите на это так: лиза гарантирует, что рано или поздно владельцев станет не больше одного, но не гарантирует, что их не бывает двое сейчас. Это гарантия живости с примесью безопасности, а не безопасность.

Fencing-токен: единственное, что действительно работает

Раз двух владельцев не предотвратить, нужно предотвратить вред от двух владельцев. Механизм называется fencing (ограждение), и он до неприличия прост:

Вместе с владением координатор выдаёт монотонно растущее число. Ресурс запоминает максимальный номер, который видел, и отвергает любую операцию с меньшим номером.

Разберём по частям, потому что каждое слово нагружено.

«Монотонно растущее» — номер обязан расти при каждой смене владельца и никогда не убывать. Часы не подходят: они прыгают. Локальный счётчик не подходит: он у каждого свой. Подходит только то, что порождает сам консенсус — номер записи в реплицированном логе. В etcd это revision, в ZooKeeper — zxid, в Raft — term, в Kafka — leader epoch и producer epoch.

«Ресурс запоминает» — и это место, где 90 % реализаций разваливаются. Проверку обязана делать та сторона, которая принимает эффект, а не та, которая его производит. Если клиент сам проверяет свой токен перед записью — это не fencing, это самообман: между проверкой и записью помещается вся пауза GC.

В SQL проверка выглядит так — одним оператором, без чтения перед записью:

-- Таблица владения шардом хранит максимальный увиденный токен.
CREATE TABLE shard_owner (
    shard_id    int PRIMARY KEY,
    owner_id    text NOT NULL,
    fence_token bigint NOT NULL   -- монотонная ревизия от координатора
);

-- Любая запись эффекта идёт ТОЛЬКО через захват владения этим оператором.
-- Условие fence_token < :token отсекает проснувшегося старого владельца.
UPDATE shard_owner
   SET owner_id = :owner, fence_token = :token
 WHERE shard_id = :shard
   AND fence_token < :token;
-- 0 обновлённых строк => мы устарели: не ретраить, а остановить работу.

-- Полезная работа идёт в ТОЙ ЖЕ транзакции, что и проверка токена,
-- иначе между проверкой и записью снова помещается пауза.
INSERT INTO events (shard_id, payload) VALUES (:shard, :payload);

Про уровни изоляции, при которых это действительно атомарно, — транзакции и изоляция.

Что делать, если ресурс не умеет проверять токен. Это самый частый вопрос на практике, и ответов ровно четыре:

  1. Условная запись по версии. S3 с 2024 года поддерживает If-None-Match: * (создать, только если объекта нет) и If-Match: <etag> (перезаписать, только если версия та). Azure Blob — ETag-условия с рождения. DynamoDB — ConditionExpression. GCS — x-goog-if-generation-match, где generation монотонно растёт и работает как токен буквально.
  2. Токен в имени. Пишите не в s3://bucket/shard-7/state.json, а в s3://bucket/shard-7/epoch-000034/state.json, а указатель на актуальную эпоху держите в координаторе. Старый владелец физически не может испортить новые данные: он пишет в свой каталог, который никто не читает. Так устроен QuorumJournalManager в HDFS — JournalNode-ы отвергают запись с epoch меньше принятого.
  3. Внешнее ограждение узла. STONITH («Shoot The Other Node In The Head»): выключить питание, отобрать сетевой порт, отвязать диск через SAN-fencing. Грубо, надёжно, требует управляемого железа. Так работают Pacemaker/Corosync и sshfence в HDFS HA.
  4. Задержка (lock-delay). Если ничего из перечисленного невозможно, координатор после потери связи с владельцем не отдаёт лок сразу, а выжидает. В Chubby это lock-delay до минуты, в Consul — LockDelay (по умолчанию 15 с). Это не гарантия, а снижение вероятности: вы просто делаете зазор менее вероятным. Годится для «эффективности», не годится для «корректности».

Если ни один вариант не проходит — примите, что задача решается не блокировкой, а идемпотентностью: пусть двойное выполнение будет безвредным по построению.

Что на самом деле продаёт координационный сервис

Со стороны etcd и ZooKeeper выглядят как маленькое key-value-хранилище с причудливым API. На деле вы покупаете не хранилище, а четыре свойства, которых нет ни у Redis, ни у обычной базы.

Анатомия координационного сервиса: слои и соответствие примитивов ZooKeeper и etcd

  1. Линеаризуемый атомарный CAS. «Создай ключ, только если его нет» и «запиши, только если версия равна N» выполняются как одна неделимая операция, согласованная кворумом. Без этого блокировки не построить в принципе: неатомарная пара «прочитал — записал» — это гонка.
  2. Монотонный счётчик версий. Ревизия/zxid растут глобально на весь кластер и переживают перевыборы лидера. Это готовый fencing-токен, который не надо изобретать.
  3. Эфемерность. Ключ живёт, пока жива сессия клиента. Умер процесс — ключ исчез без участия человека. Это то, чего не даёт SETEX: TTL в Redis не связан с состоянием соединения.
  4. Watch. Подписка на изменение вместо опроса в цикле. Это разница между «100 воркеров опрашивают ключ каждые 100 мс» (1000 RPS фонового шума) и «100 воркеров молчат, пока не произойдёт событие».

И один минус, который надо принять сразу: координационный сервис — CP-система в терминах CAP и PACELC. Потеряли кворум — не получите ни лока, ни лидера, ни ответа. Всё, что от него зависит, встаёт. Это не баг, это цена: система предпочитает не ответить, чем ответить неправдой.

ZooKeeper: znode, эфемерность и правильный рецепт лока

ZooKeeper (Hunt, Konar, Junqueira, Reed, USENIX ATC 2010) даёт иерархию узлов-znode, похожую на файловую систему, и протокол атомарной рассылки ZAB. Полный набор рецептов — в официальной документации.

Ключевые свойства znode:

  • ephemeral — узел исчезает при истечении сессии. Сессия живёт heartbeat-ами, таймаут согласуется между клиентом и сервером в диапазоне от 2 × tickTime до 20 × tickTime.
  • sequential — при создании ZooKeeper дописывает к имени монотонный десятизначный суффикс: lock-0000000042. Счётчик хранится в родительском узле.
  • czxid — идентификатор транзакции, в которой узел создан. Монотонен на весь кластер. Это ваш fencing-токен.
  • watchодноразовый. Сработал — переустанавливайте. Между срабатыванием и переустановкой можно пропустить событие, поэтому watch — это сигнал «пойди перечитай», а не «вот тебе значение».

Наивный рецепт и почему он плох

пока true:
    если create("/lock", ephemeral) успешно: захватили, выходим
    иначе: exists("/lock", watch=true); ждём уведомления

Работает, но при освобождении лока сервер будит все ожидающие сессии, и они одновременно бросаются создавать узел. Это стадный эффект (herd effect). При n ожидающих: O(n) уведомлений и O(n) попыток создания на каждую передачу лока, то есть O(n²) сообщений на проход очереди из n клиентов. На 500 воркерах это заметный всплеск трафика и латентности каждые несколько секунд.

Рецепт со ссылкой на предшественника

1. my = create("/lock/req-", ephemeral + sequential)   # получаем req-0000000042
2. children = getChildren("/lock")                     # без watch!
3. если my — минимальный в children: лок наш, fence_token = czxid(my), выход
4. pred = наибольший элемент children, строго меньший my
5. если exists(pred, watch=true) вернул null: goto 2   # предшественник уже ушёл
6. ждём уведомления по watch; goto 2
7. release: delete(my)  (или просто закрыть сессию)

Сложность. Каждый клиент держит ровно один watch на своего предшественника, поэтому уход владельца будит ровно одного клиента: O(1) уведомлений на передачу лока, O(n) на проход всей очереди вместо O(n²). Память на сервере — O(n) znode. Честная FIFO-очередь получается бесплатно из монотонности суффиксов. Цена — getChildren возвращает весь список детей, то есть O(n) байт на каждую попытку; при n порядка десятков тысяч список упирается в jute.maxbuffer (по умолчанию 1 МБ), и клиент получает Packet len is out of range. Это, кстати, классическая авария: ZooKeeper использовали как очередь задач, накопили 200 000 детей в одном znode, и кластер перестал отвечать целиком.

Реализация на Python

from kazoo.client import KazooClient
from kazoo.exceptions import NoNodeError

def acquire_lock(zk: KazooClient, root: str = "/lock") -> tuple[str, int]:
    """Возвращает (путь своего узла, fencing-токен). Блокирующий вызов."""
    zk.ensure_path(root)
    # ephemeral+sequential: узел уйдёт вместе с сессией, суффикс задаёт порядок
    my_path = zk.create(f"{root}/req-", ephemeral=True, sequence=True)
    my_name = my_path.rsplit("/", 1)[1]

    while True:
        children = sorted(zk.get_children(root))   # O(n) байт по сети
        idx = children.index(my_name)
        if idx == 0:                                # мы первые в очереди
            stat = zk.exists(my_path)
            return my_path, stat.czxid              # czxid — монотонный токен
        predecessor = f"{root}/{children[idx - 1]}"

        gate = zk.handler.event_object()            # переиспользуемое событие
        def on_change(event):                       # watch одноразовый
            gate.set()

        if zk.exists(predecessor, watch=on_change) is None:
            continue                                # предшественник уже исчез
        gate.wait()                                 # спим до его удаления

В проде так руками не пишут: берут Apache Curator (InterProcessMutex, LeaderLatch, LeaderSelector) или kazoo.recipe.lock.Lock. Но знать устройство обязательно — потому что ломается оно на уровне сессии, а не на уровне рецепта.

Как это выглядит в логах, когда ломается

2026-07-12 09:41:12,447 WARN  [main-SendThread(zk-1:2181)] ClientCnxn:
  Client session timed out, have not heard from server in 26668ms
  for sessionid 0x1006f0a5c2e0003
2026-07-12 09:41:12,451 INFO  [main-SendThread(zk-2:2181)] ClientCnxn:
  Opening socket connection to server zk-2/10.0.1.12:2181
2026-07-12 09:41:12,903 WARN  [main-SendThread(zk-2:2181)] ClientCnxn:
  Unable to reconnect to ZooKeeper service, session 0x1006f0a5c2e0003 has expired
2026-07-12 09:41:12,905 INFO  [main-EventThread] ConnectionStateManager:
  State change: LOST

Две строки — вся суть. session ... has expired означает: эфемерный узел уже удалён, лок уже у кого-то другого, а ваш код в это время выполнял бизнес-логику. Если между State change: SUSPENDED (связь потеряна, судьба сессии неизвестна) и LOST вы продолжали писать в ресурс — вы писали без владения.

Отсюда правило работы с Curator: обработчик SUSPENDED обязан остановить работу немедленно, не дожидаясь LOST. SUSPENDED — это «я не знаю, владею ли», а в координации «не знаю» эквивалентно «не владею». Curator для того и предусмотрел CancelLeadershipException.

etcd: лизы, транзакции и выборы

etcd (документация по API) устроен иначе: плоское MVCC-пространство ключей поверх Raft, глобальная revision, растущая на каждую модификацию, и явные лизы вместо неявных сессий.

Код

package main

import (
    "context"
    "time"

    clientv3 "go.etcd.io/etcd/client/v3"
)

func acquire(ctx context.Context, cli *clientv3.Client, key, me string) (int64, error) {
    // 1. Лиза на 15 с. TTL — верхняя граница простоя после падения владельца.
    lease, err := cli.Grant(ctx, 15)
    if err != nil {
        return 0, err
    }

    // 2. Продление в отдельной горутине. Канал ОБЯЗАН вычитываться:
    //    полный буфер -> "lease keepalive response queue is full" -> потеря лизы.
    ka, err := cli.KeepAlive(ctx, lease.ID)
    if err != nil {
        return 0, err
    }
    go func() {
        for range ka { // молча дренируем; закрытие канала = лиза потеряна
        }
    }()

    // 3. Атомарный захват: успех, только если ключа ещё нет.
    //    WithRequireLeader отсекает ответ от узла, отвалившегося от кворума.
    resp, err := cli.Txn(clientv3.WithRequireLeader(ctx)).
        If(clientv3.Compare(clientv3.CreateRevision(key), "=", 0)).
        Then(clientv3.OpPut(key, me, clientv3.WithLease(lease.ID))).
        Commit()
    if err != nil {
        return 0, err
    }
    if !resp.Succeeded {
        return 0, ErrLockHeld // лок занят: ждём через Watch, не крутим опрос
    }

    // 4. Ревизия успешного Put = CreateRevision нашего ключа = fencing-токен.
    return resp.Header.Revision, nil
}

// Каждая значимая операция идёт под защитой токена: одна транзакция
// проверяет, что ключ всё ещё наш и создан именно нами.
func guardedWrite(ctx context.Context, cli *clientv3.Client,
    key string, fence int64, dataKey, payload string) error {

    resp, err := cli.Txn(clientv3.WithRequireLeader(ctx)).
        If(clientv3.Compare(clientv3.CreateRevision(key), "=", fence)).
        Then(clientv3.OpPut(dataKey, payload)).
        Commit()
    if err != nil {
        return err
    }
    if !resp.Succeeded {
        return ErrFenced // владение утрачено — останавливаемся, не ретраим
    }
    return nil
}

Готовые обёртки — в go.etcd.io/etcd/client/v3/concurrency: NewSession + NewMutex для лока, NewElection с Campaign/Proclaim/Resign/Observe для выборов лидера. Election.Observe возвращает канал смен лидера — именно на нём строят «слежу, кто главный» без опроса. Из CLI то же самое доступно как etcdctl lock mylock -- ./my-command и etcdctl elect.

Ловушки, за которые платят в проде

Keepalive живёт своей жизнью. Горутина продления работает, даже если рабочая логика намертво зависла на блокирующем вызове (медленный диск, повисший HTTP без таймаута). Лиза формально жива, работы нет, лок никому не отдаётся — «зомби-владелец». Лечение: продлевать лизу должен тот же цикл, что делает работу, либо рядом должен жить watchdog, снимающий лизу при отсутствии прогресса.

Обратное тоже верно. Пауза GC убивает keepalive, но не убивает намерение писать. Это ровно сценарий с картинки выше — и лечится он только fencing-ом.

Чтения по умолчанию линеаризуемые, и это стоит round-trip. etcd делает ReadIndex: лидер подтверждает лидерство кворумным heartbeat-ом, прежде чем ответить. Если вы в горячем цикле читаете «кто лидер» — вы генерируете кворумный трафик на каждый запрос. clientv3.WithSerializable() даёт локальное чтение с любой реплики: быстро, но может вернуть устаревшего лидера. Для решений о владении — никогда.

Характерные строки в логах:

{"level":"warn","msg":"apply request took too long","took":"1.203s",
 "expected-duration":"100ms","request":"lease_grant:<ttl:15-second>"}
{"level":"warn","msg":"lease keepalive response queue is full; dropping response send"}
rpc error: code = NotFound desc = etcdserver: requested lease not found
etcdserver: mvcc: database space exceeded

Первая почти всегда означает диск, а не сеть: etcd_disk_wal_fsync_duration_seconds p99 должен быть меньше 10 мс, иначе heartbeat-ы не укладываются в бюджет и начинаются перевыборы. Последняя — сработавший alarm NOSPACE: квота backend по умолчанию 2 ГиБ, рекомендуемый потолок 8 ГиБ, и она кончается, если etcd используют как базу данных вместо координатора.

Жизненный цикл владения: состояние, которое надо явно моделировать

Самая частая архитектурная ошибка — считать владение булевым флагом. Владение имеет пять состояний, и переход в «не знаю» обязан быть обработан отдельно.

Два инварианта, которые стоит вынести в код и в тесты:

  • Suspect останавливает работу. Если система не может отличить «связь моргнула» от «меня уже заменили», она обязана считать, что её заменили. Асимметрия цены: лишняя остановка стоит секунды простоя, лишняя запись — инцидент.
  • После Lost токен недействителен навсегда. Нельзя «перевзять лизу и продолжить с той же позиции». Новый заход — новый токен, повторная инициализация, повторная проверка, что мы всё ещё имеем право писать.

Тайминги и часы: сколько на самом деле длится ваша лиза

Лиза — единственный примитив в координации, который напрямую зависит от часов, поэтому арифметику нужно делать честно. Клиент не владеет ресурсом столько, сколько написано в TTL. Он владеет:

безопасное_владение = TTL
                    − задержка_запроса_туда_и_обратно   (ответ шёл к вам, время уже текло)
                    − возможный_дрейф_часов             (ваши часы могут идти быстрее серверных)
                    − запас_на_обработку                (пока вы решаете писать, время идёт)

Практические следствия:

  • Только монотонные часы. CLOCK_MONOTONIC, time.Since, System.nanoTime(). Никогда — разность двух wall-clock-меток: NTP-коррекция или ручной перевод времени превратит вашу лизу в тыкву. Подробности — время и часы.
  • Клиент считает срок с момента отправки запроса, а не с момента получения ответа. Иначе сетевая задержка добавляется к сроку, а не вычитается из него.
  • Сервер отсчитывает от момента коммита в реплицированный лог, и это единственная временная точка, с которой согласны все реплики.
  • Порог продления — треть TTL. При TTL 15 с продлевайте каждые 5 с: два подряд потерянных heartbeat-а не должны убивать лизу.

Тот же расчёт объясняет требование Kubernetes к параметрам выбора лидера: LeaseDuration > RenewDeadline > RetryPeriod × 1.2. Дефолты kube-controller-manager — 15 с / 10 с / 2 с. Если поставить RenewDeadline больше LeaseDuration, вы получите легальную конфигурацию, в которой два контроллера считают себя лидерами штатно, а не по случайности.

E0712 09:41:12.447213  1 leaderelection.go:369] Failed to update lock:
  Put "https://10.0.0.1:6443/apis/coordination.k8s.io/v1/namespaces/kube-system/
  leases/kube-controller-manager": context deadline exceeded
I0712 09:41:12.447301  1 leaderelection.go:283] failed to renew lease
  kube-system/kube-controller-manager: timed out waiting for the condition
F0712 09:41:12.447355  1 controllermanager.go:293] leaderelection lost

Обратите внимание на уровень последней строки: F — fatal. Kubernetes на потерю лидерства убивает процесс, а не пытается продолжить. Это не грубость, это единственная надёжная реализация состояния Lost: после exec заново нельзя случайно остаться с устаревшим состоянием в памяти.

И важная честность: в комментарии к client-go/tools/leaderelection прямо написано, что реализация не гарантирует единственного лидера и не делает fencing. Контроллеры Kubernetes безопасны не потому, что лидер один, а потому, что их операции идут через API-сервер с проверкой resourceVersion — то есть fencing выполняет ресурс, ровно по нашему правилу. Подробнее про устройство кластера — Kubernetes.

Redis, Redlock и почему спор 2016 года всё ещё актуален

Самый популярный распределённый лок в мире — три строки на Redis:

import uuid, redis

r = redis.Redis()
token = str(uuid.uuid4())

# NX — только если ключа нет; PX — TTL в миллисекундах. Атомарно.
acquired = r.set("lock:shard-7", token, nx=True, px=15_000)

# Освобождение ОБЯЗАНО быть атомарной проверкой владельца.
# Голый DEL удалит чужой лок, если наш TTL истёк, пока мы работали.
UNLOCK = """
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0
end
"""
if acquired:
    try:
        do_work()
    finally:
        r.eval(UNLOCK, 1, "lock:shard-7", token)

Это корректная реализация для задач эффективности, и она покрывает 80 % реальных применений. Что она не даёт:

  • Токен token — не fencing-токен. Он случайный, а не монотонный: ресурс не может сравнить два UUID и понять, какой новее. Fencing на нём не построить.
  • Одиночный Redis теряет лок при failover. Мастер подтвердил SET NX, реплика ещё не получила команду, мастер упал, Sentinel повысил реплику — лок исчез, его сразу берёт второй клиент. Асинхронная репликация не переживает failover без потери подтверждённых записей.
  • Redlock не чинит главное. Алгоритм из документации Redis берёт лок на большинстве из N независимых мастеров. Клеппманн в «How to do distributed locking» показал, что алгоритм опирается на ограниченный дрейф часов и ограниченные паузы процессов; ответ Сальваторе Санфилиппо уточняет допущения, но не отменяет вывода: без fencing-токена корректность не гарантируется ни одним локом, включая Redlock. Спор полезно прочитать целиком — он учит отличать «работает у меня» от «доказуемо безопасно».

Практический вывод: Redis-лок — отличный дедупликатор работы и плохая гарантия корректности. Если задача про деньги или про целостность данных — берите координатор с монотонными ревизиями, а проверку кладите в ресурс. Про сам Redis — отдельная статья.

Отдельно упомянем advisory-локи PostgreSQL (pg_advisory_lock, pg_try_advisory_lock): они привязаны к сессии, снимаются при её обрыве, не имеют TTL и потому не страдают от «истёк, пока я работал». Если у вас уже есть надёжная база и одна её реплика-мастер, это часто честнее, чем тащить в стек ещё один кластер. Ограничение очевидное: лок живёт ровно столько, сколько живёт мастер базы, и не переживает её failover.

Координация, спрятанная внутри Cassandra, Kafka и Spanner

Отдельный координатор — не единственный способ. Зрелые системы прячут те же примитивы внутри себя, и полезно уметь их узнавать: вы платите за консенсус в любом случае, вопрос только в том, видите ли вы счёт.

Cassandra: Paxos на одну партицию. Обычная запись в Cassandra — это last-write-wins по временной метке, никакой взаимоисключаемости. Но IF NOT EXISTS и IF <условие> (lightweight transactions) переключают путь записи на честный Paxos:

-- Классический рецепт «лока» в Cassandra: захват владения шардом.
-- ttl даёт лизу, IF NOT EXISTS даёт атомарный CAS.
INSERT INTO shard_owner (shard_id, owner_id)
VALUES (7, 'worker-a') IF NOT EXISTS USING TTL 15;

-- Продление и освобождение — тоже через условие, иначе перетрёте чужое владение.
UPDATE shard_owner USING TTL 15 SET owner_id = 'worker-a'
 WHERE shard_id = 7 IF owner_id = 'worker-a';

Что здесь важно понимать. Во-первых, LWT стоит четыре round-trip вместо одного (prepare/promise, read, propose/accept, commit) и требует кворума SERIAL — на межрегиональном кластере это десятки миллисекунд и падение пропускной способности примерно на порядок; LOCAL_SERIAL ограничивает Paxos одним ДЦ и снова превращает лок в локальный. Во-вторых, атомарность действует строго внутри одной партиции — Paxos-раунд ведётся по ключу партиции, координировать две партиции нельзя. В-третьих, читать значение, записанное через LWT, нужно тоже на SERIAL: обычное чтение QUORUM может увидеть принятое, но ещё не закоммиченное предложение. И главная ловушка: смешивать LWT и обычные записи по одной и той же партиции нельзя — обычная запись не проходит через Paxos и молча затрёт результат CAS.

Сценарий отказа выглядит как таймаут с особым типом:

com.datastax.oss.driver.api.core.servererrors.WriteTimeoutException:
  Cassandra timeout during CAS write query at consistency SERIAL
  (2 replica were required but only 1 acknowledged the write), writeType=CAS

writeType=CAS означает «раунд Paxos не завершился» — и это неизвестный исход, а не отказ: предложение могло быть принято частью реплик и будет докоммичено следующим раундом. Ретрай INSERT ... IF NOT EXISTS вернёт [applied]=false с уже вашим собственным owner_id, и наивный код решит, что лок занят кем-то другим. Обрабатывать надо так: при WriteTimeout с writeType=CAS перечитать состояние на SERIAL и сравнить owner_id с собой.

Kafka: фенсинг вместо блокировок, на всех уровнях. В Kafka нет ни одного распределённого лока в привычном смысле — вместо них везде эпохи. Партицию пишет только лидер, и его полномочия ограничены leader epoch; consumer group не «блокирует» партицию, а получает её в назначение от координатора группы вместе с generation id; транзакционный продюсер регистрирует transactional.id и получает producer epoch. Все три — fencing-токены в чистом виде, и все три ломаются одинаково громко:

org.apache.kafka.clients.consumer.CommitFailedException: Offset commit cannot be
  completed since the consumer is not part of an active group for auto partition
  assignment; it is likely that the consumer was kicked out of the group.

org.apache.kafka.common.errors.ProducerFencedException: There is a newer producer
  with the same transactionalId which fences the current one.

Первая строка — ровно наш сценарий с паузой: воркер не прислал heartbeat, координатор переназначил партицию другому, и брокер отверг коммит оффсета от устаревшего поколения. Заметьте, кто отверг — ресурс, а не клиент. Вторая — то же самое для продюсера: старый экземпляр после паузы физически не может записать в топик, потому что его эпоха меньше. Именно это, а не магия, стоит за «exactly-once»-режимом Kafka; разбор — в статье про гарантии доставки и дальше в очередях и потоках.

Spanner: как выглядит лиза, которой можно доверять. Spanner (Corbett et al., OSDI 2012) держит лидера каждой Paxos-группы на лизе длиной 10 секунд, продлеваемой при каждой успешной записи. Отличие от вашего лока на etcd не в алгоритме, а в часах: Spanner опирается на TrueTime — API, который возвращает не момент, а интервал [earliest, latest] с доказанной границей неопределённости (единицы миллисекунд, обеспечены атомными часами и GPS-приёмниками в каждом ДЦ). Инвариант «интервалы лидерства двух последовательных лидеров не пересекаются» проверяется явно: новый лидер ждёт, пока TT.after(лиза_предыдущего) станет истиной.

Вывод для нас практический и отрезвляющий: безопасная лиза покупается за ограниченную ошибку часов, и Google купил её железом на сотни миллионов. У вас NTP с ошибкой, которая формально не ограничена ничем, поэтому ваша лиза остаётся вероятностной — и fencing-токен обязателен. Подробнее про TrueTime и коммит-ожидание — в статье про время и часы.

«Ровно один исполнитель» — тот же миф, что exactly-once

Формулировка «блокировка гарантирует, что задача выполнится ровно один раз» — прямой родственник мифа об exactly-once-доставке, и разваливается по той же причине.

Разложим «задача выполнилась один раз» на события: взял локвыполнил работузафиксировал эффектотпустил лок. Между любыми двумя соседними шагами процесс может умереть, зависнуть или потерять лизу. Ни одна пара шагов не атомарна, потому что они происходят в разных системах: координатор, ваш процесс и ресурс — три независимых участника.

Значит, честная формулировка звучит так:

Лок даёт at-most-one владельца в глазах координатора и at-least-once выполнение работы. Пересечение «ровно один раз» получается не из лока, а из свойств эффекта.

Что делают вместо погони за «ровно один раз»:

  1. Fencing-токен + условная запись — эффект физически невозможен от устаревшего владельца (разобрано выше).
  2. Идемпотентный ключ операции — эффект применяется по ключу, повтор с тем же ключом ничего не меняет. Схема из статьи про гарантии доставки.
  3. Единственность по построению вместо лока — не «кто первый взял лок, тот обрабатывает шард», а «шард 7 всегда обрабатывает владелец партиции 7». Ровно так работают consumer group в Kafka: не блокировки, а назначение владения партициями, где смена владельца сопровождается ростом leader epoch — тем же fencing-токеном.
  4. Транзакционный outbox — эффект и запись о нём фиксируются одной локальной транзакцией.

Полезная проверка на зрелость дизайна: если выключить блокировку совсем, данные испортятся? Если да — вы полагаетесь на лок как на гарантию корректности, а он её не даёт. Если нет (будет только лишняя работа) — вы построили систему правильно, и лок работает как оптимизация.

Как не координироваться

Координация — самый дорогой примитив в распределённой системе: она линеаризует то, что могло идти параллельно, и превращает независимые отказы в общий. Каждый лок — это участок кода, который выполняется последовательно на весь кластер, со всеми последствиями закона Амдала. Поэтому первый вопрос всегда: можно ли не координироваться вообще?

Теоретический фундамент у этого дерева есть. I-confluence (Bailis et al., «Coordination Avoidance in Database Systems», VLDB 2015) даёт критерий: инвариант можно поддерживать без координации тогда и только тогда, когда объединение любых двух допустимых состояний остаётся допустимым. Ограничение «баланс ≥ 0» этому не удовлетворяет — два независимых списания дают отрицательный баланс, координация обязательна. Ограничение «множество тегов» удовлетворяет — объединение множеств всегда допустимо, координация не нужна, отсюда и CRDT.

Родственный результат — CALM (Hellerstein, Alvaro, «Keeping CALM: When Distributed Consistency Is Easy»): программа имеет консистентную распределённую реализацию без координации тогда и только тогда, когда она монотонна — новые факты только добавляют выводы, но не отменяют старые. Практический перевод: «добавить в множество» и «взять максимум» координации не требуют, «удалить» и «проверить, что чего-то нет» — требуют.

Практические приёмы, вытекающие отсюда:

  • Координируйтесь на смене владения, а не на каждой операции. Взять лидерство на шард один раз в час стоит копейки; брать лок на каждый из 10 000 RPS — значит превратить координатор в узкое место и точку отказа.
  • Делайте гранулярность владения крупной, а число ключей — маленьким. 64 партиции с закреплёнными владельцами лучше, чем 10 миллионов локов по идентификатору сущности.
  • Единственный писатель на партицию снимает вопрос конфликтов вообще: см. партиционирование.
  • Не держите в координаторе бизнес-данные. etcd и ZooKeeper — про метаданные килобайтного размера и низкую частоту записи. Это прямым текстом сказано ещё в статье о Chubby: главным сюрпризом для авторов стало то, что разработчики начали использовать сервис блокировок как файловое хранилище.

Инструменты: что выбрать

Инструмент Основа Монотонный токен Эфемерность Когда брать
etcd Raft revision lease + keepalive Kubernetes-стек, Go, нужен простой API и watch по префиксу
ZooKeeper ZAB zxid / czxid session + ephemeral JVM-экосистема: Kafka (до KRaft), HBase, Flink, HDFS HA
Consul Raft ModifyIndex session + TTL + LockDelay уже есть как service discovery, нужны health-checks в связке
Chubby Paxos lock generation number session lease + grace недоступен снаружи Google; читать как первоисточник
Redis / Redlock репликация, не консенсус нет TTL, не связан с сессией только «эффективность»: дедупликация работы, троттлинг
PostgreSQL advisory одиночный мастер txid косвенно привязка к сессии база уже есть, кластер не нужен, failover редок
DynamoDB / S3 условные записи сервис облака версия объекта нет, нужен явный TTL-атрибут serverless, ресурс и координатор — один и тот же сервис

Первоисточник, который стоит прочитать целиком, — «The Chubby lock service for loosely-coupled distributed systems» (Burrows, OSDI 2006). Там уже есть всё, что мы обсуждали: session lease по умолчанию 12 с, grace period 45 с на переживание перевыборов мастера, sequencer — буквально fencing-токен с проверкой на стороне сервера, и lock-delay как запасной вариант для ресурсов, которые проверять не умеют. Двадцать лет спустя ничего принципиально нового не добавилось — добавились удобные обёртки.

Типичные ошибки

  1. Лок без fencing-токена там, где важна корректность. Самая дорогая. Признак: в коде есть lock.acquire(), но ни одна запись не содержит проверки версии.
  2. Проверка токена на стороне клиента. Между if token_valid() и write() помещается вся пауза GC. Проверять обязан ресурс, одной атомарной операцией.
  3. Игнорирование состояния «не знаю». SUSPENDED в Curator, context deadline exceeded в etcd, разрыв TCP — всё это обязано останавливать работу немедленно.
  4. Keepalive в отдельном потоке от работы. Порождает зомби-владельцев: лиза продлевается, работа стоит, лок никто не получит.
  5. Кластер координатора из 2 или 4 узлов. Чётное число не повышает отказоустойчивость, но увеличивает вероятность потери кворума. 3 или 5, и никогда не в одной зоне доступности.
  6. Координатор как база данных. Терабайт бизнес-данных в etcd — гарантированный mvcc: database space exceeded; 200 000 детей в одном znode — гарантированный отказ ZooKeeper.
  7. Лок на горячем пути. Лок на каждый запрос превращает координатор в узкое место, а его перевыборы — в отказ всего сервиса.
  8. TTL «побольше, чтобы не срывалось». Вы не убрали зазор, вы удлинили простой после честного падения.
  9. Watch как источник данных. Watch говорит «что-то изменилось», а не «вот новое значение». Всегда перечитывайте состояние после уведомления.
  10. Один координатор на всю компанию. etcd, обслуживающий и Kubernetes, и прикладные локи, делает деградацию приложения деградацией всего кластера.

Мини-итог

  • Мьютекса по сети не бывает: нет общей памяти, нет надёжного детектора отказов, есть частичные отказы. Есть только лиза — аренда с истечением.
  • Лиза гарантирует, что владельцев в конце концов станет не больше одного, но не гарантирует, что их не двое сейчас. Пауза GC на 14 секунд — рядовое событие, а не экзотика.
  • Двух владельцев не предотвратить; предотвращают вред от них. Единственный работающий механизм — fencing-токен: монотонное число от консенсуса, проверяемое на стороне ресурса одной атомарной операцией.
  • etcd, ZooKeeper, Consul и Chubby — один и тот же прибор под разными именами: реплицированный лог + атомарный CAS + монотонная ревизия + эфемерность + watch. Освоив один, переносите рецепты за час.
  • Тот же прибор встроен в системы, которые вы уже используете: Paxos на партицию в Cassandra (IF NOT EXISTS), эпохи лидера, поколения группы и эпохи продюсера в Kafka, лизы лидера в Spanner. Spanner — единственный из списка, у кого лиза безопасна сама по себе, и куплено это ограниченной ошибкой часов TrueTime, а не алгоритмом.
  • Владение — не булев флаг, а автомат с состоянием «не знаю». В этом состоянии работа останавливается; после потери сессии старый токен мёртв навсегда.
  • «Ровно один исполнитель» — тот же миф, что exactly-once. Реально получаете at-least-once выполнение, а единственность эффекта строите идемпотентностью, условной записью и статическим закреплением владения.
  • Самая дешёвая координация — отсутствующая. I-confluence и CALM дают критерии, когда без неё можно обойтись; партиционирование владения и идемпотентность решают большинство задач без единого лока.

Источники

Что дальше

Координация отвечает на вопрос «кто имеет право действовать». Следующий уровень — как эти действия доезжают друг до друга: порядок сообщений, обратное давление и повторы, из-за которых рождается вся тема идемпотентности.

Очереди и потоки: брокеры, порядок сообщений, backpressure, повторы

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов