Files
progcode/editorial/agent-rewrites/240.json
T

8 lines
16 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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 &lt;= 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>"
}