diff --git a/editorial/agent-rewrites/240.json b/editorial/agent-rewrites/240.json index 57a019d..d947a43 100644 --- a/editorial/agent-rewrites/240.json +++ b/editorial/agent-rewrites/240.json @@ -2,6 +2,6 @@ "index": 240, "slug": "editorial-2021-05-practice-distributed-locks", "title": "Распределённая блокировка: почему lease не защищает позднюю запись", - "excerpt": "Старый worker может продолжить работу после истечения lease. Разбираем, как fencing token защищает ресурс, почему одного lock недостаточно и как проверить отрицательный сценарий до интеграции.", - "contentHtml": "

В журнале появляется странная последовательность: worker-A получил блокировку, worker-B выполнил ту же задачу после истечения времени, а затем worker-A вернулся и снова записал результат. Оба запроса могут завершиться успешно. Пользователь увидит старые данные, повторную отправку или две операции, которые должны были быть взаимоисключающими. Цена ошибки — не лишняя строка в логе. Это потеря свежего состояния и сложное восстановление, если поздняя запись уже ушла во внешнюю систему.

\n

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

\n

Что именно блокирует lease

\n

Пусть два worker-а пересобирают отчёт report:417. Lock authority хранит имя ресурса, текущий handle захвата и срок lease. Worker-A получает lease-1 и token 1. Затем процесс останавливается на паузе сборщика мусора, зависает на сетевом вызове или теряет связь с authority. Lease истекает. Worker-B получает lease-2 и token 2.

\n

На этом этапе worker-A не знает, что его право закончилось. Он может продолжить вычисление и отправить ранее подготовленный запрос. Если база или API принимают запись без проверки token, запрос worker-A перезапишет результат worker-B. Значит, проверка «lock был взят перед началом работы» не доказывает безопасность записи. Между проверкой и эффектом проходит время, за которое владелец может измениться.

\n
Контракт распределённой блокировки
ПолеГде проверяемЧто оно подтверждаетЧего оно не подтверждает
lockNamelock authorityWorker-ы спорят за один предметВсе связанные ресурсы используют ту же границу
ownerIdжурнал и диагностикаКто отправил запросПроцесс всё ещё имеет право писать
leaseIdоперация releaseОсвобождается именно текущий захватСтарый запрос исчез из сети
fenceTokenзащищаемый ресурсПоколение запросаЗапрос автоматически отменён
acceptedFenceTokenатомарная запись ресурсаСтарое поколение будет отклоненоОперация стала exactly-once
\n

ownerId нужен оператору. leaseId защищает release от запоздалого процесса. fenceToken защищает конечную запись. Не стоит подменять token временем на часах worker-а или случайным ключом lock-сервиса. Нужен порядок поколений для одного доменного ресурса. Ресурс должен хранить последнее принятое значение и сравнивать его с token в той же атомарной операции, которая создаёт эффект.

\n

Учебный interleaving

\n

Ниже — ограниченный учебный пример. Он не запускает etcd, Redis, базу, сеть или реальный cluster. Числа и ручные отметки времени нужны только для воспроизведения порядка событий.

\n
const resource = {\n  acceptedFenceToken: 0,\n  value: null,\n};\n\nfunction protectedWrite(token, value) {\n  if (token <= resource.acceptedFenceToken) {\n    return { status: 'rejected-stale-fence' };\n  }\n\n  resource.acceptedFenceToken = token;\n  resource.value = value;\n  return { status: 'accepted' };\n}\n\n// worker-A: lease-1, token 1; затем процесс остановился\n// worker-B: lease-2, token 2; запись пришла первой\nconsole.log(protectedWrite(2, 'report-v2-from-worker-B'));\nconsole.log(protectedWrite(1, 'old-report-from-worker-A'));\n// accepted\n// rejected-stale-fence
\n

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

\n
\"Учебная
Учебный interleaving: истечение lease освобождает имя для нового владельца, но не отзывает уже созданную работу старого процесса.
\n

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

\n
СимптомПричинаПроверкаДействие
Старый результат перезаписал новыйРесурс принимает запись без fencingСравнить token запроса и последнее принятое поколениеДобавить атомарный reject устаревшего token или перенести эффект в ресурс с такой проверкой
Старый worker снял новую блокировкуRelease ищет только по имениСопоставить leaseId и текущий handleОтклонять release, если handle больше не принадлежит владельцу
Операция зависает после expiryНет отдельного таймаута работы или отменыПроверить длительность критической секции и путь отменыОграничить работу, обновлять lease по контракту или возвращать ошибку владельцу
Одинаковая задача выполнена дваждыFencing не равен идемпотентностиПроверить ключ операции и повторы после ответаДобавить идемпотентный эффект; не выдавать его за замену fencing
Нельзя объяснить отказЛоги не связывают worker, lease и ресурсНайти ownerId, leaseId, token и результат записиЛогировать корреляционный пакет без секретов и персональных данных
\n

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

