DarkRiDDeR15 мин

Lease-блокировка: что проверить до критической секции

НадёжностьBackendПрактика

Симптом выглядит как редкая гонка: worker-A взял блокировку для пересборки отчёта, остановился дольше lease, а worker-B после истечения уже выполнил ту же критическую секцию. Затем worker-A просыпается и отправляет старую запись. Цена ошибки не в том, что два процесса увидели один lock. Цена в том, что поздняя запись может перетереть более новое состояние, хотя второй worker действовал по выданному ему lease.

В мае 2021 года я бы не называл распределённую блокировку универсальной защитой. Lease ограничивает период владения в lock authority. Он не отменяет уже созданный запрос, не останавливает paused процесс и не заставляет внешний ресурс спросить authority перед записью. Поэтому ниже есть маленький договор из owner, leaseId, fence token и проверки на стороне ресурса. Все workers, lease, token, значения ресурса и времена ниже учебные. Модель работает только с Map и счётчиком времени в памяти: она не запускает etcd, Redis, ZooKeeper, базу, broker, HTTP, сетевой тест, синхронизацию часов или cluster.

Сначала разделяем lock и ресурс, который меняется

Критическая секция часто записывается одной фразой: «взяли lock — обновили данные». В ней скрыты два разных состояния. Lock authority знает, кому сейчас принадлежит имя report:417:rebuild и когда истекает lease. Защищаемый ресурс знает, какое значение он уже принял. Если ресурсом является таблица, файл, API партнёра или другой сервис, lock authority не получает автоматического права отклонять его поздние запросы. Эту границу надо назвать до выбора клиента и TTL.

Учебный контракт одной критической секции
ПолеКто владеетЧто проверяемЧего поле не доказывает
lockNamelock authorityоба worker спорят за один и тот же предметчто все связанные ресурсы вошли в тот же lock
ownerIdклиент/журнал запросакакой worker отправил действиечто процесс всё ещё исполняется или имеет право писать
leaseIdlock authorityrelease относится к текущему захватучто старый request уже исчез из сети
fenceTokenпоколение владения и ресурсresource принимает только token больше последнего принятогочто token сам по себе отменяет старый request
acceptedFenceTokenзащищаемый ресурспоздний владелец не перезапишет новое значениечто resource получил все запросы или делает exactly-once

Здесь ownerId нужен для наблюдения, а не для авторизации. leaseId позволяет не снять чужой lock при запоздалом release. Fence token — это монотонное поколение одного защищаемого предмета. В учебной модели это числа 1 и 2. В реальной схеме способ получить такое поколение зависит от выбранного провайдера и ресурса; нельзя произвольно объявить его строковый key или timestamp. Главное требование находится ниже по цепочке: ресурс должен хранить последний принятый token и уметь отклонить меньший или равный.

Lease даёт окно владения, а не вечное разрешение

Давайте начнём с захвата. В модели worker-A получает lease на 5000 мс и token 1. Число выбрано только для читаемого interleaving. Оно не является рекомендацией TTL: в реальной работе его нельзя выбрать без ограничения операции, сети, keepalive и поведения конкретного провайдера. Мы также не берём системное время. Учебный clock двигается вручную, чтобы порядок событий нельзя было случайно изменить нагрузкой машины.

const lease = authority.acquire({
  lockName: 'report:417:rebuild',
  ownerId: 'worker-A',
  leaseMs: 5000,
});

// Учебный результат: leaseId=training-lease-1, fenceToken=1.
// Token принадлежит поколению владения, не времени на часах worker-A.

Официальный API etcd v3.4 полезен как историческая граница: Lock возвращает key на время владения, а lease автоматически освобождает lock при истечении или отзыве. В API lease запрошенный TTL advisory, а ответ возвращает TTL, выбранный server. Из этого не следует, что значение key можно без проверки передать в произвольную базу как fencing token. Документация обещает lifecycle lock внутри etcd, а не правило записи в другой ресурс.

