Из-за временных ограничений на территории РФ наблюдаются проблемы с оплатой. Если платёж не проходит, оставьте запрос в службу поддержки.Служба поддержки работает 24/7 — мы всегда на связи по вопросам хостинга и серверов.Открыт прием заявок на аренду выделенных серверов и размещение оборудования в дата-центре.Напоминаем: рекомендуем включить резервное копирование для дополнительной защиты данных.Доступна новая линейка VPS/VDS с NVMe-дисками и увеличенной производительностью.Технические работы на части серверов завершены. Все сервисы работают в штатном режиме.
Статья8 мин чтенияПросмотры0

TTL-блокировка истекла: как fencing token останавливает старый воркер

На безопасной модели показываем, почему истечение аренды не останавливает старый процесс и как монотонный fencing token не даёт ему перезаписать результат нового владельца.

Комментарии 0

Два автономных робота у промышленного клапана: новый зубчатый токен принят, старый остановлен механизмом
В этой статье

Если фоновый воркер получил аренду задания на пять секунд, это ещё не значит, что после пятой секунды он физически перестанет выполнять код. Процесс может зависнуть, потерять связь с координатором или просто надолго остановиться, а затем продолжить работу уже после того, как задание получил другой воркер. Для записи, от которой зависит корректность данных, к аренде нужен fencing token — монотонно растущий номер владельца. Хранилище принимает новый номер и отклоняет любой более старый.

На маленьком примере ниже первый воркер получает token 1, переживает окончание аренды и пытается записать старый остаток товара. Второй воркер уже получил token 2 и записал более новое значение. Обычная TTL-блокировка допускает позднюю перезапись, а проверка токена сохраняет результат нового владельца.

Что документировано, а что проверено здесь

В документации Hazelcast FencedLock 5.7 описан сценарий с паузой клиента, потерей сессии и новым владельцем; внешний ресурс должен отклонить запрос со старым монотонным токеном. Репозиторий etcd содержит исполняемый пример с той же границей: аренда может истечь, поэтому защищаемое хранилище проверяет версию запроса.

Учебный прогон не использует Hazelcast, etcd, Redis, сеть или 1С-Битрикс. Он отдельно воспроизводит инвариант на CPython 3.12.14: после принятия token 2 запись с token 1 должна завершиться отказом. Это модель механизма, а не отчёт о внедрении у клиента.

Начальные условия

  • CPython 3.12.14 и только стандартная библиотека; прогон выполнен 2 ноября 2026 года.

  • Изолированная модель без сети, файлов, базы данных и фоновых процессов; все данные заданы в коде.

  • Время задаётся целыми условными секундами. Воркер A получает аренду в t=0 до t=5, воркер B пытается войти в t=4 и получает отказ, затем получает новую аренду в t=6.

  • Каждая успешная выдача аренды увеличивает токен: A получает 1, B получает 2.

  • Сначала используется хранилище без проверки токена, затем — хранилище, которое атомарно сравнивает номер с последним принятым.

Момент

Событие

Ожидаемый результат

t=0

A получает аренду и token 1

A может начать работу

t=4

B запрашивает занятую аренду

Отказ LockBusy

t=5

Аренда A заканчивается

Код A сам по себе не останавливается

t=6

B получает token 2 и пишет остаток товара 8

Новое состояние принято

t=7

A возобновляется и пытается записать 5

Запись с token 1 отклонена

Два воркера получают токены 1 и 2: поздняя запись первого отклоняется после записи второго
Учебная шкала времени: аренда A заканчивается в t=5, B получает token 2 в t=6, а запоздавшая запись A с token 1 отклоняется в t=7.

Воспроизводимая модель

Создайте новый временный каталог, сохраните следующий код в файл fencing_token_lab.py и запустите обычным пользователем. Пример ничего не читает, не изменяет и не удаляет вне этого файла.

