8 lines
16 KiB
JSON
8 lines
16 KiB
JSON
{
|
||
"index": 240,
|
||
"slug": "editorial-2021-05-practice-distributed-locks",
|
||
"title": "Распределённая блокировка: почему lease не защищает позднюю запись",
|
||
"excerpt": "Истёкший lease не останавливает старый worker и не отменяет уже отправленный запрос. Разбираем fencing token, атомарную проверку на ресурсе и отрицательный сценарий, который стоит воспроизвести до интеграции.",
|
||
"contentHtml": "<p>В журнале появляется странная последовательность: worker-A получил блокировку, worker-B выполнил ту же задачу после истечения времени, а затем worker-A вернулся и снова записал результат. Оба запроса могут завершиться успешно. Пользователь увидит старые данные, повторную отправку или две операции, которые должны были быть взаимоисключающими. Цена ошибки — не лишняя строка в логе. Это потеря свежего состояния и сложное восстановление, если поздняя запись уже ушла во внешнюю систему.</p>\n<p>Причина обычно не в том, что lock-сервис выдал два права одновременно. Lease даёт владельцу временное окно. Он не останавливает зависший процесс, не отменяет запрос в сети и не заставляет базу данных проверять состояние lock-сервиса. Поэтому распределённая блокировка защищает критическую секцию только вместе с защитой самого ресурса. Для этой границы нужен fencing token — монотонное поколение, которое ресурс сравнивает с последним принятым поколением.</p>\n<h2>Что именно блокирует lease</h2>\n<p>Пусть два worker-а пересобирают отчёт <code>report:417</code>. Сервис, который выдаёт lock, хранит имя ресурса, текущий handle захвата и срок lease. Worker-A получает <code>lease-1</code> и token <code>1</code>. Затем процесс останавливается на паузе сборщика мусора, зависает на сетевом вызове или теряет связь с сервисом lock. Lease истекает. В реализации, где захват привязан к TTL, имя становится доступно worker-B; тот получает <code>lease-2</code> и token <code>2</code>.</p>\n<p>На этом этапе worker-A не знает, что его право закончилось. Он может продолжить вычисление и отправить ранее подготовленный запрос. Если база или API принимают запись без проверки token, запрос worker-A перезапишет результат worker-B. Значит, проверка «lock был взят перед началом работы» не доказывает безопасность записи. Между проверкой и эффектом проходит время, за которое владелец может измениться.</p>\n<table><caption>Контракт распределённой блокировки</caption><thead><tr><th>Поле</th><th>Где проверяем</th><th>Что оно подтверждает</th><th>Чего оно не подтверждает</th></tr></thead><tbody><tr><td><code>lockName</code></td><td>lock authority</td><td>Worker-ы спорят за один предмет</td><td>Все связанные ресурсы используют ту же границу</td></tr><tr><td><code>ownerId</code></td><td>журнал и диагностика</td><td>Кто отправил запрос</td><td>Процесс всё ещё имеет право писать</td></tr><tr><td><code>leaseId</code></td><td>операция release</td><td>Освобождается именно текущий захват</td><td>Старый запрос исчез из сети</td></tr><tr><td><code>fenceToken</code></td><td>защищаемый ресурс</td><td>Поколение запроса</td><td>Запрос автоматически отменён</td></tr><tr><td><code>acceptedFenceToken</code></td><td>атомарная запись ресурса</td><td>Старое поколение будет отклонено</td><td>Операция стала exactly-once</td></tr></tbody></table>\n<p><code>ownerId</code> нужен оператору. <code>leaseId</code> защищает release от запоздалого процесса. <code>fenceToken</code> защищает конечную запись. Не стоит подменять token временем на часах worker-а или случайным ключом lock-сервиса. Нужен порядок поколений для одного доменного ресурса. Ресурс должен хранить последнее принятое значение и сравнивать его с token в той же атомарной операции, которая создаёт эффект.</p>\n<h2>Учебное чередование событий</h2>\n<p>Ниже — ограниченный учебный пример. Он не запускает etcd, Redis, базу, сеть или реальный кластер. Числа и ручные отметки времени нужны только для воспроизведения порядка событий. В реальной базе условие и изменение должны быть одной атомарной операцией.</p>\n<pre><code>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' }</code></pre>\n<p>Проверка должна выполняться на стороне ресурса. Если token сравнивается в worker-е отдельным чтением, гонка остаётся: оба worker-а могут прочитать одно и то же старое значение, а затем записать новое. В базе это обычно означает условное обновление внутри транзакции, проверку версии строки или другой атомарный механизм. Для файла нужен контролируемый сервер записи. Для внешнего API нужен контракт, который принимает поколение и отклоняет устаревшее. Если такой границы нет, lock остаётся средством координации, но не доказательством защиты данных.</p>\n<figure><img src=\"/assets/editorial/2021/distributed-lock-lease-timeline-2021.svg\" alt=\"Учебная временная шкала: worker-A теряет lease, worker-B получает новое поколение, ресурс принимает token 2 и отклоняет поздний token 1\"><figcaption>Чередование событий: истечение lease освобождает имя для нового владельца, но не отзывает уже созданную работу старого процесса.</figcaption></figure>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Старый результат перезаписал новый</td><td>Ресурс принимает запись без fencing</td><td>Сравнить token запроса и последнее принятое поколение</td><td>Добавить атомарный reject устаревшего token или перенести эффект в ресурс с такой проверкой</td></tr><tr><td>Старый worker снял новую блокировку</td><td>Release ищет только по имени</td><td>Сопоставить <code>leaseId</code> и текущий handle</td><td>Отклонять release, если handle больше не принадлежит владельцу</td></tr><tr><td>Операция зависает после expiry</td><td>Нет отдельного таймаута работы или отмены</td><td>Проверить длительность критической секции и путь отмены</td><td>Ограничить работу, обновлять lease по контракту или возвращать ошибку владельцу</td></tr><tr><td>Одинаковая задача выполнена дважды</td><td>Fencing не равен идемпотентности</td><td>Проверить ключ операции и повторы после ответа</td><td>Добавить идемпотентный эффект; не выдавать его за замену fencing</td></tr><tr><td>Нельзя объяснить отказ</td><td>Логи не связывают worker, lease и ресурс</td><td>Найти <code>ownerId</code>, <code>leaseId</code>, token и результат записи</td><td>Логировать корреляционный пакет без секретов и персональных данных</td></tr></tbody></table>\n<h2>Порядок проверки</h2>\n<ol><li>Назовите один предмет блокировки и один ресурс, который меняется. Если операция трогает несколько ресурсов, опишите их границы отдельно.</li><li>Зафиксируйте, что означает окончание владения: release, expiry, отзыв сессии или событие, определённое вашим провайдером.</li><li>Получите уникальный handle текущего захвата. Старый worker не должен снять новый lease только потому, что знает имя lock.</li><li>Выберите монотонный fencing token для каждого ресурса. Не выводите его из системных часов worker-а.</li><li>Встройте сравнение token и запись эффекта в одну атомарную операцию ресурса.</li><li>Воспроизведите отрицательный путь: token 1 остановился, lease истёк, token 2 записал результат, token 1 пришёл поздно. Зафиксируйте именно отказ поздней записи.</li><li>Проверьте повтор запроса и ошибку release отдельно. Они дополняют fencing и не следуют из него автоматически.</li><li>Только после этого проверяйте TTL, keep-alive, переподключение и отказ провайдера в тесте конкретной интеграции.</li></ol>\n<h2>Ограничения</h2>\n<p>Fencing не делает операцию exactly-once. Он не отменяет старую работу и не исправляет побочный эффект, который уже произошёл до проверки. Он также не решает конфликт, если два действия законно должны выполняться параллельно. В этом случае им нужен разный ключ ресурса или другой протокол.</p>\n<p>Короткий lease нельзя объявлять безопасным только потому, что обычная операция обычно укладывается в его срок. Нужно учитывать паузы процесса, задержку сети, очередь на сервере, повторные попытки и время ответа защищаемого ресурса. Длинный lease уменьшает число ложных истечений, но увеличивает окно ожидания после сбоя. Ни один TTL не заменяет проверку поздней записи.</p>\n<p>Учебный код выше не подтверждает поведение конкретного провайдера и не содержит production-результатов. Он проверяет только контракт: после принятия token 2 запрос с token 1 получает явный отказ. Если выбранная база или API не умеют выполнить такой reject, это нужно записать как ограничение дизайна, а не скрывать увеличением TTL. В etcd lease привязывает ключ к времени жизни и удаляет ключ после expiry, а ревизия упорядочивает изменения; downstream-ресурс всё равно должен сам принять и сравнить fencing token.</p>\n<h2>Критерий готовности</h2>\n<p>Механизм готов к интеграционной проверке, когда для одного доменного ресурса можно показать четыре наблюдаемых факта: новый владелец получает новое поколение; ресурс атомарно принимает его; поздний запрос старого владельца получает отдельный отказ и не меняет значение; старый release не снимает новый lease. В тестовом отчёте должны остаться token, leaseId, порядок событий и итоговое значение ресурса. Без этих фактов утверждение «распределённая блокировка защищает критическую секцию» слишком сильное.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/\" target=\"_blank\" rel=\"noopener\">Redis: Distributed Locks with Redis</a> — описывает свойства блокировки, ограниченное время владения и отдельно рекомендует fencing tokens для долгих операций.</li><li><a href=\"https://etcd.io/docs/v3.4/learning/api/\" target=\"_blank\" rel=\"noopener\">etcd v3.4 API overview</a> — фиксирует для исторической версии смысл lease, keep-alive и ревизий; ревизия становится fencing token только если защищаемый ресурс проверяет её атомарно.</li><li><a href=\"https://www.postgresql.org/docs/current/explicit-locking.html\" target=\"_blank\" rel=\"noopener\">PostgreSQL: Explicit Locking</a> — объясняет, что advisory locks имеют смысл, заданный приложением, и могут жить на уровне сессии или транзакции.</li></ul>"
|
||
}
|