{ "index": 238, "slug": "editorial-2021-05-field-distributed-locks", "title": "Поздний владелец lease: как остановить stale write", "excerpt": "Истёкший lease не останавливает worker, который уже выполняет работу. Разбираем stale write, fencing token и атомарную проверку ресурса на конкретном interleaving.", "contentHtml": "
В журнале появляется странная последовательность: worker-B записал новое значение, а через несколько секунд worker-A вернул успешный ответ для той же записи. Команда видит два захвата одного lock и начинает менять TTL. Но часто provider не нарушал контракт. Worker-A получил lease, остановился на паузе, дождался expiry и продолжил работу. Ресурс принял его позднюю запись, потому что ничего не знал о поколении lock. Цена ошибки — откат состояния, повторная отправка платежа или потеря результата более нового worker.
\nТезис статьи простой: distributed lock координирует владельцев, но не защищает внешний ресурс от уже запущенного старого владельца. Для этой границы нужен fencing token. Worker передаёт token вместе с записью. Ресурс хранит последний принятый token и отклоняет меньший. Учебные значения, workers и времена ниже вымышлены. Пример работает в памяти и не доказывает поведение конкретного кластера.
\nLock authority отвечает на вопрос: «кому выдать следующее владение именем?». Lease даёт этому владению срок жизни. Keepalive продлевает срок, пока authority получает подтверждения от клиента.
\nЗащищаемый ресурс отвечает на другой вопрос: «может ли этот запрос изменить состояние после уже принятого поколения?». Здесь живёт compare-and-set, условный UPDATE, версия строки или другая транзакционная проверка. Если ресурс не выполняет такую проверку, token остаётся полем в логе.
\n| Событие | Состояние authority | Состояние ресурса | Ожидаемое решение |
|---|---|---|---|
| A получил token 1 | lease-A активен | последний token меньше 1 | можно начать работу |
| lease-A истёк | имя можно выдать снова | A может ещё выполнять код | не считать A остановленным |
| B получил token 2 | lease-B активен | token 2 ещё не принят | передать token 2 в write |
| B записал значение | authority может не участвовать | highest token равен 2 | сохранить значение B |
| A прислал token 1 | lease-A уже недействителен | highest token равен 2 | отклонить stale write |
Проверка lease в начале функции защищает только момент проверки. Она не переносится на будущую запись. Пауза может возникнуть из-за GC, медленной базы, debugger, сетевого ожидания или планировщика. За это время authority выдаст новое владение. Локальная переменная A всё ещё содержит старый leaseId, поэтому код продолжит работу, если перед write нет отдельного барьера.
\nt=0 A: acquire(lock) -> token=1, lease=active\nt=4s A: строит результат и останавливается\nt=5s authority: lease A истёк\nt=5s B: acquire(lock) -> token=2\nt=6s B: write(item, token=2) -> accepted\nt=7s A: write(item, token=1) -> должен быть rejected\nПоследняя строка не исправляется повторной проверкой lock в A. Между такой проверкой и фактическим write снова появится окно. Ресурс должен проверить token в той же операции, которая меняет его состояние.
\nУчебная модель хранит два поля: значение и highest accepted token. Новый запрос проходит только при token больше сохранённого. Сравнение и запись должны быть атомарными относительно других writers. В SQL это обычно означает условие в одном UPDATE и проверку числа изменённых строк.
\nUPDATE jobs\nSET result = :result, accepted_fence_token = :token\nWHERE job_id = :job_id\n AND accepted_fence_token < :token;\nЕсли UPDATE изменил ноль строк, запрос не получил право менять ресурс. Причины нужно различать: token мог быть старым, запись могла исчезнуть, а запрос мог повториться с тем же token. Не превращайте все нулевые результаты в retry. Повтор старого token не станет новым от повторной доставки.
\nРавенство тоже важно. Условие <= отвергает повтор с тем же поколением. Это не решает идемпотентность бизнес-операции. Для неё нужен отдельный operation id и журнал уже применённых эффектов. Owner, fence token и idempotency key отвечают на разные вопросы.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старый worker пишет после нового | Ресурс не проверяет token | Найти highest token до и после write | Добавить атомарное сравнение при записи |
| Старый worker снимает lock B | release ищет только по lock name | Сверить ownerId, leaseId и token | Освобождать только конкретный handle |
| Один результат применился дважды | Повтор доставки, а не stale generation | Сравнить operation id и effect ledger | Ввести отдельный idempotency contract |
| Оба запроса отклонены | Token относится не к той resource key | Сопоставить ключ конфликта и линию поколений | Сузить область lock и fencing до одного эффекта |
| Ошибку видят только по времени логов | Нет состояния решения на ресурсе | Проверить запись принятого token | Логировать решение рядом с условным write |
После expiry A может проснуться и отправить release. Если authority удаляет lock только по имени, A способен снять lease-B. Это отдельная ошибка. Fencing защищает запись ресурса, но не делает старый release безопасным.
\nrelease(lockName, leaseId, ownerId, token)\n if active.lockName != lockName: reject\n if active.leaseId != leaseId: reject\n if active.ownerId != ownerId: reject\n delete active\nКонкретный provider может использовать другой handle. Нельзя переносить названия полей как готовый API. Переносим только инвариант: старый владелец не должен менять состояние нового владельца.
\nFencing не останавливает worker и не отменяет уже отправленный HTTP-запрос. Он не делает workflow exactly-once. Он не упорядочивает разные resource keys и не защищает внешний API, который не умеет принимать и проверять token. Для нескольких эффектов понадобятся разные контракты: fencing для одной записи, idempotency для повторного вызова, компенсация для уже принятого внешнего эффекта.
\nLease тоже не равен сигналу остановки процесса. В документации etcd expiry удаляет связанные ключи и освобождает lock, если сервер не получает keepalive. Документация не обещает отмену локальной функции клиента. Поэтому claim «lease истёк, A больше ничего не сделает» неверен.
\nЭта статья не проверяет etcd, Redis, ZooKeeper, provider SDK, сеть, clock drift, cluster, нагрузку или production. Учебный interleaving показывает только нужный инвариант. Перед выпуском на реальном ресурсе нужно доказать, что его транзакция действительно отклоняет меньший token.
\nРабота готова, если интеграционный тест на выбранном ресурсе проходит четыре проверки: token 2 принимается; поздний token 1 не меняет значение; повторный token 1 получает явный отказ; старый release не снимает lease нового owner. В отчёте должны быть resource key, token до и после, причина отказа и сохранённое итоговое значение. Если хотя бы одного поля нет, тест подтверждает только наличие lock, но не защиту от stale write.
\n