from dataclasses import dataclass class LockBusy(RuntimeError): pass class StaleWriter(RuntimeError): pass @dataclass(frozen=True) class Lease: owner: str token: int expires_at: int class LockService: def __init__(self) -> None: self._counter = 0 self._lease: Lease | None = None def acquire(self, owner: str, now: int, ttl: int) -> Lease: if ttl <= 0: raise ValueError("ttl must be positive") if self._lease is not None and now < self._lease.expires_at: raise LockBusy(f"held by {self._lease.owner}") self._counter += 1 self._lease = Lease(owner, self._counter, now + ttl) return self._lease class NaiveStock: def __init__(self) -> None: self.value = 0 def write(self, value: int) -> None: self.value = value class FencedStock: def __init__(self) -> None: self.value = 0 self.last_token = 0 def write(self, value: int, token: int) -> None: if token < self.last_token: raise StaleWriter( f"token {token} is older than accepted token {self.last_token}" ) self.value = value self.last_token = token locks = LockService() worker_a = locks.acquire("worker-a", now=0, ttl=5) try: locks.acquire("worker-b", now=4, ttl=5) except LockBusy as error: print(f"before expiry: {error}") worker_b = locks.acquire("worker-b", now=6, ttl=5) assert (worker_a.token, worker_b.token) == (1, 2) print(f"new owner: a={worker_a.token} b={worker_b.token}") naive = NaiveStock() naive.write(8) naive.write(5) assert naive.value == 5 print(f"without fence: stock={naive.value}") safe = FencedStock() safe.write(8, worker_b.token) try: safe.write(5, worker_a.token) except StaleWriter as error: print(f"stale write: {error}") assert safe.value == 8 and safe.last_token == 2 safe.write(9, worker_b.token) assert safe.value == 9 and safe.last_token == 2 print(f"with fence: stock={safe.value} last_token={safe.last_token}")

Команда запуска:

python3 fencing_token_lab.py

Полученный вывод:

before expiry: held by worker-a new owner: a=1 b=2 without fence: stock=5 stale write: token 1 is older than accepted token 2 with fence: stock=9 last_token=2

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

Что показал прогон

Проверка

Без fencing token

С проверкой токена

B до t=5

Блокировка занята

Блокировка занята

B после t=5

Пишет значение 8

Пишет значение 8 с token 2

A возобновился в t=7

Поздно пишет 5 поверх 8

Получает StaleWriter

Повторная запись B

Разрешена

Разрешена с тем же token 2

Итог

Остаток товара 5 — старое значение победило

Остаток товара 9, last_token 2

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

Почему TTL и проверка владельца перед записью недостаточны

TTL отвечает на вопрос, когда координатор может выдать аренду следующему воркеру. Он не отзывает уже выполняющийся код, не вытаскивает сетевой запрос из буфера и не мешает задержанному пакету прийти позже. Даже мгновенная проверка «аренда ещё моя» не гарантирует, что следующая запись доберётся до ресурса раньше запроса нового владельца.

Fencing token переносит решение к месту побочного эффекта. Ресурс уже видел token 2 — значит token 1 устарел независимо от часов старого процесса и порядка доставки пакетов. Номер не обязан быть временем; важно, чтобы он строго рос при передаче владения и не откатывался после сбоя или восстановления.

Что fencing token не решает

  • Токен не делает операцию идемпотентной. Две команды одного владельца с token 2 обе допустимы; для защиты от повторного применения доставленной команды нужен отдельный operation_id и сохранённый результат.

  • Токен не исправляет неверный SKU, поле, цену или остаток товара и не заменяет проверку прав технического пользователя.

  • Если внешний API не умеет атомарно хранить и сравнивать токен, один только номер в журнале не защищает запись.

  • Счётчик должен оставаться монотонным при перезапуске, отказе узла и восстановлении из резервной копии. Откат счётчика разрушает гарантию.

  • Если клиент не получил однозначного подтверждения, что аренда выдана, нужен отдельный контракт. Клиент не должен считать себя владельцем по одному тайм-ауту.

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

Повтор с тем же владельцем разбирается отдельно в материале о дельте, operation_id и версии остатка товара. Fencing token отвечает на другой вопрос: какой владелец уже устарел после передачи аренды.

Граница с 1С-Битрикс и обменом магазина

В 1С-Битрикс: Управление сайтом похожая ситуация возможна у фонового обработчика импорта, агента, очереди обновления остатков или собственного интеграционного модуля. Но названия заданий, способ блокировки и доступные точки расширения зависят от редакции, версии платформы, модуля и конкретного направления обмена. Этот пример не проверяет штатный обмен с 1С и не предлагает вставлять токен в таблицы продукта без проектирования.

  • Уточните, что именно защищается: товар, торговое предложение, склад, пакет обмена или заказ.

  • Зафиксируйте устойчивый идентификатор SKU или торгового предложения, поля цены и остатка товара, направление обмена и источник окончательного значения.

  • Проверьте, где можно атомарно сравнить fencing token и записать данные с теми же правами и в той же транзакционной границе.

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

  • Отдельно повторите команду текущего владельца и подтвердите идемпотентность через operation_id, если повтор может изменить деньги, количество или статус.

  • Проверьте восстановление координатора и хранилища из резервной копии: последний принятый токен не должен стать больше нового счётчика.

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

Обсуждение 0

Делись опытом и задавай вопросы. Комментарии без ссылок появляются после проверки редактором.

Пока никто не написал. Начни обсуждение.