diff --git a/editorial/agent-rewrites/239.json b/editorial/agent-rewrites/239.json index 0b556f2..e3c0b67 100644 --- a/editorial/agent-rewrites/239.json +++ b/editorial/agent-rewrites/239.json @@ -3,5 +3,5 @@ "slug": "editorial-2021-05-mechanism-distributed-locks", "title": "Почему distributed lock не защищает позднюю запись", "excerpt": "Lease координирует владельцев, но не останавливает проснувшийся процесс. Разбираем fencing token, атомарную проверку на ресурсе и диагностику stale write.", - "contentHtml": "

В журнале появляется странная последовательность: worker-A получил lock, надолго замолчал, worker-B получил тот же ресурс и записал новый результат, а затем A проснулся и тоже получил успешный ответ на запись. В базе снова лежит старое значение. В очереди возникла повторная команда. Внешний API принял действие от процесса, который уже не владел именем ресурса.

\n

Цена ошибки — не только одна неверная строка. Команда может решить, что lock-сервис выдал два владения одновременно, увеличить TTL и оставить настоящую дыру. Старый процесс не обязан знать, что его lease истёк. Проверка «lock был захвачен в начале» не защищает операцию, которая завершается позже.

\n

Тезис: lease отвечает за координацию владельцев, а fencing token защищает финальную запись. Ресурс должен хранить последнее принятое поколение и атомарно отклонять запрос с меньшим или равным token. Все worker, lease, token и времена ниже учебные: пример не запускает etcd, Redis, ZooKeeper, базу или сеть.

\n

Два разных вопроса

\n

Lock authority отвечает: «кому сейчас выдано владение именем?» Он может выдать lease-1 worker-A, дождаться expiry и выдать lease-2 worker-B. Lease ограничивает время владения. Keepalive продлевает его только пока authority получает подтверждения.

\n

Защищаемый ресурс отвечает иначе: «можно ли этому запросу изменить моё состояние после уже принятого поколения?» Ресурсом может быть строка в базе, объектное хранилище, файл или сервер, который принимает команду. Если он не проверяет поколение сам, наличие lock в другом сервисе не влияет на его решение.

\n
Граница ответственности lock и ресурса
СобытиеЗнает authorityЗнает ресурсДействие
A получил token 1lease-1 активенничегопередать token вместе с записью
lease-1 истёкимя доступноA всё ещё может ждатьне считать A остановленным
B получил token 2lease-2 активенничегопередать token вместе с записью
ресурс принял token 2может не участвоватьhighest token равен 2сохранить новое значение
поздний запрос A с token 1может видеть lease-2token 1 устарелотклонить запись
\n

Ресурсу не нужно снова спрашивать authority, жив ли lease-A. Такой запрос добавляет сетевой вызов и новую гонку. Достаточно собственного факта: token 2 уже принят для этого resource key. Это узкая гарантия. Она не останавливает A и не делает всю бизнес-операцию exactly-once.

\n

Механизм fencing token

\n

При каждом новом захвате authority выдаёт монотонное поколение. Token 1 относится к lease-A, token 2 — к lease-B. Request несёт token до точки, где появляется эффект. Ресурс хранит acceptedFenceToken. Условие записи выглядит так:

\n
function write(resource, request) {\n  if (request.fenceToken <= resource.acceptedFenceToken) {\n    return { status: 'rejected-stale-fence' };\n  }\n  resource.acceptedFenceToken = request.fenceToken;\n  resource.value = request.value;\n  return { status: 'accepted' };\n}
\n

Это псевдокод. В SQL условие и присваивание должны быть одной операцией, например UPDATE ... SET value = $1, fence_token = $2 WHERE id = $3 AND fence_token < $2. По числу изменённых строк видно, принял ли ресурс запрос. Отдельное чтение token, пауза и последующий write оставляют ту же гонку.

\n

Строгое сравнение важно. Равный token не делает request новым. Повтор доставки с тем же token требует отдельного operationId и идемпотентного журнала. ownerId показывает автора, leaseId связывает release с конкретным захватом, fenceToken задаёт порядок поколений, а operationId различает повторы одной операции.

\n
\"Схема
Authority выдаёт поколения, но обязательная защита появляется в ресурсе: он сравнивает token с последним принятым значением.
\n

Пошаговый пример гонки

\n

