В повседневной разработке, такие бизнес-сценарии, как моментальные покупки товаров и борьба за красные пакеты, требуют использования распределённых блокировок. Redis отлично подходит для реализации распределённых блокировок. В данной статье будут рассмотрены семь различных подходов к правильному использованию распределённых блокировок Redis. Если вы заметите ошибки, пожалуйста, сообщите об этом, чтобы мы могли вместе учиться и прогрессировать.
❝
Распределённая блокировка — это реализация блокировки, которая контролирует доступ к общему ресурсу в распределённой системе. Если различные системы или разные машины одного и того же сервера используют один и тот же критический ресурс, часто требуется взаимоисключение для предотвращения конфликтов и обеспечения согласованности.
❞Давайте рассмотрим основные характеристики надёжной распределённой блокировки:
Когда речь заходит о распределённых блокировках Redis, многие сразу вспоминают команды SETNX
и EXPIRE
. Это означает, что сначала используется SETNX
, чтобы захватить блокировку, а затем EXPIRE
для установки времени жизни блокировки, чтобы предотвратить забывание её освобождения.>
Команда SETNX является сокращением от SET IF NOT EXISTS. Её формат команды — SETNX key value. Если ключ не существует, команда успешно выполнится и вернёт 1. Если ключ уже существует, команда вернёт 0.
Предположим, что интернет-магазин проводит акцию моментальных покупок для определённого товара. Ключ можно установить как
key_resource_id
, а значение — любое. Пример псевдокода представлен ниже:
if (jedis.setnx(key_resource_id, lock_value) == 1) { // закрепление
expire(key_resource_id, 100); // установка времени жизни
try {
делай что-то // бизнес-запрос
} catch () {
}
finally {
jedis.del(key_resource_id); // освобождение закрепления
}
}
Однако в этом решении команды setnx
и expire
разделены, «что не является атомарной операцией». Если после выполнения setnx
происходит аварийное завершение процесса или требуется перезапуск для обслуживания, то закрепление становится «вечным», «и другие потоки никогда не смогут получить это закрепление».
Чтобы решить проблему первого решения, когда «при возникновении ошибки закрепление не освобождается», некоторые участники предложили включить время жизни в значение setnx
. Если закрепление не удалось, можно извлечь значение value для проверки. Код для закрепления выглядит следующим образом:
long expires = System.currentTimeMillis() + expireTime; // текущее время системы + установленное время жизни
String expiresStr = String.valueOf(expires);
```// Если закрепление еще не существует, вернуть успешное закрепление
if (jedis.setnx(key_resource_id, expiresStr) == 1) {
return true;
}
// Если закрепление уже существует, получить его время жизни
String currentValueStr = jedis.get(key_resource_id);// Если полученное время жизни меньше текущего времени системы, значит закрепление просрочено
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
// Закрепление просрочено, получить старое время жизни и установить новое время жизни (если вы не знакомы с командой getSet Redis, вы можете найти информацию на официальном сайте)
String oldValueStr = jedis.getSet(key_resource_id, expiresStr);
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// Учитывая многопоточность, только один поток может успешно установить закрепление, если его значение совпадает с текущим значением
return true;
}
}
// В других случаях, вернуть неудачное закрепление
return false;
}Это решение имеет преимущество в том, что оно удаляет отдельное установление времени жизни через `expire`, вместо этого помещая **«время жизни в значение setnx»**. Это решает проблему, когда при возникновении ошибки закрепление не освобождается. Однако это решение также имеет свои недостатки:
> ❝
>
> - Время истечения срока действия генерируется клиентом самостоятельно (`System.currentTimeMillis()` — это текущее время системы), и в распределённой среде время на каждом клиенте должно быть синхронизировано.
> - При истечении срока действия блокировки, если несколько клиентов одновременно отправят запросы и выполнят `jedis.getSet()`, то только один клиент сможет успешно заблокировать, но время истечения срока действия этой блокировки может быть перезаписано другим клиентом.
> - Блокировка не хранит уникальный идентификатор владельца, поэтому она может быть случайно освобождена/разблокирована другим клиентом.
>
> ❞
### Схема распределённой блокировки Redis три: использование скриптов Lua (включает команды `SETNX` и `EXPIRE`)На самом деле, мы можем использовать скрипты Lua для обеспечения атомарности (включающие команды setnx и expire), пример скрипта приведён ниже:
```lua
if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then
redis.call('expire', KEYS[1], ARGV[2])
else
return 0
end
Код для установки блокировки выглядит следующим образом:
String lua_scripts = "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then" +
" redis.call('expire', KEYS[1], ARGV[2]) return 1 else return 0 end";
Object result = jedis.eval(lua_scripts, Collections.singletonList(key_resource_id), Collections.singletonList(values));
// проверяем успешность операции
return result.equals(1L);
Эта схема, сравнивая её со второй схемой, какую из них вы считаете лучше?
Кроме использования скриптов Lua для обеспечения атомарности двух команд SETNX
и EXPIRE
, мы также можем использовать расширенные параметры команды SET
в Redis! (SET key value[EX seconds][PX milliseconds][NX|XX]
) Это тоже является атомарной операцией!
SET key value[EX seconds][PX milliseconds][NX|XX]
- NX: Устанавливает значение только если ключ отсутствует, то есть гарантирует, что только первый клиент может получить блокировку, а остальные должны ждать освобождения блокировки.
- EX seconds: Устанавливает время жизни ключа в секундах.
- PX milliseconds: Устанавливает время жизни ключа в миллисекундах.
- XX: Устанавливает значение только если ключ существует.
Таким образом, использование расширенных параметров команды SET
позволяет выполнить атомарную операцию без необходимости писать скрипт Lua.Пример псевдокода:
if (jedis.set(key_resource_id, lock_value, "NX", "EX", 100) == 1) { // установка блокировки
try {
// выполнение бизнес-логики
} catch (Exception e) {
// обработка ошибок
} finally {
jedis.del(key_resource_id); // освобождение блокировки
}
}
Однако, эта схема всё ещё может иметь проблемы:
Проблема 1: "Срок действия блока истёк, но бизнес-логика ещё не завершена". Предположим, что поток a успешно захватил блокировку и продолжает выполнять код критической секции. Однако спустя 100 секунд он всё ещё не завершил выполнение. В это время срок действия блокировки истёк, и поток b запросил блокировку. Очевидно, что поток b сможет успешно получить блокировку и начать выполнять код критической секции. Таким образом, бизнес-логика критической секции уже не выполняется строго последовательно.
Проблема 2: "Блокировка была случайно удалена другим потоком". Предположим, что после завершения работы поток a пытается освободить блокировку. Однако он не знает, что текущая блокировка может принадлежать потоку b (когда поток a пытается освободить блокировку, её срок действия мог истечь, и поток b занял блокировку). В результате поток a случайно освобождает блокировку потока b, хотя код критической секции потока b может ещё не быть завершён.
if (jedis.set(key_resource_id, uni_request_id, "NX", "EX", 100s) == 1) { // захватываем замок
try {
выполнить какое-то действие // бизнес-логика
} catch () {
}
finally {
// проверяем, является ли текущий ключ замком, установленным текущим потоком, если да, освобождаем его
if (uni_request_id.equals(jedis.get(key_resource_id))) {
jedis.del(lockKey); // освобождаем замок
}
}
}
Здесь "проверка, является ли текущий ключ замком, установленным текущим потоком" и "освобождение замка" не являются атомарной операцией. Если вызывается jedis.del()
для освобождения замка, замок может уже принадлежать другому клиенту, что приведет к освобождению замка другого потока.
Для большей строгости обычно используются скрипты на Lua. Пример Lua-скрипта:
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end;
Как только поток успешно захватывает блок, запускается фоновый поток watch dog
, который каждые 10 секунд проверяет наличие блока. Если поток всё ещё использует блок, время его жизни продлевается. Таким образом, Redisson решает проблему "освобождение блока по истечению времени, когда бизнес-логика ещё не завершена".
Первые шесть схем обсуждались в контексте одного сервера, что ещё не идеально. На самом деле Redis обычно развернут в виде кластера:
Если поток 1 получил блокировку на мастер-узле Redis, но ключ ещё не синхронизирован на slave-узел, и в этот момент мастер-узел выходит из строя, один из slave-узлов становится новым мастером. В этом случае поток 2 может получить блокировку для того же ключа, хотя блокировка уже была получена потоком 1, что нарушает целостность блокировки.
Чтобы решить эту проблему, автор Redis Антриз предложил продвинутый алгоритм распределённой блокировки Redlock. Основная идея Redlock заключается в следующем:> ❝
Создайте несколько мастер-узлов Redis, чтобы гарантировать, что они не будут одновременно выходить из строя. Эти мастер-узлы должны быть полностью независимыми друг от друга и не должны синхронизировать данные между собой. При этом необходимо обеспечить, что метод получения и освобождения блокировок будет таким же, как при использовании одного экземпляра Redis.
❞
Предположим, что у нас есть пять мастер-узлов Redis, работающих на пяти разных серверах.
Шаги реализации Redlock:> ❝
- Получите текущее время в миллисекундах.
- Последовательно запросите блокировку на пяти мастер-узлах. Клиент должен установить сетевые соединения и время ожидания ответа, которое должно быть меньше времени жизни блокировки (например, если время автоматического истечения блокировки составляет 10 секунд, то время ожидания обычно составляет 5-50 миллисекунд, предположим, что оно равно 50 мс). Если время ожидания истекло, пропустите этот мастер-узел и как можно скорее попытайтесь получить блокировку на следующем мастер-узле.
- Клиент использует текущее время минус время начала запроса блокировки (то есть время, записанное в шаге 1), чтобы получить время, затраченное на получение блокировки. Блокировка считается успешно полученной, если более половины мастер-узлов (N/2+1, здесь это 5/2+1=3 узла) предоставили блокировку, и время, затраченное на получение блокировки, меньше времени жизни блокировки (как показано на рисунке, 10 с > 30 мс + 40 мс + 50 мс + 4 мс + 50 мс).
- Если блокировка была получена, реальное время действия ключа изменится, нужно вычесть время, затраченное на получение блокировки.> - Если блокировка не была получена (не удалось получить блокировку на хотя бы N/2+1 мастер-узлах, или время получения блockировки превысило время жизни блокировки), клиент должен освободить блокировку на всех мастер-узлах (даже если некоторые мастер-узлы не смогли получить блокировку, их тоже следует освободить, чтобы избежать утечек).
❞Упрощённые шаги следуют ниже:- Последовательно запрашивать блокировку у 5 мастер-узлов.
Redisson реализует версию redlock блокировки.
Вы можете оставить комментарий после Вход в систему
Неприемлемый контент может быть отображен здесь и не будет показан на странице. Вы можете проверить и изменить его с помощью соответствующей функции редактирования.
Если вы подтверждаете, что содержание не содержит непристойной лексики/перенаправления на рекламу/насилия/вульгарной порнографии/нарушений/пиратства/ложного/незначительного или незаконного контента, связанного с национальными законами и предписаниями, вы можете нажать «Отправить» для подачи апелляции, и мы обработаем ее как можно скорее.
Опубликовать ( 0 )