Коротко о сути
В современном мире enterprise-разработки, микросервисов, Docker и k8s достаточно часто встречается необходимость реализации распределённых блокировок. Подобные блокировки нужны для работы с критичными секциями кода, где нам нужно обеспечить атомарность. Одно из решений — это паттерн RedLock, описанный в этой статье. Материал подробный, советую к прочтению.
Недавно у меня как раз возникла необходимость реализации распределённой блокировки. И так как я не нашел статью именно о RedLock.NET на Хабре, то теперь хочу поделиться с сообществом опытом использования данного паттерна и его реализации на .NET. Под катом вы не найдете каких-то откровений. Просто чуть более подробный мануал (с моей точки зрения), который, надеюсь, позволит разработчикам реализовать распределённую блокировку чуть быстрее, чем это сделал я.
Однако когда я писал абзац выше, то хотел просто показать рабочий пример RedLock.NET, но как-то "слово за слово" статья вылилась в сравнительный анализ RedLock.NET и других решений, которые я тоже рассматривал. Мне кажется все описанные ниже очевидные и не очень решения будет вспомнить вполне уместно. Надеюсь получится не так уж длинно и будет полезно для читателей.
Еще раз о главной цели
Давайте напомним себе, когда вам нужен этот пакет, этот паттерн и вообще распределённые блокировки.
Он может понадобиться, если у нас есть какой-то код, которому нужно выполнить эксклюзивную операцию в контексте физически разделённых экземпляров сервисов или приложений (для простоты далее буду и сервисы и приложения называть сервисами. Думаю читатели понимают, что для нас это не самый принципиальный момент). Да-да, операция, которая выполняется в критической секции и которой мы хотим гарантировать атомарность и единственность в моменте времени, называется именно так — эксклюзивная.
Что это может быть за операция? Вариантов много:
изменение важного значения в БД, которое обычно критично для бизнес-логики;
отправка реквеста во внешние API (к примеру регуляторному: коллеги из ретейла могут вспомнить "Честный знак" и ЕГАИС, а коллеги из страхования сервис РСА);
и еще масса примеров, которые я не смог придумать.
При этом как мы написали выше "физически разделённые экземпляры сервисов" могут представлять из себя:
нескольких микросервисов одного решения, объединенных паттернами оркестрация или хореография;
два параллельно работающих сервиса одной экосистемы, которые связаны только косвенно;
несколько экземпляров (pod-ов) одного микросервиса;
суперпозицию вышеописанных ситуаций.
Когда нам нужно заблокировать какую-то секцию для одной из вышеперечисленных ситуаций, то такая блокировка будет называться распределённой.
Кстати, я заметил, что термин распределённая блокировка вызывает тревогу у разработчиков, архитекторов и других коллег. Моментально все начинают думать, что это или флажок, говорящий о плохом дизайне, или оверинжиниринг без которого можно обойтись. Скорее всего это вызвано тем, что подобные механизмы реализуются один раз при разработке ядра новых сервисов и потом живут годами. А современные команды большую часть времени заняты реализацией бизнес-логики и иногда забывают "а как у нас работает сам сервис".
RedLock.NET
Давайте пару слов скажем о самом пакете, прежде чем погрузиться в анализ разных подходов.
Сам пакет RedLock.NET разумеется можно найти на github. Последняя версия пакета 2.3.2, выпущена в 2022 году и считается стабильной. Небольшой off topic. Сейчас, при разработке enterprise-приложений для крупных российских компаний, часто (а точнее почти всегда) отделы информационной безопасности (знаю все разработчики их очень любят) стали более дотошно проверять все nuget-пакеты на предмет уязвимостей и закладок, которые могут быть сделаны плохими людьми. К данному пакету вопросов нет. По крайней мере наши безопасники ничего не нашли. Эта на первый взгляд мелочь на самом деле крайне важна для вывода приложения на prod. По крайней мере это чуть ли не первое, что я проверил.
Несмотря на то, что новые версии пакета довольно давно не выходят, автор очень оперативно отвечает на все вопросы. За это ему большое спасибо.
Какие у нас есть варианты
Что ж, давайте подумаем, как мы можем гарантировать эксклюзивность нашей секции кода.
Стандартные механизмы .NET
Если бы у нас был один экземпляр нашего сервиса и не было других физических «конкурентов», то мы могли бы без проблем решить нашу задачу. К примеру, используя:
SemaphoreSlim. Золотой стандарт индустрии. Хотя вот тут коллег он не устроил и они написали свой с lock-free;
AsyncLock. Работает на базе SemaphoreSlim;
старый добрый lock. Это если не используем асинхронность. Иногда нужно.
Думаю, об этом подходе подумают 99% коллег, когда поймут необходимость блокировки, но через долю секунды осознают, что это «не то», так как вспомнят, что наш сервис будет иметь больше одного экземпляра или у него будут «физические соседи». Тут нужно что-то другое.
Если бы на дворе был 2018 год и мы подняли бы пару экземпляров нашего сервиса на Windows Server с настроенным балансировщиком (улыбнитесь те, кто делал это сам по RDP в те времена, когда слово DevOps было еще никому не известно), то мы могли бы использовать для выполнения нашего эксклюзивного кода Mutex. Признаюсь честно, для prod-задач не использовал ни разу. Решение очень мощное, но действительно эффективно будет работать на Windows, так как для синхронизации будут использоваться примитивы ОС. Есть реализации того самого Windows-Mutex и под Linux, но там это делается через файловую систему, то есть по факту это не Mutex, а его моделирование для обеспечения кроссплатформенности .NET, версий Core и выше.
Вообще при работе с Mutex из асинхронного кода может быть (а вернее точно будет) много проблем. К примеру, очень высока вероятность возникновение ApplicationException при конструкциях типа:
/*это плохой код. Не делайте так */ _currentMutex.WaitOne(1000); try { await DoImplementationAsync(); } finally { _currentMutex.ReleaseMutex(); }
Исключение будет вызвано тем, что захват Mutex через WaitOne и его освобождение через ReleaseMutex скорее всего будет в разных потоках. Так что тут, придется асинхронный код переводить на синхронный и оборачивать этот код или в таски или в отдельные потоки. Но это наверное, тема немного другого обсуждения.
Но даже если мы представим, что использовать Mutex для Linux неплохая идея, то в 2026 году каждый экземпляр нашего сервиса будет поднят в своем Docker-контейнере и следовательно, экземпляры нашего сервиса будут разделены физически.
Небольшой итог. В общем стандартные .NET механизмы нам не помогут, но мы в этом были и так уверены, но должны были проверить. Нужно что-то другое.
Хорошие практики распределённых блокировок
Давайте вспомним и продолжим наш поиск.
Блокировка через БД
И правда, почему нет. Раз мы хотим записать что-то эксклюзивно в БД, или что-то отправить во внешнее API, то у нас почти 100% есть БД. И эта БД у нас одна для всех экземпляров нашего сервиса.
Кстати, в первую очередь примеры будут про PostgreSQL, но всё это можно сделать и на других БД. Уж на MS SQL Server — точно.
Старая школа. Блокировка через отдельную таблицу
Те, кто работал с приложениями, разработанными в 00-х годах или мигрировал их, то скорее всего 100% сталкивались с этим подходом. Добавляем таблицу Options, а еще лучше Lock (улыбнитесь те, у кого была такая таблица в БД). В эту таблицу добавляем колонку resource и вуаля. Наш сервис просто должен вставить строчку в эту таблицу, чтобы считать, что в момент времени именно этот поток этого экземпляра имеет доступ к критической секции. Как только эксклюзивная операция выполнена, то строчку можно удалить.
Кроме того, чтобы захватить и вовремя отпускать блокировку нужно добавить еще колонку expire, которая будет отвечать за протухание блокировки.
Для обеспечения большей гибкости стоит чуть расширить структуру этой таблицы. Добавить имена блокировок и имена блокирующих экземпляров сервиса. Получится, что нам понадобится добавить какую-то такую таблицу:
CREATE TABLE locks ( resource VARCHAR(100) PRIMARY KEY, locked_by VARCHAR(100), locked_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ );
Но как нам добавить в эту колонку информацию о блокировке, чтобы избежать concurrency? Давайте рассмотрим два подхода, которые сразу придут нам на ум.
Старая школа. Блокировка через отдельную таблицу
Самое очевидное — БД нам уже предоставляет уровни изоляции транзакций. Давайте использовать их. Правда, для избежания concurrency нам придется использовать высокий уровень «изоляции транзакции». Скорее всего, нам тут понадобится как минимум SERIALIZABLE. Можно много говорить об уровнях изоляции и сравнивать разные БД, но в одном можно быть уверенным: высокие уровни изоляции крайне негативно скажутся на производительности и ударят, скорее всего, критически и внезапно для команды разработки.
Кстати, тут мы можем не добавлять дополнительную таблицу, если эксклюзивная операция — это запись в БД, а просто добавлять SERIALIZABLE в нужные нам транзакции. Сути дела это не меняет.
Если мы благоразумные люди (а мы, конечно, да), то от использования SERIALIZABLE мы скорее всего откажемся. Подходящие условия для использования SERIALIZABLE складываются не так часто, и их описание выходит далеко за рамки этой статьи.
Кроме того, в современной enterprise-разработке проще решить задачу с примером из учебника Страуструпа, чем убедить DBA, что высокий уровень изоляции нам действительно нужен.
Через атомарную операцию INSERT
В случае, если мы не хотим экспериментировать с уровнями изоляции транзакции, и при этом нам нужна атомарность, то проставление блокировки будет выглядеть как-то так:
INSERT INTO lock (resource, locked_by, expires_at) VALUES (@resource, @locked_by, NOW() + (@timespan || ' seconds')::interval) ON CONFLICT (resource) DO UPDATE SET locked_by = {1}, expires_at = NOW() + (@timespan || ' seconds')::interval WHERE lock.expires_at < NOW() RETURNING locked_by;
На всякий случай поясню логику скрипта:
-
Пытаемся добавить новую блокировку на конкретную эксклюизивную операцию.
Эксклюзивная операция определяется параметром «ресурс» - @resource.
Блокирующий поток определяется параметром «блокирующий» - @locked_by.
Если блокировки нет, то просто новая строчка вставляется в таблицу и выставляется значение expiresAt, как интервал от текущего времени и возвращаем из скрипта имя запрашивающего процесса. Блокировка включена. Можно исполнять эксклюзивную операцию;
Если блокировка есть (колонка resource) и эта блокировка «не протухла» - expires_at больше текущего времени, то считаем, что операция запрашивающему потоку (locked_by не доступна. Ему скрипт вернет пустую строку.
Если блокировка есть (поле resource) и эта блокировка «протухла» - expires_at меньше текущего времени, то отрабатывает ветка CONFLICT и происходит перезапись владельца блокировки (locked_by). Возвращаем из скрипта имя запрашивающего процесса (параметр locked_by). Скорей всего этот предохранительный шаг будет задействован в том случае, если поток, который добавил блокировку по какой-то причине не смог ее снять.
Что нужно чтоб это заработало:
для каждой эксклюзивной операции использовать своё уникальное имя. Колонка @resource;
для каждой попытки блокировки мы должны использовать уникальное значение параметра @locked_by. Тут можно или просто генерировать что-то уникальное при каждом обращении (к примеру Guid) или использовать имя экземпляра сервиса (к примеру "instance-1"). Но во втором случае придется гарантировать эксклюзивность операции на уровне экземпляра. С этой задачей без проблем справится SemaphoreSlim. Но я бы, наверное, как значение @locked_by выбрал бы что-то типа $"{instanceName}_Guid.New()". Такой подход позволил бы и не париться по поводу лишних семафоров и иметь в таблице locks четкое представление какой экземпляр держит ту или иную блокировку. При желании подобную информацию даже можно вынести в какую-нибудь Grafana. Хотя, конечно, это решение будет чуть менее производительно, чем дополнительный SemaphoreSlim, но более простое;
грамотно выставлять параметр интервал @timespan, который попадет в колонку expires_at. От этого будет зависеть сколько блокировка будет висеть, если случится падение экземпляра сервиса по иным причинам;
освободить блокировку после окончания выполнения критической секции.
Для того чтобы снять блокировку, нужно будет выполнить следующий код:
DELETE FROM locks WHERE resource = @resource AND locked_by = @locked_by;
Все довольно просто и должно работать (и работает - я проверил). Но это, конечно, «старомодное» решение. К тому же, если мы хотим организовать распределённую блокировку, чтобы при локе остальные потоки ждали блокировку, а не просто проскакивали, и реализовать стабильное освобождение блокировки, то придется организовать логику retry-обращения и аккуратную обработку исключений. Конечно, к этому можно легко добавить какую-нибудь библиотеку Polly, но по правде говоря, это отнимет достаточно много времени и потребует хорошего тестирования от всей команды. А у нас, скорее всего, этого времени не будет.
Что характерно, мне не удалось найти свежего адекватного nuget-пакета для подобного подхода. Всезнающие LLM мне подсказали этот пакет под SQL Server. Но, честно говоря, не уверен, что стоит пытаться использовать библиотеку в описании, которой стоит .NET Framework 4.0+ в 2026 году.
Но, разумеется, в этом решении нет ничего плохого. Лично видел что-то подобное в нескольких legacy-системах, написанных в конце нулевых и начале десятых годов. Просто сегодня у нас есть «кое-что получше». Об этом поговорим буквально через минуту.
Как вы, друзья, заметили, пример выше — это реализация на PostgreSQL, но такое же решение для MS SQL Server реализовать тоже получится, но код будет чуточку громозднее.
Краткое итого: блокировки через отдельную таблицу нам не подойдут — ищем дальше.
Advisory lock. То что нужно
Как ни странно, в современных БД есть очень удобный механизм организации блокировки (кто бы мог подумать). К примеру, в PostgreSQL он называется advisory lock. Вот тут можно прочитать хвалебную статью об этом механизме. А вот тут можно найти базовую документацию.
Этот механизм существует как для PostgreSQL, так и для MS SQL Server. И как ни странно, им почему-то достаточно редко пользуются. Лично я не встречал ни разу.
Но давайте все-таки сосредоточимся на PostgreSQL.
PostgreSQL предоставляет две замечательные функции:
pg_advisory_lock - получает исключительную рекомендательную блокировку сеансового уровня, ожидая её, если это необходимо;
pg_advisory_xact_lock - получает исключительную рекомендательную блокировку транзакционного уровня, ожидая её, если это необходимо.
Обе эти функции принимают как параметр ключ, по которому осуществляется блокировка. Есть перегрузки функций с двумя параметрами. При чем перегрузка с одним параметром принимает 64-битный параметр (bigint), а перегрузка с двумя параметрами принимает два параметра 32-битных параметра (int). Для нашей ситуации нам лучше подойдут перегрузка с 64-битным параметром. Перегрузка с двумя параметрами понадобится если нам нужно разделять ключи по каким-то группам.
Если блокировка свободна, то функция сразу получает её, если блокировка не свободна, то функция ожидает её столько, сколько необходимо.
Это то, что нам нужно. Единственное, можно случайно «повесить» производительность нашего сервиса. Так что если мы не хотим ждать неопределённое время, блокируя исполнение нашего сервиса, то можем воспользоваться try-версиями этих функций:
pg_try_advisory_lock — получает исключительную рекомендательную блокировку сеансового уровня, если она доступна. То есть эта функция либо немедленно получает блокировку и возвращает true, либо сразу возвращает false, если получить её нельзя;
pg_try_advisory_xact_lock — получает исключительную рекомендательную блокировку транзакционного уровня, если она доступна. То есть эта функция либо немедленно получает блокировку и возвращает true, либо сразу возвращает false, если получить её нельзя.
Для наших целей как раз лучше подойдет try-версия.
Как вы можете заметить выше, функция pg_try_advisory_lock получает блокировку сеансового уровня, а функция pg_try_advisory_xact_lock получает блокировку на уровне транзакций.
Для работы REST-сервиса или чего-то подобного лучше использовать pg_try_advisory_xact_lock, так как pg_try_advisory_lock может вести себя неочевидно из-за создания нового соединения при каждом запросе к API нашего сервиса. Об этом можно почитать тут.
А pg_try_advisory_lock лучше подойдёт, если нам нужна долгоживущая блокировка или если мы планируем несколько раз вызывать и отпускать одну и ту же блокировку.
Транзакционная версия же будет вести себя как нам нужно: создавая уникальную блокировку в момент времени, независимо от соединения. Пример ниже:
await using var transaction = await context.Database.BeginTransactionAsync(); var result = await context .Database .SqlQuery<LockResult>($"SELECT pg_try_advisory_xact_lock({resource}) as acquired") .SingleAsync(); if (result.acquired) { try { await DoImplementationAsync(); } finally { await transaction.CommitAsync(); } }
Код выше создает блокировку по ключу resource на время жизни транзакции. То есть если какая-то транзакция (к примеру, в pod-е нашего сервиса) вызовет функцию pg_try_advisory_xact_lock первой (то есть как раз захватит блокировку для исполнения той самой эксклюзивной операции), то SqlQuery вернет значение true. До момента, пока захватившая блокировку транзакция не завершится любым способом, другие вызовы pg_try_advisory_xact_lock по ключу resource будут возвращать false. Как только транзакция завершится, блокировка освободится и pg_try_advisory_xact_lock начнет возвращать true.
Не решение, а мечта. Всё, что нам нужно, — это просто создать уникальный ключ для всех «физически разделённых сервисов» и синхронизировать их через БД. Причём за высвобождение блокировки вообще отвечает транзакция: даже pg_try_advisory_unlock (как для pg_try_advisory_lock) вызывать не надо.
Краткое итого. В целом организация распределённой блокировки через БД с использованием advisory lock — удобный и надёжный подход. Советую всем рассматривать его как одну из основных альтернатив при необходимости организации распределённой блокировки.
У этого подхода, разумеется, есть ограничения. И главное из них — это БД. Если нам нужна блокировка между pod-ами одного сервиса, то это не проблема — подключение к одной БД у них и так есть. А если это разные сервисы, то добавлять новое подключение к сторонней БД только ради организации блокировки может быть сомнительной идеей как с точки зрения производительности, так и с точки зрения безопасности.
Redis и RedLock.Net
Redis — наверное, одна из самых популярных внешних интеграций в настоящее время. Redis есть почти у каждой команды и интегрирован почти в каждый сервис.
Это как раз тот вариант, который и нужен нам. Он стабилен, хорошо держит нагрузку и предоставляет большое число потокобезопасных атомарных операций.
Давайте посмотрим, как с ним работать.
var multiplexer = ConnectionMultiplexer.Connect("localhost:6379"); var factory = RedLockFactory.Create(new List<RedLockMultiplexer> { multiplexer });
Разумеется, лучше весь этот код инкапсулировать в отдельный сервис. Привожу пример без него, чтобы было понятнее.
Далее нам нужно всего лишь создать блокировку через команду CreateLock. И если свойство IsAcquired объекта блокировки вернёт значение true, то текущий поток захватил блокировку, и мы можем выполнять нашу эксклюзивную операцию.
Разумеется, нам нужно придумать строковый ключ resource, также нужно указать TimeSpan для сброса («протухания») блокировки в случае слишком длительной операции.
var resource = "lock-resource"; var expiry = TimeSpan.FromSeconds(30); await using (var redLock = await _factory.CreateLockAsync(resource, expiry)) { if (redLock.IsAcquired) { // блокировка захвачена await DoImplementationAsync(); } else { // блокировка не захвачена. Сделать что-то ещё } }
После того как наш код выполнится, снятие блокировки произойдет во время диспоуза объекта redLock. Дополнительно нам делать ничего не нужно.
Стоит уточнить, что параметр expiry должен быть выбран с запасом по времени выполнения эксклюзивной операции. Рекомендуется выбирать его в 5-10 раз больше нормального времени выполнения эксклюзивной операции.
Если мы хотим поведение, близкое к стандартному C# lock, то нам нужно добавить еще пару параметров в метод создания блокировки:
var resource = "lock-resource"; var expiry = TimeSpan.FromSeconds(30); var waitTime = TimeSpan.FromSeconds(3); var retryPeriod = TimeSpan.FromMilleseconds(100); await using (var redLock = await _factory.CreateLockAsync(resource, expiry, waitTime, retryPeriod)) { if (redLock.IsAcquired) { // блокировка захвачена await DoImplementationAsync(); } else { // блокировка не захвачена. Сделать что-то ещё } }
Как вы уже догадались, параметр waitTime задает период времени, который поток будет ждать, если блокировка уже захвачена кем-то еще, и проверять освобождение блокировки каждый период времени, равный retryPeriod.
В случае, если эксклюзивная операция (DoImplementationAsync) по каким-то причинам будет выполняться достаточно долго, то все ожидающие потоки будут «долбиться» в Redis каждые retryPeriod миллисекунд. В нашем случае это будет до 30 попыток. Это довольно много, так что значения waitTime и retryPeriod нужно выбирать с умом, особенно если у вас будет высокая нагрузка или нестабильное сетевое подключение к Redis.
Хорошей практикой считается выполнение 3–5 попыток проверки статуса блокировки. То есть нам нужно, к примеру, так:
var waitTime = TimeSpan.FromSeconds(2); var retryPeriod = TimeSpan.FromMilleseconds(500);
или так:
var waitTime = TimeSpan.FromSeconds(1); var retryPeriod = TimeSpan.FromMilleseconds(250);
Я попробовал разные комбинации во время сессий нагрузочного тестирования и пришёл к эвристическому выводу, что 3–5 попыток будет золотой серединой. Почему — поговорим ниже. Но, разумеется, для каждого сервиса могут быть свои нюансы.
Что происходит в самом Redis
Давайте посмотрим, что происходит в самом Redis при выполнении кода выше.
Создание и проверка ключа блокировки будет выполняться таким скриптом (назовем его lock-скрипт):
local currentVal = redis.call('get', KEYS[1]) if (currentVal == false) then return redis.call('set', KEYS[1], ARGV[1], 'PX', ARGV[2]) and 1 or 0 elseif (currentVal == ARGV[1]) then return redis.call('pexpire', KEYS[1], ARGV[2]) else return -1 end
Lock-скрипт на Redis-сервере будет исполняться командой evalsha. Так как evalsha более ресурсозатратная команда, чем, допустим, decr или set, то плодить слишком частые вызовы через установку значений waitTime и retryPeriod, приводящих к более чем 5 попыткам запросов, не стоит, если этого явно не требуется вашей логикой.
При выполнении кода создания блокировки в Redis добавляется значение с ключом "redlock:{вашКлюч}". В нашем случае это будет redlock:lock-resource.
Вы можете увидеть этот ключ, если в redis-cli выполните команду:
KEYS *
Префикс redlock будет добавлен в ключ самой библиотекой по умолчанию. Его можно кастомизировать через вот такой код, инициализирующий саму фабрику блокировок:
var multiplexer = ConnectionMultiplexer.Connect("localhost:6379"); var redLockMultiplexer = new RedLockMultiplexer(multiplexer); redLockMultiplexer.RedisKeyFormat = "custom-redlock:{0}"; // для нашего примера будет ключ custom-redlock:lock-resource // redLockMultiplexer.RedisKeyFormat = "{0}"; // а если сделаем так, то префикс удалится, а ключ будет просто lock-resource var factory = RedLockFactory.Create(new List<RedLockMultiplexer> { redLockMultiplexer });
Мне такой механизм кастомизации не кажется удачным, я ожидал бы передачу подобного параметра при создании блокировки в методе CreateLockAsync или в конструкторе RedLockMultiplexer, но имеем что имеем. Это минорная деталь, которая на суть дела не влияет и функционал никак не ограничит.
Использовать маску {0} я не рекомендую. Почему, объясню парой абзацев ниже.
Если во время захваченной блокировки в redis-cli вы выполните команду:
GET redlock:lock-resource
Как вы видите, при снятии блокировки идёт проверка значения — того самого Guid, переданного в скрипт из библиотеки. Если значение по нашему ключу по каким-то причинам будет изменено, то блокировка снята не будет. Мы можем смоделировать эту ситуацию, например, так:
var resource = "lock-resource"; var expiry = TimeSpan.FromSeconds(30); var waitTime = TimeSpan.FromSeconds(3); var retryPeriod = TimeSpan.FromMilleseconds(100); await using (var redLock = await _factory.CreateLockAsync(resource, expiry, waitTime, retryPeriod)) { if (redLock.IsAcquired) { // блокировка захвачена await DoImplementationAsync(); var key = "redlock:lock-resource"; await _redis.StringSetAsync(key, "someOtherValue"); } else { // блокировка не захвачена. Сделать что-то ещё } }
Если в Redis по нашему ключу появилось какое-то другое значение, которое не выставлено нашим процессом, то могут быть проблемы. Потому что наш lua-скрипт выставит время жизни ключа:
return redis.call('set', KEYS[1], ARGV[1], 'PX', ARGV[2]) and 1 or 0
А вот код типа:
await _redis.StringSetAsync(key, "someOtherValue");
время действия ключа не выставит. И как следствие, получится ситуация, что ключ нашей блокировки будет занят.
Как это может произойти:
другой pod нашего сервиса или сервис, с которым мы шарим блокировку, захватил её. Крайне маловероятно, так как lock-скрипт выполняется атомарно. Произойти подобное может, если моргнуло сетевое соединение или, к примеру, произошло непредвиденное переключение между master-slave серверами нашего Redis-сервера, но и то с невысокой вероятностью. Если это так, то ситуация неприятная, но не критическая, так как у ключа выставлено время жизни и он обнулится по истечении этого периода;
какой-то другой сервис или сервисы, которые используют тот же Redis, выставили блокировку, потому что у нас совпали имена ключей блокировки. Плохая ситуация. Блокировка будет отрабатывать, но получим некорректную работу;
какой-то сервис (возможно, и наш) выставил через обычный set значение по нашему ключу. Самая плохая ситуация — блокировка может быть сломана на неизвестное время.
Именно для того, чтобы избежать второй и третьей ситуаций, лучше не использовать маску "{0}" для ключа блокировки. Предлагаю использовать маску по умолчанию "redlock:{0}" или придумать что-то своё — это нас спасёт, скорее всего, от третьего кейса. А чтобы максимально обезопасить себя от второго кейса, лучше использовать маску типа "redlock:our-service-name:{0}" или что-то подобное.
Итого. Организация блокировки через механизм Redis и RedLock.NET является как раз тем, что мы с нашей командой искали. Он простой, надёжный и лёгкий во внедрении.
Большое итого
Из всех вышеперечисленных подходов очевидно, что два действительно стабильных механизма для внедрения блокировки — это RedLock.NET и блокировка advisory lock. По удобству они схожи, но Redis-подход выигрывает в первую очередь тем, что он обычно присутствует в большем числе решений, чем БД (да-да, пошли такие времена).
По производительности на уровне нагрузки 50 RPS они сопоставимы, но при росте нагрузки (более 200 RPS) лучше выбирать Redis-блокировки.
Для большей наглядности я набросал учебный solution, в котором вы можете попробовать примеры, описанные выше. Пример чисто учебный: хорошие практики написания кода там замечены не во всех местах ;)
Детали учебного примера
Немного деталей, чтобы было проще ориентироваться в проекте:
в роли эксклюзивной операции выступает инкремент значения колонки value в таблицу counter;
для большего контроля за обновлением таблицы counter добавлена таблица log; - для того чтобы развернуть БД, можно воспользоваться Docker-образом в папке Docker/docker-postgres;
проекты Lock.Data и Lock.Logic представляют из себя базовый функционал;
проект ConcurrenceAccess – пример без механизмов синхронизации;
проект LockAccess – пример с блокировкой через SemaphoreSlim;
проект MutexAccess – пример с блокировкой через Mutex;
проект DbLock – пример с блокировкой через отдельную таблицу в БД. Таблица lock уже есть в образе из папки Docker/docker-postgres;
проект IsolationLevelAccess – пример с блокировкой через уровни изоляции;
проект PgAdvisoryLock – пример с блокировкой через advisory lock;
проект RedLock – пример с блокировкой через RedLock.NET. Для того чтобы поднять локальный Redis-сервер, можете воспользоваться Docker-образом в папке Docker/docker-redis.
И вот дочитав мой лонгрид, многие могут мне напомнить про отличный пакет DisributedLock. Да я про него знаю, но RedLock.NET это просто альтернатива ему.
Большое спасибо всем, кто дочитал. Надеюсь, описанные примеру помогут вам в ваших проектах.
monco83
С pg_advisory_lock для меня основное ограничение в том, что она только bigint принимает (8 байт). Произвольную строку в качестве ключа тут использовать не получится, guid использовать не получится... RedLock поэтому оказывается удобней.
rznELVIS Автор
все верно pg_advisory_lock только bigint примет или два int как ключ. Строковый ключ использовать не получится.
Хотя как по мне не самое критичное ограничение)
А вот Redis (и RedLock в частности ) начинает выигрывать за счет того, что уже все чаще и чаще во многих сервисах просто нет БД. Если нет и БД а есть Redis то выбор очевиден. Если нет и Redis и БД, то тоже реальней и правильней подключить корпоративный Redis для блокировки.
Хотя у коллег был забавный случай - у них Guid хэшировался и передавался в pg_advisory_lock. Зачем это было делать не ясно. Но факт имел место )
monco83
Ну, хэш очевидный воркэраунд, если забыть о коллизиях.
Коллизии, кстати, более вероятны, если использовать в качестве ключа блокировки int id из какого-нибудь последовательно растущего sequence. Можно договариваться о диапазонах, конечно (используя первый параметр int, например), но всё это... неудобно.