Распределенные блокировки
Распределенные блокировки обеспечивают синхронизированный доступ к ресурсам в распределенных системах, предотвращая гонки состояний и дублирование задач. В статье объясняется принцип их работы, подводные камни и выбор Redis как оптимального решения.
Что такое распределенные блокировки и зачем они нужны?
Распределенные блокировки — это ключевой механизм для синхронизации в распределенных системах. Они позволяют разным процессам или сервисам, работающим на разных узлах, безопасно координировать доступ к общим ресурсам без конфликтов и потери данных. Когда несколько сервисов пытаются изменить одни и те же данные одновременно или выполнить операцию в атомарном виде, без блокировки это может привести к неожиданным ошибкам. Например, если два процесса попытаются одновременно записать информацию в один и тот же файл, возможно повреждение его структуры. Аналогично, если несколько приложений одновременно запустят тяжеловесную операцию в базе данных, это может привести к избыточной нагрузке на сервер и деградации его производительности.
Использование распределенных блокировок позволяет:
- Гарантировать, что задача выполняется только на одном узле в любой момент времени
- Управлять конкурентным доступом к ресурсам, когда несколько сервисов могут обращаться к одной и той же сущности
- Избежать явления "гонки состояний" (race condition), приводящего к нежелательным последствиям
Проблемы гонки состояний и конкуренции за ресурсы
Гонка состояний — это ситуация, когда несколько процессов или сервисов одновременно пытаются выполнить операции с какого-либо ресурсом без синхронизации. Это может привести к тому что одна часть системы работают с устаревшими данными, а другая - с обновленными, что нарушает целостность логики приложения. Проблема гонки состояний была актуальна для нас в контексте работы масштабируемых сервисов, где имела место работа с общими ресурсами.
Примеры ситуаций, которые вызывают гонок состояний:
- Общий доступ к файлу. Представим что два процесса записывают данные в общий файл в бинарном формате. Каждая запись содержит заголовок, содержащий длину тела в байтах, и тело. При отсутствии блокировки об процесса могут одновременно выполнить запись в файл, что приведет к перемешиванию содержимого записей и повреждению данных
- Общий доступ к таблице базы данных. В разных инстансах одновременно выполняются операции чтения данных из таблицы и перезапись таблицы. При отсутствии блокировки возможны ошибки, поскольку перезапись таблицы неатомарна и чтение может быть выполнено в момент отсутствия таблицы
Наиболее известные примеры из реальной жизни
- Therac-25 (1985–1987) стал трагическим примером гонки состояний, когда ошибка в переключении режимов привела к смертельным дозам радиации для пациентов
- Mars Pathfinder (1997) зависал из-за гонки между задачами ОС VxWorks, что едва не сорвало миссию NASA
- В Bitcoin в 2010 году гонка состояний в коде позволила создать 184 миллиарда BTC и потребовала срочного хардфорка
- Уязвимость CVE-2019-5786 в Google Chrome в 2019 году возникла из-за гонки в FileReader API, что открыло путь к удалённому выполнению кода
- В 2021 году PrintNightmare в Windows 10 использовала гонку в службе печати, позволяя злоумышленникам повышать привилегии и атаковать корпоративные сети
Проблемы многократной обработки данных
Если несколько инстансов сервиса одновременно выполняют задачу с одним и тем же идентификатором без должной синхронизации, это может привести к дублированию результатов выполнения задачи или разным результатам, если одна из задач выполнилась успешно, другая — неуспешно.
В распределенных системах бывают ситуации, когда несколько инстансов одновременно обращаются к ресурсу, который имеет ограниченную доступность. Это может быть внешний API, который строго ограничен лимитом операций в заданный интервал времени. Отсутствие распределенной блокировки и, как следствие, многократное выполнение запроса с одинаковыми параметрами может привести к избыточному использованию и преждевременному исчерпанию лимита, а также нежелательным последствиям в виде временного или перманентного бана со стороны внешнего ресурса.
Почему мы выбрали Redis для распределенных блокировок?
При выборе хранилища для распределённых блокировок мы исходили из нескольких факторов. Блокировка — это, по сути, просто флаг, который сигнализирует о захвате ресурса. Такой флаг можно хранить в файловых системах, базах данных или in-memory хранилищах, и выбор инструмента зависит от конкретных задач.
В нашем случае блокировки используются в обработке данных, где отказ одного сервера не является критичной ситуацией. У нас нет сценариев, связанных с финансовыми операциями или другими высокочувствительными процессами, требующими абсолютной надёжности. Главным критерием для нас стала скорость установки и снятия блокировки, поскольку это напрямую влияет на производительность системы.
Существует множество вариаций способов и инструментов, позволяющих реализовать механизм распределенной блокировки. Мы рассмотртели разные варианты и остановились на Redis, поскольку он лучше всего соответствовал нашим требованиям. Ниже разберем, какие решения мы рассматривали и какие преимущества дает использование Redis-блокировок.
Преимущества Redis
Мы остановились на Redis-блокировках по нескольким причинам:
- Простота использования. Redis позволяет легко реализовать механизм блокировок с помощью атомарной операции
SET key value NX EX seconds
. - Быстродействие. Redis работает в памяти, что делает операции блокировки быстрыми и минимизирует задержки.
- Легкость развертывания и поддержки. Redis требует сравнительно небольшое количество ресурсов и прост в эксплуатации.
- Высокий уровень экспертизы по Redis в компании. Мы широко используем Redis в наших проектах, что позволило использовать уже имеющуюся экспертизу в эксплуатации хранилища.
- Универсальность. Помимо блокировок, мы также реализуем RateLimiter на базе Redis, используем его как кэш-хранилище и находим еще много полезных способов его применения.
Альтернативные решения
Etcd
является распределенным key-value хранилищем и имеет встроенную поддержку распределенных блокировок. Однако при детальном изучении практики их применения и независимых тестов мы обнаружили что эти блокировки небезопасны. Существуют сценарии, при которых несколько клиентов могут удерживать одну и ту же блокировкуRedLock
— алгоритм распределенных блокировок на основе Redis, который использует несколько независимых серверов Redis для повышения отказоустойчивости. Алгоритм предназначен для ситуаций, когда требуется надежная гарантия блокировки даже в условиях сетевых сбоев или падения отдельных узлов. Однако для наших сценариев использование RedLock является избыточным. Если Redis падает, блокировки сбрасываются, и наши приложения умеют корректно обрабатывать такие ситуации через callback-функцию ошибки, а затем перед новыми попытками выполнить операции под блокировкой дождаться восстановления хранилища и взятия блокировки. Кроме того, система мониторинга позволяет нам оперативно реагировать на проблемы с недоступностью
Примеры задач, где распределенные блокировки используются в реальных приложениях
Распределенные блокировки в наших процессах помогают решить две ключевые задачи:
- Ограничение параллельного выполнения задач на уровне всего приложения
- Контроль доступа к ограниченным ресурсам
Рассмотрим эти случаи подробнее на примерах:
Блокировка на уровне всего приложения
Один из наших сервисов отвечает за репликацию данных и развернут на нескольких хостах, однако сам процесс репликации должен выполняться только в одном экземпляре в каждый момент времени. Чтобы этого добиться, перед запуском пайплайна сервис пытается захватить блокировку. Если блокировка установлена, это означает, что репликация уже выполняется другим инстансом. В таком случае сервис находится в режиме ожидания. Если же блокировки нет, сервис получает право на выполнение пайплайна, а после завершения освобождает ресурс.
При отказе работающего инстанса блокировка автоматически освобождается после истечения TTL, и другой экземпляр сервиса может безопасно подхватить задачу. Такой механизм помогает избежать дублирования работы и гарантирует устойчивость системы.
Блокировка на уровня доступа к ресурсу
Рассмотрим пример другого сервиса, который выполняет сбор данных в виде сущностей из внешнего источника. Этот процесс организован через таблицу метаданных, где каждая запись соответствует конкретной сущности из конкретного источника и содержит текущий статус выполнения и временное окно, в рамках которого данные должны быть собраны. В данном случае ресурсом выступает сегмент таблицы метаданных, соответствующий определенной сущности и источнику данных.
При назначении задач выполняются операции чтения и записи этой таблицы:
- Выбирается одна из ранее неудачно завершенных задач или создается новая.
- Обновляется статус задачи
При масштабируемой архитектуре возникает риск, что два инстанса возьмут одну и ту же задачу одновременно. Это может привести к гонке состояний, дублирующимся запросам к внешнему источнику и нерациональному расходу rate limit.
Распределенная блокировка гарантирует атомарный доступ к ресурсу и решает проблему дублирования задач. Для этого все обращения к таблице метаданных в процессе назначения выполняются под блокировкой. В качестве параметров ключа блокировки используется название источника данных и название сущности.
Схема работы распределенных блокировок
Механизм распределенных блокировок, который мы используем, достаточно прост и основан на команде Redis SET NX EX
. Флаг NX
позволяет установить ключ только в том случае, если его еще нет в хранилище, а EX
задает время жизни ключа, после которого он автоматически удаляется. Далее рассмотрим в деталях алгоритм блокировок.
Взятие блокировки
Когда процесс хочет захватить ресурс, он выполняет команду SET <lock_name> <unique_id> NX EX <ttl>
. Если операция успешна, процесс получает эксклюзивный доступ к ресурсу. В противном случае блокировка уже занята, и процесс не получает права на выполнение. В зависимости от сценария применения можно настроить таймаут взятия блокировок или количество попыток.
Обновление блокировки
Чтобы избежать преждевременного истечения блокировки при долгих операциях, процесс должен периодически обновлять ее, увеличивая время жизни. Это достигается безопасным механизмом продления, проверяющим, владеет ли процесс данной блокировкой. Это предотвращает ситуацию, когда один процесс случайно продлевает блокировку, захваченную другим.
Механизм продления работает следующим образом:
- Получаем текущее значение ключа и сравниваем его с
unique_id
- Если значение совпадает, выполняем команду
SET
с флагамиXX
(обновить только существующий ключ) иEX
(обновить время жизни) - Если ключ принадлежит другому процессу или уже истек, обновление не выполняется
Чтобы обеспечить атомарность операции, реализуем обновление блокировки в виде Lua-скрипта:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("set", KEYS[1], ARGV[1], "EX", ARGV[2], "XX")
else
return 0
end
В этом скрипте:
KEYS[1]
- ключ блокировкиARGV[1]
-UUID
блокировкиARGV[2]
- новое время жизни блокировки в секундах
Если блокировка еще активна и принадлежит текущему процессу, она успешно продлевается. Если блокировка уже снята или принадлежит другому процессу, обновление не происходит.
Снятие блокировки
Снять блокировку можно удалив ключ из Redis. Однако при снятии важно убедиться, что только владелец блокировки может удалить ее. Это предотвращает ситуацию, когда один процесс случайно удаляет блокировку, принадлежащую другому процессу.
Механизм снятия блокировки работает следующим образом:
- Читаем текущее значение ключа и сравниваем его с
UUID
блокировки. - Если значение совпадает, выполняем команду
DEL
, удаляя ключ. - Если блокировка уже снята или принадлежит другому процессу, удаление не выполняется.
Код Lua-скрипта для снятия блокировки:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
Подводные камни Redis-блокировок
Проблема истечения TTL
Redis-блокировки устанавливаются с временем жизни (EX
), после которого ключ автоматически удаляется. Это предотвращает ситуации, когда процесс аварийно завершился, оставив блокировку навсегда. Однако это также создает риск - если один процесс вовремя не продлил активную блокировку, другой процесс сможет взять ее, что приведет к параллельному выполнению одной и той же задачи. На нашей практике мы сталкивались с таким явлением в случае длительного выполнения блокирующего кода в асинхронном приложении, когда невозможность переключения контекста дольше чем на время жизни блокировки приводило к ее потере.
Рассмотрим применяемые нами подходы к решению проблемы:
- Разделять тяжелые блокирующие операции на части, каждая из которых выполняется существенно быстрее чем TTL блокировки. Переключать асинхронный контекст между частями.
- В случае невозможности разделения операций выносить их в отдельный поток или процесс.
- Выбирать оптимальную корелляцию интервала продления блокировки и ее времени жизни
- Выбирать сбалансированное значение времени жизни блокировки, учитывая с одной стороны возможные задержки выполнения, с другой - длительность простоя при аварийном завершении одного из приложений.
Проблема потери блокировки из-за перезапуска Redis
Продление блокировки выполняется в фоне, но оно не гарантировано. Несмотря на то что описанные выше подходы снижают вероятность потери блокировки, необходимо учитывать случаи возможных потерь блокировки. Один из таких случаев - перезапуск сервера Redis с потерей всех активных блокировок.
Рассмотрим пример, при котором один процесс захватывает блокировку и выполняет обработку данных, которая занимает продолжительное время. Второй процесс периодически пытается взять блокировку и готов перенять процесс обработки данных в случае отказа первого процесса. В определенный момент сервер Redis перезагрузился и все ключи активных блокировок, хранящиеся в памяти, были сброшены. Первый процесс продолжит свою работу, будучи уверенным что владеет блокировкой. Однако второй процесс в этом случае успешно захватит блокировку.
Для предотвращения подобных случаев мы применяем следующие подходы:
- Перед продлением блокировки проверяем, владеет ли процесс блокировкой по значению ее ключа.
- Если блокировка была потеряна или захвачена другим процессом, мы вызываем callback об ошибке. В бизнес-логике реализуем обработку сценариев потери блокировки (например останавливать выполнение задачи).