Files
progcode/editorial/agent-rewrites/239.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
15 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": 239,
"slug": "editorial-2021-05-mechanism-distributed-locks",
"title": "Почему distributed lock не защищает позднюю запись",
"excerpt": "Lease координирует владельцев, но не останавливает проснувшийся процесс. Разбираем fencing token, атомарную проверку на ресурсе и диагностику stale write.",
"contentHtml": "<p>В журнале появляется странная последовательность: worker-A получил lock, надолго замолчал, worker-B получил тот же ресурс и записал новый результат, а затем A проснулся и тоже получил успешный ответ на запись. В базе снова лежит старое значение. В очереди возникла повторная команда. Внешний API принял действие от процесса, который уже не владел именем ресурса.</p>\n<p>Цена ошибки — не только одна неверная строка. Команда может решить, что lock-сервис выдал два владения одновременно, увеличить TTL и оставить настоящую дыру. Старый процесс не обязан знать, что его lease истёк. Проверка «lock был захвачен в начале» не защищает операцию, которая завершается позже.</p>\n<p><strong>Тезис:</strong> lease отвечает за координацию владельцев, а fencing token защищает финальную запись. Ресурс должен хранить последнее принятое поколение и атомарно отклонять запрос с меньшим или равным token. Все worker, lease, token и времена ниже учебные: пример не запускает etcd, Redis, ZooKeeper, базу или сеть.</p>\n<h2>Два разных вопроса</h2>\n<p>Lock authority отвечает: «кому сейчас выдано владение именем?» Он может выдать lease-1 worker-A, дождаться expiry и выдать lease-2 worker-B. Lease ограничивает время владения. Keepalive продлевает его только пока authority получает подтверждения.</p>\n<p>Защищаемый ресурс отвечает иначе: «можно ли этому запросу изменить моё состояние после уже принятого поколения?» Ресурсом может быть строка в базе, объектное хранилище, файл или сервер, который принимает команду. Если он не проверяет поколение сам, наличие lock в другом сервисе не влияет на его решение.</p>\n<table><caption>Граница ответственности lock и ресурса</caption><thead><tr><th scope=\"col\">Событие</th><th scope=\"col\">Знает authority</th><th scope=\"col\">Знает ресурс</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>A получил token 1</td><td>lease-1 активен</td><td>ничего</td><td>передать token вместе с записью</td></tr><tr><td>lease-1 истёк</td><td>имя доступно</td><td>A всё ещё может ждать</td><td>не считать A остановленным</td></tr><tr><td>B получил token 2</td><td>lease-2 активен</td><td>ничего</td><td>передать token вместе с записью</td></tr><tr><td>ресурс принял token 2</td><td>может не участвовать</td><td>highest token равен 2</td><td>сохранить новое значение</td></tr><tr><td>поздний запрос A с token 1</td><td>может видеть lease-2</td><td>token 1 устарел</td><td>отклонить запись</td></tr></tbody></table>\n<p>Ресурсу не нужно снова спрашивать authority, жив ли lease-A. Такой запрос добавляет сетевой вызов и новую гонку. Достаточно собственного факта: token 2 уже принят для этого resource key. Это узкая гарантия. Она не останавливает A и не делает всю бизнес-операцию exactly-once.</p>\n<h2>Механизм fencing token</h2>\n<p>При каждом новом захвате authority выдаёт монотонное поколение. Token 1 относится к lease-A, token 2 — к lease-B. Request несёт token до точки, где появляется эффект. Ресурс хранит <code>acceptedFenceToken</code>. Условие записи выглядит так:</p>\n<pre><code>function write(resource, request) {\n if (request.fenceToken &lt;= resource.acceptedFenceToken) {\n return { status: 'rejected-stale-fence' };\n }\n resource.acceptedFenceToken = request.fenceToken;\n resource.value = request.value;\n return { status: 'accepted' };\n}</code></pre>\n<p>Это псевдокод. В SQL условие и присваивание должны быть одной операцией, например <code>UPDATE ... SET value = $1, fence_token = $2 WHERE id = $3 AND fence_token &lt; $2</code>. По числу изменённых строк видно, принял ли ресурс запрос. Отдельное чтение token, пауза и последующий write оставляют ту же гонку.</p>\n<p>Строгое сравнение важно. Равный token не делает request новым. Повтор доставки с тем же token требует отдельного <code>operationId</code> и идемпотентного журнала. <code>ownerId</code> показывает автора, <code>leaseId</code> связывает release с конкретным захватом, <code>fenceToken</code> задаёт порядок поколений, а <code>operationId</code> различает повторы одной операции.</p>\n<figure><img src=\"/assets/editorial/2021/distributed-lock-fence-boundary-2021.svg\" alt=\"Схема fencing: worker-A получает token 1, lease истекает, worker-B получает token 2, ресурс принимает token 2 и отклоняет поздний запрос с token 1\" loading=\"lazy\" /><figcaption>Authority выдаёт поколения, но обязательная защита появляется в ресурсе: он сравнивает token с последним принятым значением.</figcaption></figure>\n<h2>Пошаговый пример гонки</h2>\n<p>Пусть A получил token 1 и начал расчёт. Расчёт должен был занять меньше lease, но процесс остановился на сборке мусора или на медленном вызове базы. Lease истёк. B получил token 2, закончил расчёт и записал <code>v2</code>. A продолжил работу и отправил уже вычисленный <code>v1</code>.</p>\n<pre><code>t=0 A: acquire(resource-7) -&gt; lease-A, token=1\nt=5 A: compute() -&gt; процесс остановился\nt=10 authority: lease-A expired\nt=11 B: acquire(resource-7) -&gt; lease-B, token=2\nt=12 B: write(v2, token=2) -&gt; accepted; highest=2\nt=13 A: write(v1, token=1) -&gt; rejected; highest=2</code></pre>\n<p>Небезопасный ресурс делает иначе:</p>\n<pre><code>resource.value = 'v2'; // B\nresource.value = 'v1'; // A: поздняя запись проходит</code></pre>\n<p>Здесь ресурс не знает о lock и не отличает старый запрос от нового. Лог о том, что A когда-то владел lock, не исправит данные после записи. Если API не предоставляет условную запись, версию или серверную проверку, перенесите эффект на контролируемую транзакционную границу либо явно примите, что lock только координирует работу.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><caption>Диагностика распределённой блокировки</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Старый worker получил 200 после нового</td><td>ресурс не проверяет token</td><td>сравнить token запроса и highest token</td><td>добавить атомарный reject stale write</td></tr><tr><td>Старый release снял lock B</td><td>release проверяет только имя</td><td>сверить owner, leaseId и token текущего захвата</td><td>отклонять старый handle</td></tr><tr><td>После retry получился duplicate</td><td>fencing принят за идемпотентность</td><td>найти operationId и журнал эффекта</td><td>добавить идемпотентный контракт отдельно</td></tr><tr><td>Растёт доля busy</td><td>lease не продлевается или ресурс долго занят</td><td>разделить acquire, keepalive и write latency</td><td>настроить TTL по измерениям, не скрывать отказ</td></tr><tr><td>Нет token в запросе ресурса</td><td>граница защиты осталась в authority</td><td>проследить поля до финального write</td><td>передавать и сохранять поколение на ресурсе</td></tr></tbody></table>\n<h2>Порядок внедрения и проверки</h2>\n<ol><li>Назовите один <code>resource key</code> и финальный write, который создаёт риск. Не прячьте несколько ресурсов за одним общим lock name.</li><li>Опишите, что завершает владение: release, expiry, revoke или потеря сессии. Зафиксируйте исход каждого варианта.</li><li>Сохраните точный handle захвата: owner, leaseId и выданный fence token. Старый handle не должен освобождать новый lease.</li><li>Выберите источник монотонных поколений для каждого resource key. Не выводите token из wall clock и не называйте произвольный уникальный key fencing token без контракта.</li><li>Сделайте сравнение token и запись эффекта одной атомарной операцией на ресурсе. Возвращайте отдельный результат <code>stale</code>, а не успешный ответ или общий timeout.</li><li>Проверьте interleaving: A получил token 1 и остановился, lease истёк, B получил token 2 и записал, поздний A был отклонён.</li><li>Отдельно проверьте повтор доставки, retry и идемпотентность. Fencing задаёт порядок владельцев, но не отвечает за повтор одного бизнес-эффекта.</li><li>После этого испытайте provider на версии API, TTL, keepalive, revoke, сбоях связи и нагрузке. Учебный пример не заменяет интеграционный тест.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Fencing не отзывает уже выполняющийся код. Он не отменяет запрос, ушедший без проверки, и не исправляет запись, принятую до добавления правила. Он не гарантирует согласованность между двумя независимыми ресурсами. Для нескольких записей нужна общая транзакционная граница, протокол саги или другая выбранная модель.</p>\n<p>Keepalive сокращает вероятность expiry во время нормальной работы, но не доказывает, что поздний request безопасен. Между подтверждением lease и финальным write остаются паузы, сетевые разрывы и очереди. Provider key тоже не становится fencing token автоматически: важны порядок поколений и проверка на стороне получателя.</p>\n<p>Учебные фрагменты не дают production-результатов. Они не измеряют задержки, clock drift, SLA, пропускную способность или поведение кластера. Проверяемый критерий готовности таков: после принятого token 2 любой запрос с token 1 получает явный stale-результат, значение ресурса остаётся результатом token 2, а старый release не меняет lease B.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://etcd.io/docs/v3.7/learning/api/\" target=\"_blank\" rel=\"noopener noreferrer\">etcd v3.7 API: leases и keep-alive</a> — официальное описание TTL lease, истечения и обновления.</li><li><a href=\"https://etcd.io/docs/v3.6/dev-guide/api_concurrency_reference_v3/\" target=\"_blank\" rel=\"noopener noreferrer\">etcd v3.6 API reference: concurrency Lock</a> — официальный контракт Lock, key и освобождения по lease.</li><li><a href=\"https://www.postgresql.org/docs/current/sql-update.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL current: UPDATE</a> — официальная документация по условному обновлению и числу реально изменённых строк.</li></ul>"
}