DarkRiDDeR16 мин

Fence token: почему lock не защищает поздний запрос сам по себе

BackendАрхитектураНадёжность

Симптом сложнее обычного 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 остаётся только полем в логе.

Граница ответственности для token 1 и token 2
СобытиеЧто знает 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может не участвовать в writehighest token равен 2сохранить v2
поздний request с token 1может уже видеть lease-2highest 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, повтор запроса или запись бизнес-эффекта.

Вертикальная схема границы fencing: lock authority выдаёт worker-A token 1, после истечения lease выдаёт worker-B token 2; защищаемый ресурс принимает token 2 и отклоняет запоздалый request с token 1 по своему highest accepted token
Authority выдаёт поколения владения; обязательная защита появляется только в ресурсе, который сравнивает token со своим сохранённым состоянием.

Почему нельзя проверить 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-Bresource-side fence
fenceTokenстрого больше highest token ресурсаtoken 1 приходит после принятого token 2идемпотентность одного effect
operationIdс ledger операцииповтор delivery того же business actionпорядок поколений владельца
leaseIdточное равенство текущему leaserelease старого 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 в своём коде

  1. Найти финальный write, который действительно создаёт риск: изменение строки, отправку команды, создание файла или вызов внешнего API.
  2. Определить owner и точный handle текущего lease для acquire/release; не использовать старый handle после reconnect как доказательство владения.
  3. Выбрать монотонное поколение для одного resource key. Документировать, откуда оно берётся и какие значения ресурс считает устаревшими.
  4. Сделать compare token и запись значения одной ресурсной операцией. Отдельное чтение highest token перед write оставляет новую гонку.
  5. Вернуть различимый результат stale fence. Не маскировать его под timeout или успешное выполнение.
  6. Добавить учебный или интеграционный interleaving: token 1 pause, expiry, token 2 accepted, поздний token 1 rejected.
  7. Отдельно проверить 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