Пусть A получил token 1 и начал расчёт. Расчёт должен был занять меньше lease, но процесс остановился на сборке мусора или на медленном вызове базы. Lease истёк. B получил token 2, закончил расчёт и записал v2. A продолжил работу и отправил уже вычисленный v1.

\n
t=0    A: acquire(resource-7) -> lease-A, token=1\nt=5    A: compute()        -> процесс остановился\nt=10   authority: lease-A expired\nt=11   B: acquire(resource-7) -> lease-B, token=2\nt=12   B: write(v2, token=2)  -> accepted; highest=2\nt=13   A: write(v1, token=1)  -> rejected; highest=2
\n

Небезопасный ресурс делает иначе:

\n
resource.value = 'v2'; // B\nresource.value = 'v1'; // A: поздняя запись проходит
\n

Здесь ресурс не знает о lock и не отличает старый запрос от нового. Лог о том, что A когда-то владел lock, не исправит данные после записи. Если API не предоставляет условную запись, версию или серверную проверку, перенесите эффект на контролируемую транзакционную границу либо явно примите, что lock только координирует работу.

\n

Симптом → причина → проверка → действие

\n
Диагностика распределённой блокировки
СимптомПричинаПроверкаДействие
Старый worker получил 200 после новогоресурс не проверяет tokenсравнить token запроса и highest tokenдобавить атомарный reject stale write
Старый release снял lock Brelease проверяет только имясверить owner, leaseId и token текущего захватаотклонять старый handle
После retry получился duplicatefencing принят за идемпотентностьнайти operationId и журнал эффектадобавить идемпотентный контракт отдельно
Растёт доля busylease не продлевается или ресурс долго занятразделить acquire, keepalive и write latencyнастроить TTL по измерениям, не скрывать отказ
Нет token в запросе ресурсаграница защиты осталась в authorityпроследить поля до финального writeпередавать и сохранять поколение на ресурсе
\n

Порядок внедрения и проверки

\n
  1. Назовите один resource key и финальный write, который создаёт риск. Не прячьте несколько ресурсов за одним общим lock name.
  2. Опишите, что завершает владение: release, expiry, revoke или потеря сессии. Зафиксируйте исход каждого варианта.
  3. Сохраните точный handle захвата: owner, leaseId и выданный fence token. Старый handle не должен освобождать новый lease.
  4. Выберите источник монотонных поколений для каждого resource key. Не выводите token из wall clock и не называйте произвольный уникальный key fencing token без контракта.
  5. Сделайте сравнение token и запись эффекта одной атомарной операцией на ресурсе. Возвращайте отдельный результат stale, а не успешный ответ или общий timeout.
  6. Проверьте interleaving: A получил token 1 и остановился, lease истёк, B получил token 2 и записал, поздний A был отклонён.
  7. Отдельно проверьте повтор доставки, retry и идемпотентность. Fencing задаёт порядок владельцев, но не отвечает за повтор одного бизнес-эффекта.
  8. После этого испытайте provider на версии API, TTL, keepalive, revoke, сбоях связи и нагрузке. Учебный пример не заменяет интеграционный тест.
\n

Ограничения и критерий готовности

\n

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

\n

Keepalive сокращает вероятность expiry во время нормальной работы, но не доказывает, что поздний request безопасен. Между подтверждением lease и финальным write остаются паузы, сетевые разрывы и очереди. Provider key тоже не становится fencing token автоматически: важны порядок поколений и проверка на стороне получателя.

\n

Учебные фрагменты не дают production-результатов. Они не измеряют задержки, clock drift, SLA, пропускную способность или поведение кластера. Проверяемый критерий готовности таков: после принятого token 2 любой запрос с token 1 получает явный stale-результат, значение ресурса остаётся результатом token 2, а старый release не меняет lease B.

\n

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

\n" + "contentHtml": "

На дежурстве инженер увидел странный порядок в логе: worker-A получил lock, надолго замолчал, worker-B дождался освобождения ресурса и записал новый результат, а затем A проснулся и тоже получил успешный ответ. В базе снова оказалось старое значение. Похожий след появляется у очереди, когда повторная команда проходит от процесса, который уже потерял право менять состояние.

\n

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

\n

Рабочее правило: lease отвечает за координацию владельцев, а fencing token защищает финальную запись. Ресурс хранит последнее принятое поколение и атомарно отклоняет запрос с меньшим или равным token. Все worker, lease, token и времена ниже учебные: пример не запускает etcd, Redis, базу или сеть.

\n

Сначала разделим четыре идентификатора

\n

