{ "index": 239, "slug": "editorial-2021-05-mechanism-distributed-locks", "title": "Почему distributed lock не защищает позднюю запись", "excerpt": "Lease координирует владельцев, но не останавливает проснувшийся процесс. Разбираем fencing token, атомарную проверку на ресурсе и диагностику stale write.", "contentHtml": "
На дежурстве инженер увидел странный порядок в логе: worker-A получил lock, надолго замолчал, worker-B дождался освобождения ресурса и записал новый результат, а затем A проснулся и тоже получил успешный ответ. В базе снова оказалось старое значение. Похожий след появляется у очереди, когда повторная команда проходит от процесса, который уже потерял право менять состояние.
\nЦена ошибки — не только одна неверная строка. Команда может решить, что сервис блокировок выдал два владения одновременно, увеличить TTL и оставить настоящую дыру. Lease ограничивает время владения, но не возвращает процесс назад и не отменяет запрос, который уже ушёл к ресурсу. Поэтому проверка «lock был захвачен в начале» не защищает операцию, которая завершается позже.
\nРабочее правило: lease отвечает за координацию владельцев, а fencing token защищает финальную запись. Ресурс хранит последнее принятое поколение и атомарно отклоняет запрос с меньшим или равным token. Все worker, lease, token и времена ниже учебные: пример не запускает etcd, Redis, базу или сеть.
\nВ гонке легко назвать все значения «токеном» и потерять смысл каждого поля. ownerId обозначает процесс, leaseId связывает его с конкретным захватом, fenceToken задаёт порядок поколений, а operationId позволяет распознать повтор одной бизнес-операции. Эти поля не заменяют друг друга.
| Поле | Кто выдаёт или создаёт | Где проверяется | Какую проблему решает |
|---|---|---|---|
ownerId | worker или его экземпляр | лог и диагностика | показывает, кто отправил запрос |
leaseId | lock authority | release и контракт владения | не даёт старому handle снять новый lease |
fenceToken | authority с монотонным счётчиком | защищаемый ресурс | отбрасывает позднее поколение |
operationId | доменная операция | журнал эффекта или API | отличает повтор доставки от новой операции |
Lock authority отвечает на вопрос «кому сейчас выдано имя?». Он может выдать lease-1 worker-A, дождаться истечения и выдать lease-2 worker-B. Защищаемый ресурс отвечает на другой вопрос: «можно ли этому запросу менять моё состояние после уже принятого поколения?». Если ресурс не проверяет поколение сам, наличие lock в другом сервисе не влияет на его решение.
\nПусть A получил lease и fenceToken=41, после чего начал расчёт. Расчёт должен был завершиться раньше TTL, но процесс остановился на сборке мусора или на медленном вызове базы. Authority признал lease истёкшим. B получил новое поколение 42, закончил расчёт и записал v2. Когда A продолжил работу, в его памяти всё ещё был результат v1.
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\nНебезопасный ресурс делает иначе:
\nresource.value = 'v2'; // B\nresource.value = 'v1'; // A: поздняя запись проходит\nЛог о том, что A когда-то владел lock, не исправит данные после записи. Проверка «lease ещё жив?» непосредственно перед записью тоже не является достаточной: между чтением статуса и отправкой эффекта может пройти пауза. Решение должно находиться на той же границе, где ресурс принимает изменение.
\nПри каждом новом захвате конкретного resourceKey authority выдаёт большее поколение. Ресурс хранит acceptedFenceToken рядом с защищаемым состоянием. Условие простое: принять запрос можно только при request.fenceToken > acceptedFenceToken. После принятия ресурс в одной атомарной операции обновляет и данные, и сохранённое поколение.
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}\nЭто псевдокод, а не готовая транзакция. Для одной строки PostgreSQL условие и присваивание можно выразить так:
\nUPDATE resource_state\nSET value = $1, fence_token = $2\nWHERE resource_key = $3\n AND fence_token < $2;\nСмотрите на число изменённых строк: ноль означает, что запрос не прошёл условие или ресурс уже обработал более новое поколение. Отдельное чтение token, пауза и последующий UPDATE оставляют ту же гонку. Поле fence_token должно переживать перезапуск и не сбрасываться вместе с памятью worker-а; иначе старый token может снова стать «новым».
В etcd lease живёт ограниченное время: кластер удаляет связанные ключи после истечения lease, если keep-alive не пришёл. Concurrency Lock возвращает ключ владения, который существует, пока lock удерживается; его можно использовать в транзакции для защиты изменений внутри самого etcd. Это полезная координация, но ключ lock или обычный leaseId не превращается автоматически в fencing token для внешней базы, файла или API.
Для внешнего ресурса нужен отдельный контракт. Authority должен выдать монотонное поколение, worker обязан передать его в запросе, а ресурс — проверить и записать результат атомарно. В работе Chubby эту роль выполняет sequencer: сервер-получатель проверяет, что последовательность всё ещё действительна, и отклоняет операцию с устаревшим состоянием. Название поля может быть другим, но граница остаётся той же.
\n| Шаг | Состояние authority | Состояние ресурса | Ожидаемый результат |
|---|---|---|---|
| A захватил ресурс | lease-A активен, token 41 | последний token 40 | A может начать работу |
| lease-A истёк | имя можно выдать снова | A может продолжать вычисление | старый процесс не считается остановленным |
| B захватил ресурс | lease-B активен, token 42 | последний token 40 | B передаёт 42 в финальный write |
| B записал | может не участвовать | token 42 и v2 | ресурс принимает изменение |
| A записал поздно | может видеть новый lease | 41 меньше 42 | ресурс отвечает stale |
Если операция меняет несколько строк, условие должно защищать одну выбранную транзакционную границу. Несколько независимых UPDATE не превращаются в атомарную бизнес-операцию только потому, что в каждом есть token. Для внешнего API нужны его условная запись, идемпотентный ключ или другая явно выбранная гарантия.
| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| Старый worker получил успех после нового | ресурс не проверяет token | сравнить token запроса с сохранённым | добавить атомарный reject устаревшего write |
| Старый release снял lock B | release проверяет только имя | сверить owner, leaseId и handle текущего захвата | отклонять старый handle |
| Повтор создал второй эффект | fencing приняли за идемпотентность | найти operationId и записи эффекта | добавить идемпотентный контракт отдельно |
| Token повторился после рестарта | счётчик жил только в памяти | сравнить историю выдачи и состояние authority | сделать источник поколений долговечным |
| Запрос ресурса не содержит token | граница защиты осталась в authority | проследить поля до финального write | передавать и проверять поколение на ресурсе |
resourceKey и финальный write, который создаёт риск. Не прячьте несколько независимых ресурсов за одним общим lock name.stale, а не успешный ответ или общий timeout.operationId. Fencing задаёт порядок владельцев, но не отвечает за повтор одного бизнес-эффекта.Fencing не отзывает уже выполняющийся код. Он не отменяет запрос, ушедший без проверки, и не исправляет запись, принятую до добавления правила. Он не гарантирует согласованность между двумя независимыми ресурсами. Для нескольких записей нужна общая транзакционная граница, протокол саги или другая выбранная модель.
\nKeepalive уменьшает вероятность истечения lease во время нормальной работы, но не доказывает безопасность позднего request. Между подтверждением lease и финальным write остаются паузы, сетевые разрывы и очереди. Счётчик тоже не становится fencing token автоматически: важны монотонный порядок, долговечность и проверка на стороне получателя.
\nУчебные фрагменты не дают измерений задержки, clock drift, SLA или пропускной способности кластера. Критерий готовности уже конкретнее: после принятого token 42 любой запрос с token 41 получает явный stale-результат, значение ресурса остаётся v2, старый release не меняет lease B, а повтор с тем же operationId не создаёт второй эффект.
Вернитесь к сцене из начала: перед увеличением TTL сначала найдите финальный ресурс и проверьте, доходит ли до него поколение. Если поля нет или ресурс не умеет условную запись, lock по-прежнему только координирует worker-ы; защиту нужно добавить именно на границе эффекта.
\nWHERE, результату обновления и числу изменённых строк.