8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"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 проснулся и тоже получил успешный ответ. В базе снова оказалось старое значение. Похожий след появляется у очереди, когда повторная команда проходит от процесса, который уже потерял право менять состояние.</p>\n<p>Цена ошибки — не только одна неверная строка. Команда может решить, что сервис блокировок выдал два владения одновременно, увеличить TTL и оставить настоящую дыру. Lease ограничивает время владения, но не возвращает процесс назад и не отменяет запрос, который уже ушёл к ресурсу. Поэтому проверка «lock был захвачен в начале» не защищает операцию, которая завершается позже.</p>\n<p><strong>Рабочее правило:</strong> lease отвечает за координацию владельцев, а fencing token защищает финальную запись. Ресурс хранит последнее принятое поколение и атомарно отклоняет запрос с меньшим или равным token. Все worker, lease, token и времена ниже учебные: пример не запускает etcd, Redis, базу или сеть.</p>\n<h2>Сначала разделим четыре идентификатора</h2>\n<p>В гонке легко назвать все значения «токеном» и потерять смысл каждого поля. <code>ownerId</code> обозначает процесс, <code>leaseId</code> связывает его с конкретным захватом, <code>fenceToken</code> задаёт порядок поколений, а <code>operationId</code> позволяет распознать повтор одной бизнес-операции. Эти поля не заменяют друг друга.</p>\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><code>ownerId</code></td><td>worker или его экземпляр</td><td>лог и диагностика</td><td>показывает, кто отправил запрос</td></tr><tr><td><code>leaseId</code></td><td>lock authority</td><td>release и контракт владения</td><td>не даёт старому handle снять новый lease</td></tr><tr><td><code>fenceToken</code></td><td>authority с монотонным счётчиком</td><td>защищаемый ресурс</td><td>отбрасывает позднее поколение</td></tr><tr><td><code>operationId</code></td><td>доменная операция</td><td>журнал эффекта или API</td><td>отличает повтор доставки от новой операции</td></tr></tbody></table>\n<p>Lock authority отвечает на вопрос «кому сейчас выдано имя?». Он может выдать lease-1 worker-A, дождаться истечения и выдать lease-2 worker-B. Защищаемый ресурс отвечает на другой вопрос: «можно ли этому запросу менять моё состояние после уже принятого поколения?». Если ресурс не проверяет поколение сам, наличие lock в другом сервисе не влияет на его решение.</p>\n<h2>Сценарий гонки: lock уже истёк, процесс ещё жив</h2>\n<p>Пусть A получил lease и <code>fenceToken=41</code>, после чего начал расчёт. Расчёт должен был завершиться раньше TTL, но процесс остановился на сборке мусора или на медленном вызове базы. Authority признал lease истёкшим. B получил новое поколение 42, закончил расчёт и записал <code>v2</code>. Когда A продолжил работу, в его памяти всё ещё был результат <code>v1</code>.</p>\n<pre><code>t=0 A: acquire(resource-7) -> lease-A, fenceToken=41\nt=5 A: compute() -> процесс остановился\nt=10 authority: lease-A expired\nt=11 B: acquire(resource-7) -> lease-B, fenceToken=42\nt=12 B: write(v2, token=42) -> accepted; highest=42\nt=13 A: write(v1, token=41) -> rejected; highest=42</code></pre>\n<p>Небезопасный ресурс делает иначе:</p>\n<pre><code>resource.value = 'v2'; // B\nresource.value = 'v1'; // A: поздняя запись проходит</code></pre>\n<p>Лог о том, что A когда-то владел lock, не исправит данные после записи. Проверка «lease ещё жив?» непосредственно перед записью тоже не является достаточной: между чтением статуса и отправкой эффекта может пройти пауза. Решение должно находиться на той же границе, где ресурс принимает изменение.</p>\n<figure><img src=\"/assets/editorial/2021/distributed-lock-fence-boundary-2021.svg\" alt=\"Схема fencing: worker-A получает поколение 41, lease истекает, worker-B получает 42, ресурс принимает 42 и отклоняет поздний запрос A\" loading=\"lazy\" /><figcaption>Authority выдаёт новые поколения, а ресурс сравнивает их с последним принятым token. Именно это сравнение закрывает окно для позднего запроса.</figcaption></figure>\n<h2>Что именно делает fencing token</h2>\n<p>При каждом новом захвате конкретного <code>resourceKey</code> authority выдаёт большее поколение. Ресурс хранит <code>acceptedFenceToken</code> рядом с защищаемым состоянием. Условие простое: принять запрос можно только при <code>request.fenceToken > acceptedFenceToken</code>. После принятия ресурс в одной атомарной операции обновляет и данные, и сохранённое поколение.</p>\n<pre><code>function write(resource, request) {\n if (request.fenceToken <= resource.acceptedFenceToken) {\n return { status: 'rejected-stale-fence' };\n }\n\n resource.acceptedFenceToken = request.fenceToken;\n resource.value = request.value;\n return { status: 'accepted' };\n}</code></pre>\n<p>Это псевдокод, а не готовая транзакция. Для одной строки PostgreSQL условие и присваивание можно выразить так:</p>\n<pre><code>UPDATE resource_state\nSET value = $1, fence_token = $2\nWHERE resource_key = $3\n AND fence_token < $2;</code></pre>\n<p>Смотрите на число изменённых строк: ноль означает, что запрос не прошёл условие или ресурс уже обработал более новое поколение. Отдельное чтение token, пауза и последующий <code>UPDATE</code> оставляют ту же гонку. Поле <code>fence_token</code> должно переживать перезапуск и не сбрасываться вместе с памятью worker-а; иначе старый token может снова стать «новым».</p>\n<h2>Граница etcd и внешнего ресурса</h2>\n<p>В etcd lease живёт ограниченное время: кластер удаляет связанные ключи после истечения lease, если keep-alive не пришёл. Concurrency Lock возвращает ключ владения, который существует, пока lock удерживается; его можно использовать в транзакции для защиты изменений внутри самого etcd. Это полезная координация, но ключ lock или обычный <code>leaseId</code> не превращается автоматически в fencing token для внешней базы, файла или API.</p>\n<p>Для внешнего ресурса нужен отдельный контракт. Authority должен выдать монотонное поколение, worker обязан передать его в запросе, а ресурс — проверить и записать результат атомарно. В работе Chubby эту роль выполняет sequencer: сервер-получатель проверяет, что последовательность всё ещё действительна, и отклоняет операцию с устаревшим состоянием. Название поля может быть другим, но граница остаётся той же.</p>\n<table><caption>Граница ответственности в протоколе</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 захватил ресурс</td><td>lease-A активен, token 41</td><td>последний token 40</td><td>A может начать работу</td></tr><tr><td>lease-A истёк</td><td>имя можно выдать снова</td><td>A может продолжать вычисление</td><td>старый процесс не считается остановленным</td></tr><tr><td>B захватил ресурс</td><td>lease-B активен, token 42</td><td>последний token 40</td><td>B передаёт 42 в финальный write</td></tr><tr><td>B записал</td><td>может не участвовать</td><td>token 42 и <code>v2</code></td><td>ресурс принимает изменение</td></tr><tr><td>A записал поздно</td><td>может видеть новый lease</td><td>41 меньше 42</td><td>ресурс отвечает <code>stale</code></td></tr></tbody></table>\n<p>Если операция меняет несколько строк, условие должно защищать одну выбранную транзакционную границу. Несколько независимых <code>UPDATE</code> не превращаются в атомарную бизнес-операцию только потому, что в каждом есть token. Для внешнего API нужны его условная запись, идемпотентный ключ или другая явно выбранная гарантия.</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 получил успех после нового</td><td>ресурс не проверяет token</td><td>сравнить token запроса с сохранённым</td><td>добавить атомарный reject устаревшего write</td></tr><tr><td>Старый release снял lock B</td><td>release проверяет только имя</td><td>сверить owner, leaseId и handle текущего захвата</td><td>отклонять старый handle</td></tr><tr><td>Повтор создал второй эффект</td><td>fencing приняли за идемпотентность</td><td>найти operationId и записи эффекта</td><td>добавить идемпотентный контракт отдельно</td></tr><tr><td>Token повторился после рестарта</td><td>счётчик жил только в памяти</td><td>сравнить историю выдачи и состояние authority</td><td>сделать источник поколений долговечным</td></tr><tr><td>Запрос ресурса не содержит token</td><td>граница защиты осталась в authority</td><td>проследить поля до финального write</td><td>передавать и проверять поколение на ресурсе</td></tr></tbody></table>\n<h2>Порядок внедрения и воспроизводимая проверка</h2>\n<ol><li>Назовите один <code>resourceKey</code> и финальный write, который создаёт риск. Не прячьте несколько независимых ресурсов за одним общим lock name.</li><li>Зафиксируйте, что завершает владение: release, expiry, revoke или потеря сессии. Для каждого исхода запишите, может ли старый worker продолжить вычисление.</li><li>Сохраните точный handle захвата: owner, leaseId и выданный fence token. Старый handle не должен освобождать новый lease.</li><li>Выберите долговечный источник монотонных поколений для каждого resourceKey. Не выводите token из wall clock и не сбрасывайте счётчик при перезапуске authority.</li><li>Сделайте сравнение token и запись эффекта одной атомарной операцией на ресурсе. Возвращайте отдельный результат <code>stale</code>, а не успешный ответ или общий timeout.</li><li>Запустите interleaving-тест: A получил 41 и остановился, lease истёк, B получил 42 и записал, поздний A получил отказ; значение осталось результатом B.</li><li>Повторите тест после рестарта authority и ресурса: token 42 не должен быть принят как новое поколение, а следующий захват должен получить значение больше 42.</li><li>Отдельно проверьте повтор доставки, retry и идемпотентность по <code>operationId</code>. Fencing задаёт порядок владельцев, но не отвечает за повтор одного бизнес-эффекта.</li><li>После этого испытайте выбранный provider на версии API, TTL, keepalive, revoke, сбоях связи и нагрузке. Учебный пример проверяет механизм, но не заменяет интеграционный тест.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Fencing не отзывает уже выполняющийся код. Он не отменяет запрос, ушедший без проверки, и не исправляет запись, принятую до добавления правила. Он не гарантирует согласованность между двумя независимыми ресурсами. Для нескольких записей нужна общая транзакционная граница, протокол саги или другая выбранная модель.</p>\n<p>Keepalive уменьшает вероятность истечения lease во время нормальной работы, но не доказывает безопасность позднего request. Между подтверждением lease и финальным write остаются паузы, сетевые разрывы и очереди. Счётчик тоже не становится fencing token автоматически: важны монотонный порядок, долговечность и проверка на стороне получателя.</p>\n<p>Учебные фрагменты не дают измерений задержки, clock drift, SLA или пропускной способности кластера. Критерий готовности уже конкретнее: после принятого token 42 любой запрос с token 41 получает явный <code>stale</code>-результат, значение ресурса остаётся <code>v2</code>, старый release не меняет lease B, а повтор с тем же <code>operationId</code> не создаёт второй эффект.</p>\n<p>Вернитесь к сцене из начала: перед увеличением TTL сначала найдите финальный ресурс и проверьте, доходит ли до него поколение. Если поля нет или ресурс не умеет условную запись, lock по-прежнему только координирует worker-ы; защиту нужно добавить именно на границе эффекта.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.usenix.org/legacy/events/osdi06/tech/full_papers/burrows/burrows_html/\" target=\"_blank\" rel=\"noopener noreferrer\">The Chubby lock service for loosely-coupled distributed systems</a> — первоисточник о sequencer, поколении lock и проверке состояния на стороне сервера-получателя.</li><li><a href=\"https://etcd.io/docs/v3.7/learning/api/\" target=\"_blank\" rel=\"noopener noreferrer\">etcd v3.7 API</a> — официальное описание lease, keep-alive, ревизий и атомарных транзакций.</li><li><a href=\"https://etcd.io/docs/v3.7/dev-guide/api_concurrency_reference_v3/\" target=\"_blank\" rel=\"noopener noreferrer\">etcd v3.7 concurrency API reference</a> — официальный контракт Lock, ownership key и освобождения по lease.</li><li><a href=\"https://www.postgresql.org/docs/current/sql-update.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL current: UPDATE</a> — документация по условию <code>WHERE</code>, результату обновления и числу изменённых строк.</li></ul>"
|
||
}
|