В гонке легко назвать все значения «токеном» и потерять смысл каждого поля. ownerId обозначает процесс, leaseId связывает его с конкретным захватом, fenceToken задаёт порядок поколений, а operationId позволяет распознать повтор одной бизнес-операции. Эти поля не заменяют друг друга.

\n
Что означает поле и кто его проверяет
ПолеКто выдаёт или создаётГде проверяетсяКакую проблему решает
ownerIdworker или его экземплярлог и диагностикапоказывает, кто отправил запрос
leaseIdlock authorityrelease и контракт владенияне даёт старому handle снять новый lease
fenceTokenauthority с монотонным счётчикомзащищаемый ресурсотбрасывает позднее поколение
operationIdдоменная операцияжурнал эффекта или APIотличает повтор доставки от новой операции
\n

Lock authority отвечает на вопрос «кому сейчас выдано имя?». Он может выдать lease-1 worker-A, дождаться истечения и выдать lease-2 worker-B. Защищаемый ресурс отвечает на другой вопрос: «можно ли этому запросу менять моё состояние после уже принятого поколения?». Если ресурс не проверяет поколение сам, наличие lock в другом сервисе не влияет на его решение.

\n

Сценарий гонки: lock уже истёк, процесс ещё жив

\n

Пусть A получил lease и fenceToken=41, после чего начал расчёт. Расчёт должен был завершиться раньше TTL, но процесс остановился на сборке мусора или на медленном вызове базы. Authority признал lease истёкшим. B получил новое поколение 42, закончил расчёт и записал v2. Когда A продолжил работу, в его памяти всё ещё был результат v1.

\n
t=0    A: acquire(resource-7) -> lease-A, fenceToken=41\nt=5    A: compute()           -> процесс остановился\nt=10   authority: lease-A expired\nt=11   B: acquire(resource-7) -> lease-B, fenceToken=42\nt=12   B: write(v2, token=42) -> accepted; highest=42\nt=13   A: write(v1, token=41) -> rejected; highest=42
\n

Небезопасный ресурс делает иначе:

\n
resource.value = 'v2'; // B\nresource.value = 'v1'; // A: поздняя запись проходит
\n

Лог о том, что A когда-то владел lock, не исправит данные после записи. Проверка «lease ещё жив?» непосредственно перед записью тоже не является достаточной: между чтением статуса и отправкой эффекта может пройти пауза. Решение должно находиться на той же границе, где ресурс принимает изменение.

\n
\"Схема
Authority выдаёт новые поколения, а ресурс сравнивает их с последним принятым token. Именно это сравнение закрывает окно для позднего запроса.
\n

Что именно делает fencing token

\n

При каждом новом захвате конкретного resourceKey authority выдаёт большее поколение. Ресурс хранит acceptedFenceToken рядом с защищаемым состоянием. Условие простое: принять запрос можно только при request.fenceToken > acceptedFenceToken. После принятия ресурс в одной атомарной операции обновляет и данные, и сохранённое поколение.

\n
function write(resource, request) {\n  if (request.fenceToken <= resource.acceptedFenceToken) {\n    return { status: 'rejected-stale-fence' };\n  }\n\n  resource.acceptedFenceToken = request.fenceToken;\n  resource.value = request.value;\n  return { status: 'accepted' };\n}
\n

Это псевдокод, а не готовая транзакция. Для одной строки PostgreSQL условие и присваивание можно выразить так:

\n
UPDATE resource_state\nSET value = $1, fence_token = $2\nWHERE resource_key = $3\n  AND fence_token < $2;
\n

Смотрите на число изменённых строк: ноль означает, что запрос не прошёл условие или ресурс уже обработал более новое поколение. Отдельное чтение token, пауза и последующий UPDATE оставляют ту же гонку. Поле fence_token должно переживать перезапуск и не сбрасываться вместе с памятью worker-а; иначе старый token может снова стать «новым».

\n

Граница etcd и внешнего ресурса

\n

В etcd lease живёт ограниченное время: кластер удаляет связанные ключи после истечения lease, если keep-alive не пришёл. Concurrency Lock возвращает ключ владения, который существует, пока lock удерживается; его можно использовать в транзакции для защиты изменений внутри самого etcd. Это полезная координация, но ключ lock или обычный leaseId не превращается автоматически в fencing token для внешней базы, файла или API.

\n

