Распределённые блокировки и координация

Когда нужна распределённая блокировка, чем опасен Redlock, как работают lease в etcd и почему координацию часто стоит избегать

Иногда нужно, чтобы ровно один экземпляр сервиса делал что-то в один момент: обрабатывал задачу, запускал миграцию, был лидером. Распределённая блокировка выглядит просто, но это одна из самых коварных тем: блокировка без fencing token не защищает от «уснувшего» владельца, а наивный Redlock вызвал многолетний спор о корректности. Часто правильный ответ — спроектировать так, чтобы блокировка была не нужна.

Третья статья серии «System design: resilience». Всё ниже проверено на стенде architecture/resilience/locking: там и порча данных без защиты, и её отсутствие с защитой — воспроизводимо, а не на словах.

Гравюра в стиле «Полдень»: дверь хранилища со встроенным храповым счётчиком — терракотовой зубчатой рейкой без цифр. Слева бодрый служащий с биркой, соответствующей текущему зубцу, пропускается внутрь. Справа — тот, кто явно проспал: в паутине и пыли, с фонарём, и его старая бирка отбивается подпружиненным рычагом. Решает механизм двери, а не ключ

В статье

Зачем координация

Под «нужна блокировка» обычно скрываются три разные задачи:

  • Эксклюзивная задача — периодическую работу должен выполнить ровно один инстанс, а не все пять по расписанию (подробнее про этот класс — в распределённом cronготовится, с 19 сентября).
  • Лидер — один инстанс принимает решения или владеет ресурсом, остальные ждут своей очереди.
  • Single-writer к данным — не допустить одновременной записи в одну сущность.

Различать их важно, потому что у третьей задачи почти всегда есть решение без распределённого лока — на стороне базы. К этому вернёмся.

Почему распределённый лок сложнее локального

Локальный мьютекс работает, потому что у потоков есть общая память и общий планировщик: владение однозначно. В распределённой системе нет ни того, ни другого — только сообщения, которые могут задержаться, и часы, которые идут по-разному.

Отсюда неизбежность TTL: если владелец лока умрёт, лок должен освободиться сам, иначе система встанет навсегда. Но TTL немедленно порождает проблему: срок может истечь, пока владелец жив. Долгая пауза сборщика мусора, вытеснение процесса, зависшая сеть — и владелец продолжает считать, что держит лок, хотя тот уже отдан другому.

Это не редкий сбой, а нормальная работа системы: пауза в сотни миллисекунд — обычное дело, а лок с TTL в секунды выглядит «безопасным».

Уснувший владелец

Сценарий, который сделал тему знаменитой (его подробно разобрал Мартин Клеппманн):

  1. Владелец A берёт лок и начинает работу.
  2. A «засыпает» — пауза GC дольше TTL.
  3. TTL истекает, лок свободен. Владелец B берёт его и пишет свои данные.
  4. A просыпается. Он не знает, что потерял лок, и тоже пишет.

Итог — двое считали себя владельцами, и запись A затёрла свежие данные B. Стенд воспроизводит это буквально:

ресурс итог
без fencing A-stale — свежие данные B затёрты
с fencing B-data — запоздавшая запись A отвергнута

Ключевой вывод: сам по себе лок этого не лечит. Ни один TTL-лок не может гарантировать, что владелец узнает о потере владения вовремя, — сообщение об этом может просто не успеть.

Fencing-токен

Лечит не лок, а ресурс. Fencing-токен — монотонно растущий номер, который выдаётся вместе с локом; владелец несёт его к ресурсу при каждой записи, а ресурс отвергает всё, что старее уже принятого.

В сценарии выше: A получил токен 1, B — токен 2. B записал с токеном 2, ресурс запомнил «видел 2». Запоздавшая запись A с токеном 1 отвергается — не потому, что A узнал о потере лока, а потому, что ресурс сам умеет отличать устаревшего писателя.

Отсюда два практических следствия:

  • Токен должен проверять ресурс. Если хранилище не умеет условную запись «применить, только если номер не меньше», fencing не работает — сколько бы токенов ни выдавал лок-сервис.
  • Токен должен быть монотонным. Не время (часы разъезжаются), а номер, который выдаётся строго возрастающим: счётчик или версия/revision от координатора.
  • Токен должен выдаваться атомарно с локом. Если захват и выдача номера — две отдельные операции, возможно чередование: A взял лок, уснул до получения номера, TTL истёк, B взял лок и номер, записал; A проснулся, получил номер больше — и затёр данные B, уже не владея локом. Стенд это воспроизводит, поэтому и лок, и токен там выдаёт одна атомарная операция.