Вертикальная временная шкала учебного interleaving: worker-A получает lease-1 и token 1, останавливается после истечения lease, worker-B получает lease-2 и token 2, ресурс принимает запись token 2 и отклоняет позднюю запись token 1
Истечение lease освобождает имя для нового владельца, но не отзывает уже вычисленную работу старого процесса.

Проверяем interleaving до интеграции с провайдером

Воспроизводимый случай состоит из шести событий. В t=0 worker-A получил lease-1. В t=6000 модель отмечает его истекшим: worker-A не присылал keepalive, но сам объект worker-A не уничтожается. В тот же момент worker-B получает lease-2 с token 2. Он первым меняет ресурс. В t=6200 старый worker-A продолжает ранее начатую работу. Самая важная строка — не выдача второго lease, а ответ ресурса на request с token 1.

t=0     worker-A acquire lease-1, fence=1
t=6000  worker-A всё ещё paused; lease-1 уже истёк
t=6000  worker-B acquire lease-2, fence=2
t=6100  resource.write(fence=2, value=v2) -> accepted
t=6200  worker-A resumes
t=6200  resource.write(fence=1, value=v1) -> rejected-stale-fence

// Владелец lease-1 не исчезает из памяти процесса только потому,
// что authority уже выдал lease-2 другому worker.

Такой порядок не требует утверждать, что реальные часы точны или что провайдер всегда мгновенно наблюдает остановку клиента. Он фиксирует более простой факт: request может жить дольше представления worker о своём владении. Поэтому проверка lease is active только перед началом работы недостаточна. Между проверкой и write lease может закончиться, другой worker получить новое поколение, а первый request всё равно дойти до ресурса.

Ресурс принимает только новое поколение

Проверка fencing должна находиться там, где появляется эффект. В модели ресурс хранит acceptedFenceToken. Request с token 2 увеличивает это значение и записывает report-v2-from-worker-B. Следующий request с token 1 не сравнивается с локальным clock worker-A и не делает сетевой запрос к authority. Ресурс видит уже принятый 2 и возвращает rejected-stale-fence. Так поздняя работа становится наблюдаемым отказом, а не тихой порчей значения.

function writeProtectedResource(state, request) {
  if (request.fenceToken <= state.acceptedFenceToken) {
    return { status: 'rejected-stale-fence' };
  }

  state.acceptedFenceToken = request.fenceToken;
  state.value = request.value;
  return { status: 'accepted' };
}

// Важна проверка внутри ресурса, который меняется.

Требование строгого > имеет смысл только для одного доменного предмета и одной линии поколений. Если два независимых действия действительно могут выполняться параллельно, им не стоит навязывать общий token ради удобства. Если одно действие зависит от другого, оба должны указывать на один и тот же ресурсный contract. Важно не число как такое, а то, кто хранит последнее принятое значение и в какой атомарной операции он его сравнивает с request.

Контраст: lock без fencing оставляет позднюю запись допустимой

Ниже намеренно показан небезопасный вариант. Он не проверяет token вообще. Worker-B сначала записывает v2, затем старый worker-A записывает old-v1. Такой ресурс не знает, что lock уже менял владельца, поэтому обе записи выглядят ему одинаково допустимыми. Наличие lease в другом компоненте не меняет этот результат.

function writeWithoutFence(state, request) {
  state.value = request.value;
  return { status: 'accepted-without-fence' };
}

writeWithoutFence(state, { fenceToken: 2, value: 'v2' });
writeWithoutFence(state, { fenceToken: 1, value: 'old-v1' });
// state.value === old-v1: поздний владелец переписал новое значение.

Этот контраст не говорит, что fencing автоматически подходит к любому ресурсу. В API без условной записи, версии, сравнения или server-side проверки token некуда положить правило. Тогда сначала надо уменьшить критическую секцию до ресурса с контролируемой транзакционной границей либо выбрать другой contract эффекта. Просто увеличить TTL, повторить acquire или написать в лог «lock held» не превращает операцию в безопасную.

Release тоже принадлежит конкретному захвату

После t=6200 worker-A может попытаться честно освободить то, что он когда-то взял. Если release принимает только имя lock, старый запрос способен удалить lease worker-B. Поэтому release должен сверять owner, leaseId и поколение текущего захвата. В нашей модели старый release получает release-rejected-not-owner; worker-B продолжает владеть lease-2. Это отдельная проверка от fencing: она защищает lock authority, а не запись в ресурс.

function releaseCurrent(active, request) {
  const ownsCurrentLease = active.ownerId === request.ownerId
    && active.leaseId === request.leaseId
    && active.fenceToken === request.fenceToken;

  return ownsCurrentLease ? { status: 'released' }
    : { status: 'release-rejected-not-owner' };
}

// Старый worker-A не должен снять lease-2, выданный worker-B.

Не стоит заменять эту проверку случайной строкой owner. Owner нужен, чтобы оператор видел автора действия. Для release важен неповторимый handle текущего захвата, который выдал provider. Для защищённой записи важен token, который ресурс способен сравнить. Один идентификатор может участвовать в обоих протоколах только если это явно задокументировано и проверено в конкретной интеграции. Учебная модель намеренно не делает такого вывода.

Маршрут проверки перед критической секцией

  1. Назвать один предмет блокировки и один ресурс, который реально меняется. Если список ресурсов разный, не скрывать это за одним широким lockName.
  2. Зафиксировать, что provider считает окончанием владения: явный release, истечение lease, отзыв сессии или другой документированный исход.
  3. Проверить, какой handle относится к release. Старый worker не должен иметь возможность снять более новый lease.
  4. Определить монотонный token для каждого защищаемого предмета. Не выводить его из wall clock и не считать произвольный provider key достаточным без контракта.
  5. Добавить в ресурс атомарную проверку: принять request только если token больше последнего принятого, иначе вернуть отдельный stale result.
  6. Собрать fixture с паузой владельца после expiry, вторым захватом и поздней записью первого worker. Отдельно показать, что вариант без проверки переписывает значение.
  7. Только затем проверять выбранный provider на его версии, TTL, keepalive, сбои и нагрузку. Эти тесты не заменяют ресурсную границу fencing.

Историческая рамка и пределы учебного примера

Для исторической рамки мая 2021 здесь выбран versioned API etcd v3.4. Статья не переносит в него возможности более поздних линий. Документация v3.4 описывает lease и lock key, но не обещает универсальный token для чужой базы или API. Первичная работа Chubby ещё прямее разделяет ответственности: locks advisory, sequencer передаётся получателю, а получатель проверяет его актуальность. Это полезная модель мышления, не инструкция по установке Chubby.

В пакете не запускались etcd, Redis, ZooKeeper, база, HTTP, provider SDK, cluster, keepalive, реальные часы, синхронизация времени, нагрузочный тест, browser, CI, production build или deployment. Нет claim о provider SLA, clock correctness, exactly-once или работе на реальных данных. Следующий шаг после статьи — выбрать один фактический ресурс и доказать его условную запись в отдельном интеграционном тесте. Если ресурс не умеет отвергать старый token, lease следует считать координацией, а не защитой от позднего эффекта.

Проверяемые источники

  • etcd v3.4: Concurrency API Reference — Lock и Unlock — версионная линия v3.4 существовала до мая 2021 года: Lock возвращает key на время владения, а истечение или отзыв привязанного lease освобождает lock; документация не объявляет этот key универсальным fencing token для внешнего ресурса
  • etcd v3.4: API Reference — LeaseGrant и LeaseKeepAlive — LeaseGrant получает advisory TTL; LeaseGrantResponse возвращает TTL, выбранный server. Без keepAlive lease истекает, а прикреплённые keys удаляются; учебная длительность ниже не является настройкой etcd
  • Mike Burrows, The Chubby lock service for loosely-coupled distributed systems, OSDI 2006 — первичная публикация: locks advisory, а sequencer передаётся защищаемому серверу, который сам проверяет актуальность и отклоняет устаревший запрос; это исторический пример границы fencing