\n
  1. Назовите один предмет блокировки и один ресурс, который меняется. Если операция трогает несколько ресурсов, опишите их границы отдельно.
  2. Зафиксируйте, что означает окончание владения: release, expiry, отзыв сессии или событие, определённое вашим provider.
  3. Получите уникальный handle текущего захвата. Старый worker не должен снять новый lease только потому, что знает имя lock.
  4. Выберите монотонный fencing token для каждого ресурса. Не выводите его из wall clock.
  5. Встройте сравнение token и запись эффекта в одну атомарную операцию ресурса.
  6. Воспроизведите отрицательный путь: token 1 остановился, lease истёк, token 2 записал результат, token 1 пришёл поздно.
  7. Проверьте повтор запроса и ошибку release отдельно. Они дополняют fencing и не следуют из него автоматически.
  8. Только после этого проверяйте TTL, keepalive, reconnect и отказ provider в тесте конкретной интеграции.
\n

Ограничения

\n

Fencing не делает операцию exactly-once. Он не отменяет старую работу и не исправляет побочный эффект, который уже произошёл до проверки. Он также не решает конфликт, если два действия законно должны выполняться параллельно. В этом случае им нужен разный ключ ресурса или другой протокол.

\n

Короткий lease нельзя объявлять безопасным только потому, что обычная операция обычно укладывается в его срок. Нужно учитывать паузы процесса, задержку сети, очередь на сервере, повторные попытки и время ответа защищаемого ресурса. Длинный lease уменьшает число ложных истечений, но увеличивает окно ожидания после сбоя. Ни один TTL не заменяет проверку поздней записи.

\n

Учебный код выше не подтверждает поведение конкретного провайдера и не содержит production-результатов. Он проверяет только контракт: после принятия token 2 запрос с token 1 получает явный отказ. Если выбранная база или API не умеют выполнить такой reject, это нужно записать как ограничение дизайна, а не скрывать увеличением TTL.

\n

Критерий готовности

\n

Механизм готов к интеграционной проверке, когда для одного доменного ресурса можно показать четыре наблюдаемых факта: новый владелец получает новое поколение; ресурс атомарно принимает его; поздний запрос старого владельца получает отдельный отказ и не меняет значение; старый release не снимает новый lease. В тестовом отчёте должны остаться token, leaseId, порядок событий и итоговое значение ресурса. Без этих фактов утверждение «распределённая блокировка защищает критическую секцию» слишком сильное.

\n

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

\n" + "excerpt": "Истёкший lease не останавливает старый worker и не отменяет уже отправленный запрос. Разбираем fencing token, атомарную проверку на ресурсе и отрицательный сценарий, который стоит воспроизвести до интеграции.", + "contentHtml": "

В журнале появляется странная последовательность: worker-A получил блокировку, worker-B выполнил ту же задачу после истечения времени, а затем worker-A вернулся и снова записал результат. Оба запроса могут завершиться успешно. Пользователь увидит старые данные, повторную отправку или две операции, которые должны были быть взаимоисключающими. Цена ошибки — не лишняя строка в логе. Это потеря свежего состояния и сложное восстановление, если поздняя запись уже ушла во внешнюю систему.

\n

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

\n

Что именно блокирует lease

\n

Пусть два worker-а пересобирают отчёт report:417. Сервис, который выдаёт lock, хранит имя ресурса, текущий handle захвата и срок lease. Worker-A получает lease-1 и token 1. Затем процесс останавливается на паузе сборщика мусора, зависает на сетевом вызове или теряет связь с сервисом lock. Lease истекает. В реализации, где захват привязан к TTL, имя становится доступно worker-B; тот получает lease-2 и token 2.

\n

На этом этапе worker-A не знает, что его право закончилось. Он может продолжить вычисление и отправить ранее подготовленный запрос. Если база или API принимают запись без проверки token, запрос worker-A перезапишет результат worker-B. Значит, проверка «lock был взят перед началом работы» не доказывает безопасность записи. Между проверкой и эффектом проходит время, за которое владелец может измениться.

\n
Контракт распределённой блокировки
ПолеГде проверяемЧто оно подтверждаетЧего оно не подтверждает
lockNamelock authorityWorker-ы спорят за один предметВсе связанные ресурсы используют ту же границу
ownerIdжурнал и диагностикаКто отправил запросПроцесс всё ещё имеет право писать
leaseIdоперация releaseОсвобождается именно текущий захватСтарый запрос исчез из сети
fenceTokenзащищаемый ресурсПоколение запросаЗапрос автоматически отменён
acceptedFenceTokenатомарная запись ресурсаСтарое поколение будет отклоненоОперация стала exactly-once
\n

ownerId нужен оператору. leaseId защищает release от запоздалого процесса. fenceToken защищает конечную запись. Не стоит подменять token временем на часах worker-а или случайным ключом lock-сервиса. Нужен порядок поколений для одного доменного ресурса. Ресурс должен хранить последнее принятое значение и сравнивать его с token в той же атомарной операции, которая создаёт эффект.