Redis и спор о Redlock

Redlock — алгоритм распределённой блокировки на нескольких независимых Redis. Вокруг его корректности идёт давний спор: Клеппманн разбирает его как небезопасный при паузах и рассинхроне часов, Антирез (автор Redis) отвечает, что модель угроз и допущения были другими. Вердикт здесь выносить не будем — вместо этого посмотрим на конкретный, воспроизводимый сценарий.

На стенде обычный лок на Redis (SET key owner NX PX ttl): владелец A взял лок с TTL и «уснул»; TTL истёк; B взял освободившийся лок. Оба живы и оба считают себя владельцами — двойное владение зафиксировано. Именно fencing-токены (на стенде — монотонный INCR) не дают этому испортить данные.

Ещё одна деталь, которую часто пропускают: снимать лок нужно только свой. Наивное «проверил владельца, потом удалил» двумя командами разъезжается — между проверкой и удалением TTL может истечь и лок достанется другому, а вы снимете чужой. На стенде это одна атомарная Lua-операция: сравнить владельца и удалить.

И следом менее очевидное: идентификатор владельца должен быть уникален для каждого захвата, а не быть стабильным именем вроде хоста, pid или worker-1. Иначе ломается сценарий, который выглядит совершенно невинно: воркер взял лок, TTL истёк, тот же воркер взял лок заново — а запоздавшее освобождение от первого захвата снимает второй, живой лок. Формально «свой» лок и снят, а фактически чужой. Поэтому на стенде идентификатор генерируется случайным внутри API захвата, и отдать его наружу вызывающему негде.

Практический вывод про Redis-локи: они уместны как оптимизация («обычно работу делает один инстанс — не будем дублировать усилия»), но не как гарантия корректности. Если от эксклюзивности зависит целостность данных, нужен либо fencing на стороне ресурса, либо примитив с настоящими гарантиями.

etcd: корректный примитив

etcd (и ZooKeeper в той же роли) дают то, что в Redis приходится собирать вручную:

  • Lease с TTL — сессия, которую владелец продлевает; отвалился — лидерство уходит автоматически.
  • Выборы — примитив leader election, а не самодельная схема на ключах.
  • Монотонная revision — глобальный номер версии хранилища, который уже является fencing-токеном: его не нужно заводить отдельно.

На стенде два кандидата по очереди выигрывают выборы, и revision ключа лидера растёт: 2 → 4. Это и есть готовые fencing-токены — координатор выдаёт их по построению.

Цена — отдельный кластер координатора, который надо эксплуатировать, и линеаризуемость, за которую платят латентностью. За этими гарантиями стоит консенсус: как он работает и почему стоит дорого — в карте консенсуса. Часто etcd в системе уже есть (например, под распределённую конфигурацию) — тогда брать координацию оттуда разумнее, чем городить своё.

Как обойтись без блокировки

Самый надёжный лок — тот, который не нужен. Прежде чем вводить координацию, стоит проверить три обходных пути:

  • Идемпотентность. Если операцию безопасно выполнить дважды, эксклюзивность перестаёт быть обязательной: пусть выполнят двое, результат один (как это делается — в гарантиях доставки и идемпотентности).
  • Оптимистическая блокировка. Вместо «захватить и держать» — запись с проверкой версии: UPDATE ... WHERE version = N. Конфликт виден по нулю затронутых строк, повторяем. По сути тот же fencing, только встроенный в базу (см. уровни изоляции и аномалии).
  • Разделение по ключу. Если поток задач партиционирован так, что каждую сущность обрабатывает всегда один и тот же потребитель, эксклюзивность получается из маршрутизации, а не из лока.

Распределённая блокировка оправдана, когда обойти её нельзя: неидемпотентная операция во внешней системе, которая не умеет условную запись, — и тогда сразу с честным пониманием её границ.

Checklist

  • Сформулировано, какая из трёх задач решается: эксклюзивная работа, лидер или single-writer.
  • Проверены альтернативы: идемпотентность, оптимистическая блокировка, разделение по ключу.
  • Если лок всё же нужен: понятно, что происходит при паузе владельца дольше TTL.
  • Ресурс проверяет fencing-токен — иначе двойное владение испортит данные независимо от лока.
  • Токен монотонный (счётчик или revision координатора), а не отметка времени.
  • Лок снимается атомарно и только свой.
  • Решено, лок — это гарантия корректности или лишь оптимизация; от ответа зависит выбор Redis против etcd/ZooKeeper.

Первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии