{ "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Пусть два worker-а пересобирают отчёт report:417. Lock authority хранит имя ресурса, текущий handle захвата и срок lease. Worker-A получает lease-1 и token 1. Затем процесс останавливается на паузе сборщика мусора, зависает на сетевом вызове или теряет связь с authority. Lease истекает. Worker-B получает lease-2 и token 2.
На этом этапе worker-A не знает, что его право закончилось. Он может продолжить вычисление и отправить ранее подготовленный запрос. Если база или API принимают запись без проверки token, запрос worker-A перезапишет результат worker-B. Значит, проверка «lock был взят перед началом работы» не доказывает безопасность записи. Между проверкой и эффектом проходит время, за которое владелец может измениться.
\n| Поле | Где проверяем | Что оно подтверждает | Чего оно не подтверждает |
|---|---|---|---|
lockName | lock authority | Worker-ы спорят за один предмет | Все связанные ресурсы используют ту же границу |
ownerId | журнал и диагностика | Кто отправил запрос | Процесс всё ещё имеет право писать |
leaseId | операция release | Освобождается именно текущий захват | Старый запрос исчез из сети |
fenceToken | защищаемый ресурс | Поколение запроса | Запрос автоматически отменён |
acceptedFenceToken | атомарная запись ресурса | Старое поколение будет отклонено | Операция стала exactly-once |
ownerId нужен оператору. leaseId защищает release от запоздалого процесса. fenceToken защищает конечную запись. Не стоит подменять token временем на часах worker-а или случайным ключом lock-сервиса. Нужен порядок поколений для одного доменного ресурса. Ресурс должен хранить последнее принятое значение и сравнивать его с token в той же атомарной операции, которая создаёт эффект.
Ниже — ограниченный учебный пример. Он не запускает etcd, Redis, базу, сеть или реальный cluster. Числа и ручные отметки времени нужны только для воспроизведения порядка событий.
\nconst 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| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старый результат перезаписал новый | Ресурс принимает запись без fencing | Сравнить token запроса и последнее принятое поколение | Добавить атомарный reject устаревшего token или перенести эффект в ресурс с такой проверкой |
| Старый worker снял новую блокировку | Release ищет только по имени | Сопоставить leaseId и текущий handle | Отклонять release, если handle больше не принадлежит владельцу |
| Операция зависает после expiry | Нет отдельного таймаута работы или отмены | Проверить длительность критической секции и путь отмены | Ограничить работу, обновлять lease по контракту или возвращать ошибку владельцу |
| Одинаковая задача выполнена дважды | Fencing не равен идемпотентности | Проверить ключ операции и повторы после ответа | Добавить идемпотентный эффект; не выдавать его за замену fencing |
| Нельзя объяснить отказ | Логи не связывают worker, lease и ресурс | Найти ownerId, leaseId, token и результат записи | Логировать корреляционный пакет без секретов и персональных данных |
Fencing не делает операцию exactly-once. Он не отменяет старую работу и не исправляет побочный эффект, который уже произошёл до проверки. Он также не решает конфликт, если два действия законно должны выполняться параллельно. В этом случае им нужен разный ключ ресурса или другой протокол.
\nКороткий lease нельзя объявлять безопасным только потому, что обычная операция обычно укладывается в его срок. Нужно учитывать паузы процесса, задержку сети, очередь на сервере, повторные попытки и время ответа защищаемого ресурса. Длинный lease уменьшает число ложных истечений, но увеличивает окно ожидания после сбоя. Ни один TTL не заменяет проверку поздней записи.
\nУчебный код выше не подтверждает поведение конкретного провайдера и не содержит production-результатов. Он проверяет только контракт: после принятия token 2 запрос с token 1 получает явный отказ. Если выбранная база или API не умеют выполнить такой reject, это нужно записать как ограничение дизайна, а не скрывать увеличением TTL.
\nМеханизм готов к интеграционной проверке, когда для одного доменного ресурса можно показать четыре наблюдаемых факта: новый владелец получает новое поколение; ресурс атомарно принимает его; поздний запрос старого владельца получает отдельный отказ и не меняет значение; старый release не снимает новый lease. В тестовом отчёте должны остаться token, leaseId, порядок событий и итоговое значение ресурса. Без этих фактов утверждение «распределённая блокировка защищает критическую секцию» слишком сильное.
\n