\n

Учебное чередование событий

\n

Ниже — ограниченный учебный пример. Он не запускает etcd, Redis, базу, сеть или реальный кластер. Числа и ручные отметки времени нужны только для воспроизведения порядка событий. В реальной базе условие и изменение должны быть одной атомарной операцией.

\n
const resource = {\n  acceptedFenceToken: 0,\n  value: null,\n};\n\nfunction protectedWrite(token, value) {\n  if (token <= resource.acceptedFenceToken) {\n    return { status: 'rejected-stale-fence' };\n  }\n\n  resource.acceptedFenceToken = token;\n  resource.value = value;\n  return { status: 'accepted' };\n}\n\n// worker-A: lease-1, token 1; затем процесс остановился\n// worker-B: lease-2, token 2; запись пришла первой\nconsole.log(protectedWrite(2, 'report-v2-from-worker-B').status);\n// accepted\nconsole.log(protectedWrite(1, 'old-report-from-worker-A').status);\n// rejected-stale-fence\nconsole.log(resource);\n// { acceptedFenceToken: 2, value: 'report-v2-from-worker-B' }
\n

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

\n
\"Учебная
Чередование событий: истечение lease освобождает имя для нового владельца, но не отзывает уже созданную работу старого процесса.
\n

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

\n
СимптомПричинаПроверкаДействие
Старый результат перезаписал новыйРесурс принимает запись без fencingСравнить token запроса и последнее принятое поколениеДобавить атомарный reject устаревшего token или перенести эффект в ресурс с такой проверкой
Старый worker снял новую блокировкуRelease ищет только по имениСопоставить leaseId и текущий handleОтклонять release, если handle больше не принадлежит владельцу
Операция зависает после expiryНет отдельного таймаута работы или отменыПроверить длительность критической секции и путь отменыОграничить работу, обновлять lease по контракту или возвращать ошибку владельцу
Одинаковая задача выполнена дваждыFencing не равен идемпотентностиПроверить ключ операции и повторы после ответаДобавить идемпотентный эффект; не выдавать его за замену fencing
Нельзя объяснить отказЛоги не связывают worker, lease и ресурсНайти ownerId, leaseId, token и результат записиЛогировать корреляционный пакет без секретов и персональных данных
\n

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

\n
  1. Назовите один предмет блокировки и один ресурс, который меняется. Если операция трогает несколько ресурсов, опишите их границы отдельно.
  2. Зафиксируйте, что означает окончание владения: release, expiry, отзыв сессии или событие, определённое вашим провайдером.
  3. Получите уникальный handle текущего захвата. Старый worker не должен снять новый lease только потому, что знает имя lock.
  4. Выберите монотонный fencing token для каждого ресурса. Не выводите его из системных часов worker-а.
  5. Встройте сравнение token и запись эффекта в одну атомарную операцию ресурса.
  6. Воспроизведите отрицательный путь: token 1 остановился, lease истёк, token 2 записал результат, token 1 пришёл поздно. Зафиксируйте именно отказ поздней записи.
  7. Проверьте повтор запроса и ошибку release отдельно. Они дополняют fencing и не следуют из него автоматически.
  8. Только после этого проверяйте TTL, keep-alive, переподключение и отказ провайдера в тесте конкретной интеграции.
\n

Ограничения

\n

Fencing не делает операцию exactly-once. Он не отменяет старую работу и не исправляет побочный эффект, который уже произошёл до проверки. Он также не решает конфликт, если два действия законно должны выполняться параллельно. В этом случае им нужен разный ключ ресурса или другой протокол.

\n

Короткий lease нельзя объявлять безопасным только потому, что обычная операция обычно укладывается в его срок. Нужно учитывать паузы процесса, задержку сети, очередь на сервере, повторные попытки и время ответа защищаемого ресурса. Длинный lease уменьшает число ложных истечений, но увеличивает окно ожидания после сбоя. Ни один TTL не заменяет проверку поздней записи.

\n

Учебный код выше не подтверждает поведение конкретного провайдера и не содержит production-результатов. Он проверяет только контракт: после принятия token 2 запрос с token 1 получает явный отказ. Если выбранная база или API не умеют выполнить такой reject, это нужно записать как ограничение дизайна, а не скрывать увеличением TTL. В etcd lease привязывает ключ к времени жизни и удаляет ключ после expiry, а ревизия упорядочивает изменения; downstream-ресурс всё равно должен сам принять и сравнить fencing token.

\n

Критерий готовности

\n

Механизм готов к интеграционной проверке, когда для одного доменного ресурса можно показать четыре наблюдаемых факта: новый владелец получает новое поколение; ресурс атомарно принимает его; поздний запрос старого владельца получает отдельный отказ и не меняет значение; старый release не снимает новый lease. В тестовом отчёте должны остаться token, leaseId, порядок событий и итоговое значение ресурса. Без этих фактов утверждение «распределённая блокировка защищает критическую секцию» слишком сильное.

\n

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

\n" }