Симптом сложнее обычного duplicate: новый worker уже записал верное значение, но через несколько секунд приходит запрос от старого владельца lease и возвращает 200. Цена — не только испорченные данные. После такого ответа команда может решить, что lock provider выдал два владения одновременно, хотя реальная ошибка находится в другом месте: ресурс вообще не знал о поколении lock.
Fence token нужен именно для этой границы. Он не останавливает старый worker и не удлиняет lease. Это монотонное число или другая сравнимая версия, которую request несёт к защищаемому ресурсу. Ресурс принимает новое поколение и запоминает его; меньший token отклоняет. Все workers, lease, token, значения ресурса и времена ниже учебные. Модель работает только с Map и счётчиком времени в памяти: она не запускает etcd, Redis, ZooKeeper, базу, broker, HTTP, сетевой тест, синхронизацию часов или cluster. Поэтому пример ниже не делает вывод о реализации конкретного provider и не выдаёт Map за distributed lock.
У lock authority и ресурса разные вопросы
Lock authority отвечает на вопрос: «кому сейчас можно выдать следующее владение именем?». Ресурс отвечает на другой: «можно ли этому request изменить моё состояние после уже принятого поколения?». Первый вопрос обычно решается lease и ожиданием. Второй — условной записью, compare-and-set, транзакцией с версией или явной проверкой в server-side обработчике. Если второму вопросу негде жить, token остаётся только полем в логе.
| Событие | Что знает authority | Что знает ресурс | Безопасный исход |
|---|---|---|---|
| worker-A получил token 1 | активен lease-1 | ещё не видел write | ожидать или принять token 1 |
| lease-1 истёк | имя снова доступно | может всё ещё ждать request от A | не делать вывод, что A остановлен |
| worker-B получил token 2 | активен lease-2 | ещё не обязан знать о B | передать token 2 вместе с write |
| ресурс принял token 2 | может не участвовать в write | highest token равен 2 | сохранить v2 |
| поздний request с token 1 | может уже видеть lease-2 | highest token равен 2 | отклонить stale request |
Последняя строка важна: ресурс не обязан спрашивать lock authority, активен ли у A lease. Такой запрос мог бы добавить ещё одну гонку и зависимость. Ему достаточно собственного монотонного факта: token 2 уже принят для этого предмета. Это не доказывает, что worker-B выполнил весь бизнес-процесс, но предотвращает конкретный поздний write от token 1. Цель fencing всегда должна быть названа так узко.
Что именно делает ресурсная проверка
В учебной функции состояние ресурса содержит acceptedFenceToken. Перед записью сравниваем request с этим состоянием. В настоящем SQL это может быть условие в одном UPDATE; в объектном storage — версия при записи; в сервисе — проверка и хранение token внутри его транзакционной границы. Форма меняется, инвариант нет: чтение последнего token и фиксация нового значения не должны разъехаться между двумя независимыми действиями.
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 как новый. Равенство часто означает повторный request того же владельца. Можно отдельно проектировать идемпотентный ответ для одинакового operation id, но это другой contract. В минимальной модели token служит только для сравнения поколений, поэтому <= отвергается. Не нужно смешивать этот результат с answer про delivery, повтор запроса или запись бизнес-эффекта.
Почему нельзя проверить lock только в начале
Частая последовательность выглядит разумно: acquire, проверить lease, сделать дорогую работу, записать результат. Но проверка в начале относится к моменту до работы. Если процесс остановился на GC, в ожидании базы, в debugger или на медленном ответе, его локальная память всё ещё содержит успешный acquire. После истечения lease authority может законно выдать новое владение. Когда старый worker проснётся, его успешная проверка из прошлого ничего не говорит о праве на текущий write.
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.
Продление lease меняет только длину окна и добавляет свой protocol: кто и когда подтвердил keepalive, что считать неуспехом, сколько продлений допустимо. Оно не отменяет необходимость рассуждать о request, который уже ушёл или уже вычислен. Поэтому в критичной операции сначала нужен ресурсный fence. После этого можно обсуждать продление как способ уменьшить лишнюю работу, но не как доказательство того, что процесс никогда не станет stale.
Исторический пример не превращает key в token
В versioned документации etcd v3.4 Lock возвращает уникальный key, существующий пока caller владеет lock; expiry lease освобождает lock. Это полезная семантика ownership в самом etcd. Но API не говорит: «возьмите этот key, и внешняя PostgreSQL, файловый сервис или партнёрский API автоматически начнёт отвергать старые запросы». Поэтому нельзя называть любой id fencing token только потому, что он уникален. Нужны порядок поколений и ресурсная проверка этого порядка.
Работа Chubby 2006 описывает похожее разделение языком sequencer. Locks там advisory: они конфликтуют с захватом того же lock, но сами не делают произвольный файл или сервис недоступным. Клиент передаёт sequencer серверу, который ожидаемо проверяет его актуальность и нужный режим; при невалидности запрос отклоняется. Это не означает, что каждый provider даёт готовый sequencer, зато показывает, почему обязательство находится у получателя write.
Owner, token и idempotency не взаимозаменяемы
Owner отвечает на вопрос «кто отправил запрос». Fence token отвечает «это поколение новее последнего принятого?». Idempotency key отвечает «выполняли ли уже именно эту доменную операцию?». Иногда одно сообщение несёт все три поля, но проверять их нужно по разным правилам. Замена их одним requestId делает журнал удобнее, а поведение — неяснее: старый request может иметь уникальный id, но всё равно не иметь права перезаписывать новый результат.
| Поле | Сравнение | Пример отказа | Не заменяет |
|---|---|---|---|
ownerId | с текущим handle при release | старый A пытается снять lease-B | resource-side fence |
fenceToken | строго больше highest token ресурса | token 1 приходит после принятого token 2 | идемпотентность одного effect |
operationId | с ledger операции | повтор delivery того же business action | порядок поколений владельца |
leaseId | точное равенство текущему lease | release старого handle | право на write после expiry |
Эта таблица не требует строить большую платформу. Даже в одном сервисе она помогает не склеить диагностику: stale fence — это не duplicate delivery, а повторный operation id — не сигнал, что можно разрешить token 1. В начале достаточно договориться, какое из состояний хранится рядом с ресурсом и что вернёт API при нарушении каждого правила.
Небезопасный контраст полезнее общего обещания
Если ресурс всегда присваивает поле без условия, оба worker смогут получить успешный ответ. Внизу видно, как token 2 записывает v2, а token 1 затем записывает old-v1. Это не ошибка модели: именно так выглядит внешний ресурс, который не участвует в fencing. Нельзя исправить это задним числом логом о том, что A «раньше был владельцем».
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: поздний владелец переписал новое значение.
Если ресурс не предоставляет условную запись, надо прямо записать ограничение. Для некоторых задач можно перенести эффект в транзакционную базу, использовать механизм versioning этого ресурса, отказаться от автоматического действия или вынести его на ручную проверку. Выбор зависит от предмета. Важнее не обещать, что строка acquire перед функцией сама защищает действие, которое провайдер не видит.
Как проверить boundary в своём коде
- Найти финальный write, который действительно создаёт риск: изменение строки, отправку команды, создание файла или вызов внешнего API.
- Определить owner и точный handle текущего lease для acquire/release; не использовать старый handle после reconnect как доказательство владения.
- Выбрать монотонное поколение для одного resource key. Документировать, откуда оно берётся и какие значения ресурс считает устаревшими.
- Сделать compare token и запись значения одной ресурсной операцией. Отдельное чтение highest token перед write оставляет новую гонку.
- Вернуть различимый результат stale fence. Не маскировать его под timeout или успешное выполнение.
- Добавить учебный или интеграционный interleaving: token 1 pause, expiry, token 2 accepted, поздний token 1 rejected.
- Отдельно проверить idempotency и retry, если действие может быть доставлено повторно. Они дополняют fence, но не возникают из него автоматически.
Пределы утверждений
Модель доказывает только девять детерминированных assertions в памяти, включая отклонение token 1 после token 2 и отказ старого release. Она не измеряет latency, не проверяет clock drift, не запускает cluster и не говорит, что etcd v3.4 или другой provider даст нужную последовательность token для вашей базы. Источник v3.4 выбран как доступная к маю 2021 историческая линия; его API надо читать вместе с конкретным client и deployment contract.
В пакете не было реального provider, базы, внешнего resource, HTTP, broker, сети, синхронизации времени, нагрузки, browser, CI, build, deployment или production данных. Следующий практический шаг — не увеличить lease, а показать на выбранном ресурсе атомарный reject старого поколения. Пока этого reject нет, lock полезен для координации работы, но не является доказательством безопасности позднего запроса.
Проверяемые источники
- 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