Для внешнего ресурса нужен отдельный контракт. Authority должен выдать монотонное поколение, worker обязан передать его в запросе, а ресурс — проверить и записать результат атомарно. В работе Chubby эту роль выполняет sequencer: сервер-получатель проверяет, что последовательность всё ещё действительна, и отклоняет операцию с устаревшим состоянием. Название поля может быть другим, но граница остаётся той же.

\n
Граница ответственности в протоколе
ШагСостояние authorityСостояние ресурсаОжидаемый результат
A захватил ресурсlease-A активен, token 41последний token 40A может начать работу
lease-A истёкимя можно выдать сноваA может продолжать вычислениестарый процесс не считается остановленным
B захватил ресурсlease-B активен, token 42последний token 40B передаёт 42 в финальный write
B записалможет не участвоватьtoken 42 и v2ресурс принимает изменение
A записал поздноможет видеть новый lease41 меньше 42ресурс отвечает stale
\n

Если операция меняет несколько строк, условие должно защищать одну выбранную транзакционную границу. Несколько независимых UPDATE не превращаются в атомарную бизнес-операцию только потому, что в каждом есть token. Для внешнего API нужны его условная запись, идемпотентный ключ или другая явно выбранная гарантия.

\n

Диагностика: симптом → причина → проверка → действие

\n
Матрица проверки распределённой блокировки
СимптомВероятная причинаПроверкаДействие
Старый worker получил успех после новогоресурс не проверяет tokenсравнить token запроса с сохранённымдобавить атомарный reject устаревшего write
Старый release снял lock Brelease проверяет только имясверить owner, leaseId и handle текущего захватаотклонять старый handle
Повтор создал второй эффектfencing приняли за идемпотентностьнайти operationId и записи эффектадобавить идемпотентный контракт отдельно
Token повторился после рестартасчётчик жил только в памятисравнить историю выдачи и состояние authorityсделать источник поколений долговечным
Запрос ресурса не содержит tokenграница защиты осталась в authorityпроследить поля до финального writeпередавать и проверять поколение на ресурсе
\n

Порядок внедрения и воспроизводимая проверка

\n
  1. Назовите один resourceKey и финальный write, который создаёт риск. Не прячьте несколько независимых ресурсов за одним общим lock name.
  2. Зафиксируйте, что завершает владение: release, expiry, revoke или потеря сессии. Для каждого исхода запишите, может ли старый worker продолжить вычисление.
  3. Сохраните точный handle захвата: owner, leaseId и выданный fence token. Старый handle не должен освобождать новый lease.
  4. Выберите долговечный источник монотонных поколений для каждого resourceKey. Не выводите token из wall clock и не сбрасывайте счётчик при перезапуске authority.
  5. Сделайте сравнение token и запись эффекта одной атомарной операцией на ресурсе. Возвращайте отдельный результат stale, а не успешный ответ или общий timeout.
  6. Запустите interleaving-тест: A получил 41 и остановился, lease истёк, B получил 42 и записал, поздний A получил отказ; значение осталось результатом B.
  7. Повторите тест после рестарта authority и ресурса: token 42 не должен быть принят как новое поколение, а следующий захват должен получить значение больше 42.
  8. Отдельно проверьте повтор доставки, retry и идемпотентность по operationId. Fencing задаёт порядок владельцев, но не отвечает за повтор одного бизнес-эффекта.
  9. После этого испытайте выбранный provider на версии API, TTL, keepalive, revoke, сбоях связи и нагрузке. Учебный пример проверяет механизм, но не заменяет интеграционный тест.
\n

Ограничения и критерий готовности

\n

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

\n

Keepalive уменьшает вероятность истечения lease во время нормальной работы, но не доказывает безопасность позднего request. Между подтверждением lease и финальным write остаются паузы, сетевые разрывы и очереди. Счётчик тоже не становится fencing token автоматически: важны монотонный порядок, долговечность и проверка на стороне получателя.

\n

Учебные фрагменты не дают измерений задержки, clock drift, SLA или пропускной способности кластера. Критерий готовности уже конкретнее: после принятого token 42 любой запрос с token 41 получает явный stale-результат, значение ресурса остаётся v2, старый release не меняет lease B, а повтор с тем же operationId не создаёт второй эффект.

\n

Вернитесь к сцене из начала: перед увеличением TTL сначала найдите финальный ресурс и проверьте, доходит ли до него поколение. Если поля нет или ресурс не умеет условную запись, lock по-прежнему только координирует worker-ы; защиту нужно добавить именно на границе эффекта.

\n

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

\n" }