Серия Redis: распределенная блокировка

Redis задняя часть алгоритм GitHub

1. Введение

В этом сообщении блога описывается, как шаг за шагом создать распределенную блокировку на основе Redis. Он начнет с самой оригинальной версии, затем скорректирует ее в соответствии с проблемой и, наконец, завершит более разумную распределенную блокировку.

В этой статье реализация распределенных блокировок будет разделена на две части: одна — это автономная среда, а другая — реализация блокировки Redis в кластерной среде. Прежде чем представить реализацию распределенных блокировок, давайте разберемся с некоторыми сведениями о распределенных блокировках.

2 Распределенный замок

2.1 Что такое распределенная блокировка?

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

2.2 Каковы требования к распределенным замкам

  1. Взаимное исключение: в любой момент только один клиент удерживает блокировку.
  2. Нет взаимоблокировки: даже если клиент, удерживающий блокировку, выйдет из строя или произойдет другое непредвиденное событие, блокировку все равно можно будет получить.
  3. Отказоустойчивость: клиенты могут получать и снимать блокировки, пока работает большинство узлов Redis.

2.4 Каковы реализации распределенных блокировок?

  1. база данных
  2. Memcached (добавить команду)
  3. Redis (команда setnx)
  4. Zookeeper (эфемерный узел)
  5. и т.д

3 Распределенная блокировка автономного Redis

3.1 Подготовка

3.1.1 определение класса констант

public class LockConstants {
    public static final String OK = "OK";

    /** NX|XX, NX -- Only set the key if it does not already exist. XX -- Only set the key if it already exist. **/
    public static final String NOT_EXIST = "NX";
    public static final String EXIST = "XX";

    /** expx EX|PX, expire time units: EX = seconds; PX = milliseconds **/
    public static final String SECONDS = "EX";
    public static final String MILLISECONDS = "PX";

    private LockConstants() {}
}

3.1.2 Определение абстрактного класса для замков

Абстрактный класс RedisLock реализует интерфейс Lock в пакете java.util.concurrent, а затем предоставляет реализации по умолчанию для некоторых методов.Подклассы должны реализовать только метод блокировки и метод разблокировки. код показывает, как показано ниже

public abstract class RedisLock implements Lock {

    protected Jedis jedis;
    protected String lockKey;

    public RedisLock(Jedis jedis,String lockKey) {
        this(jedis, lockKey);
    }


    public void sleepBySencond(int sencond){
        try {
            Thread.sleep(sencond*1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }


    @Override
    public void lockInterruptibly(){}

    @Override
    public Condition newCondition() {
        return null;
    }

    @Override
    public boolean tryLock() {
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit){
        return false;
    }

}

3.2 Самая базовая версия 1

Сначала идет самая базовая версия, код выглядит следующим образом

public class LockCase1 extends RedisLock {

    public LockCase1(Jedis jedis, String name) {
        super(jedis, name);
    }

    @Override
    public void lock() {
        while(true){
            String result = jedis.set(lockKey, "value", NOT_EXIST);
            if(OK.equals(result)){
                System.out.println(Thread.currentThread().getId()+"加锁成功!");
                break;
            }
        }
    }

    @Override
    public void unlock() {
        jedis.del(lockKey);
    }
}

Класс LockCase1 предоставляет методы блокировки и разблокировки.
Метод блокировки заключается в выполнении следующей команды на клиенте reids.

SET lockKey value NX

Метод UNLOCK заключается в вызове команды DEL для удаления кнопки.
Итак, метод представлен. А теперь подумайте, что в этом плохого?
Предположим, что есть два клиента A и B, и A получает распределенную блокировку. A выполняется какое-то время, и вдруг сервер, на котором находится A, отключается (или что-то еще), то есть клиент A зависает. На данный момент есть проблема. Эта блокировка всегда существует и не будет снята. Другие клиенты никогда не получат блокировку. Следующая диаграмма

Эту проблему можно решить, установив срок годности

3.3 Версия 2 - Установить срок действия блокировки

public void lock() {
    while(true){
        String result = jedis.set(lockKey, "value", NOT_EXIST,SECONDS,30);
        if(OK.equals(result)){
            System.out.println(Thread.currentThread().getId()+"加锁成功!");
            break;
        }
    }
}

Подобные команды Redis следующие:

SET lockKey value NX EX 30

Примечание. Чтобы гарантировать, что установка времени истечения срока действия и установка блокировки являются атомарными

В это время возникает другая проблема, и шаги по устранению проблемы следующие:

  1. Клиент A успешно получает блокировку, и время истечения срока действия составляет 30 секунд.
  2. Клиент A блокирует операцию на 50 секунд.
  3. Через 30 секунд блокировка автоматически снимается.
  4. Клиент B получает блокировку, соответствующую тому же ресурсу.
  5. Клиент А восстанавливается после блокировки и снимает блокировку, удерживаемую клиентом Б.

Схема выглядит следующим образом

будет две проблемы

  1. Как сделать так, чтобы время истечения было больше, чем время выполнения бизнеса?
  2. Как сделать так, чтобы блокировка не была удалена по ошибке?

Давайте сначала решим проблему, как гарантировать, что блокировка не будет удалена по ошибке.
Эту проблему можно решить, установив в качестве значения случайную строку, сгенерированную текущим клиентом, и убедившись, что все запросы на получение блокировки от всех клиентов уникальны в течение достаточно длительного периода времени.

Полный код для версии 2:адрес гитхаба

3.4 Версия 3 - Установить значение блокировки

Абстрактный класс RedisLock добавляет поле lockValue Значением по умолчанию поля lockValue является случайное значение UUID, предполагающее идентификатор текущего потока.

public abstract class RedisLock implements Lock {

    //...
    protected String lockValue;

    public RedisLock(Jedis jedis,String lockKey) {
        this(jedis, lockKey, UUID.randomUUID().toString()+Thread.currentThread().getId());
    }

    public RedisLock(Jedis jedis, String lockKey, String lockValue) {
        this.jedis = jedis;
        this.lockKey = lockKey;
        this.lockValue = lockValue;
    }

    //...
}

код блокировки

public void lock() {
    while(true){
        String result = jedis.set(lockKey, lockValue, NOT_EXIST,SECONDS,30);
        if(OK.equals(result)){
            System.out.println(Thread.currentThread().getId()+"加锁成功!");
            break;
        }
    }
}

код разблокировки

public void unlock() {
    String lockValue = jedis.get(lockKey);
    if (lockValue.equals(lockValue)){
        jedis.del(lockKey);
    }
}

В это время посмотрите на код блокировки, вроде проблем нет.
Давайте посмотрим на код разблокировки.Операция разблокировки здесь состоит из трех шагов: получение значения, оценка и удаление блокировки. Думали ли вы на этот раз о многопоточной среде?i++действовать?

3.4.1 Проблема i++

i++Операцию также можно разделить на три шага: прочитать значение i, выполнить i+1 и установить значение i.
Если два потока одновременно выполняют операции i++ над i, возникнут следующие ситуации.

  1. я установил значение 0
  2. Поток A считывает значение i как 0
  3. Поток B также читает, что значение i равно 0
  4. Поток A выполнил операцию +1 и записал значение результата 1 в память.
  5. Поток B выполнил операцию +1 и записал значение результата 1 в память.
  6. В этот момент я выполняю две операции i++, но результат равен 1.

Есть ли способ избежать такой ситуации в многопоточной среде?
Есть много решений, таких как использование AtomicInteger, CAS, синхронизация и так далее.
Цель этих решений состоит в том, чтобы гарантировать, чтоi++Атомарность операций. Потом возвращаемся и смотрим на разблокировку, а еще нам нужно обеспечить атомарность разблокировки. Мы можем использовать lua-скрипт Redis для достижения атомарности операций разблокировки.

Полный код для версии 3:адрес гитхаба

3.5 Версия 4 — замки атомарной разблокировки

Содержимое lua-скрипта следующее

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

Когда этот сценарий Lua выполняется, значение lockValue должно быть передано как значение ARGV[1], а значение lockKey должно быть передано как значение KEYS[1]. Теперь взгляните на разблокированный код Java.

public void unlock() {
    // 使用lua脚本进行原子删除操作
    String checkAndDelScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                                "return redis.call('del', KEYS[1]) " +
                                "else " +
                                "return 0 " +
                                "end";
    jedis.eval(checkAndDelScript, 1, lockKey, lockValue);
}

Что ж, атомарность операции разблокировки тоже обеспечена, так что, распределенная блокировка автономной среды Redis здесь завершена?
не забудьтеВерсия 2 - Установить срок действия блокировкиСуществует также проблема, как обеспечить, чтобы время истечения было больше, чем время выполнения бизнеса.

Полный код для версии 4:адрес гитхаба

3.6 Версия 5. Убедитесь, что время истечения срока больше, чем время выполнения бизнеса

Абстрактный класс RedisLock добавляет атрибут логического типа isOpenExpirationRenewal, чтобы определить, следует ли включать время истечения срока обновления синхронизации.
Добавьте метод scheduleExpirationRenewal, чтобы открыть поток, обновляющий время истечения срока действия.

public abstract class RedisLock implements Lock {
	//...

    protected volatile boolean isOpenExpirationRenewal = true;

    /**
     * 开启定时刷新
     */
    protected void scheduleExpirationRenewal(){
        Thread renewalThread = new Thread(new ExpirationRenewal());
        renewalThread.start();
    }

    /**
     * 刷新key的过期时间
     */
    private class ExpirationRenewal implements Runnable{
        @Override
        public void run() {
            while (isOpenExpirationRenewal){
                System.out.println("执行延迟失效时间中...");

                String checkAndExpireScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                        "return redis.call('expire',KEYS[1],ARGV[2]) " +
                        "else " +
                        "return 0 end";
                jedis.eval(checkAndExpireScript, 1, lockKey, lockValue, "30");

                //休眠10秒
                sleepBySencond(10);
            }
        }
    }
}

Код блокировки устанавливает для isOpenExpirationRenewal значение true после успешного получения блокировки и вызывает метод scheduleExpirationRenewal для запуска потока, который обновляет время истечения срока действия.

public void lock() {
    while (true) {
        String result = jedis.set(lockKey, lockValue, NOT_EXIST, SECONDS, 30);
        if (OK.equals(result)) {
            System.out.println("线程id:"+Thread.currentThread().getId() + "加锁成功!时间:"+LocalTime.now());

            //开启定时刷新过期时间
            isOpenExpirationRenewal = true;
            scheduleExpirationRenewal();
            break;
        }
        System.out.println("线程id:"+Thread.currentThread().getId() + "获取锁失败,休眠10秒!时间:"+LocalTime.now());
        //休眠10秒
        sleepBySencond(10);
    }
}

Добавьте строку кода в код разблокировки, задайте для свойства isOpenExpirationRenewal значение false и остановите опрос потока, который обновляет время истечения срока действия.

public void unlock() {
    //...
    isOpenExpirationRenewal = false;
}

Полный код для версии 5:адрес гитхаба

3.7 Тестирование

Код теста выглядит следующим образом

public void testLockCase5() {
    //定义线程池
    ThreadPoolExecutor pool = new ThreadPoolExecutor(0, 10,
                                                    1, TimeUnit.SECONDS,
                                                    new SynchronousQueue<>());

    //添加10个线程获取锁
    for (int i = 0; i < 10; i++) {
        pool.submit(() -> {
            try {
                Jedis jedis = new Jedis("localhost");
                LockCase5 lock = new LockCase5(jedis, lockName);
                lock.lock();

                //模拟业务执行15秒
                lock.sleepBySencond(15);

                lock.unlock();
            } catch (Exception e){
                e.printStackTrace();
            }
        });
    }

    //当线程池中的线程数为0时,退出
    while (pool.getPoolSize() != 0) {}
}

Результаты теста

Возможно здесь представлен дистрибутив на основе автономной среды Redis. Но открыли ли учащиеся, использующие Java, важные характеристики замка?

Это повторный вход блокировок, так как же реализовать повторный вход распределенных блокировок? Здесь есть дыра

4 Распределенные блокировки кластера Redis

В распределенной среде Redis автор Redis предоставляет алгоритм RedLock для реализации распределенной блокировки.

4.1 Блокировка

Шаги блокировки алгоритма RedLock следующие:

  1. Получить текущее время Unix в миллисекундах.
  2. Попытки получить блокировки от N экземпляров последовательно с одним и тем же ключом и случайным значением. На шаге 2 при установке блокировки на Redis клиент должен установить сетевое подключение и время ожидания ответа, которое должно быть меньше времени истечения срока действия блокировки. Например, если ваша блокировка автоматически истекает через 10 секунд, время ожидания должно составлять от 5 до 50 миллисекунд. Это позволяет избежать ситуации, когда серверная часть Redis зависла, а клиентская сторона все еще ожидает результата ответа. Если сервер не отвечает в течение указанного времени, клиент должен как можно скорее попробовать другой экземпляр Redis.
  3. Клиент использует текущее время минус время начала получения блокировки (время, записанное на шаге 1), чтобы получить время, используемое для получения блокировки. Блокировка получена успешно тогда и только тогда, когда блокировка получена от большинства (здесь 3 узла) узлов Redis, а время использования меньше времени истечения срока действия блокировки.
  4. Если блокировка получена, реальное время действия ключа равно действительному времени минус время, использованное для получения блокировки (результат, рассчитанный на шаге 3).
  5. Если по какой-то причине получить блокировку не удается (нетПосле того, как не менее N/2+1 экземпляров Redis получили блокировки или время получения блокировки превысило действительное время, клиент должен разблокировать все экземпляры Redis (даже если некоторые экземпляры Redis вообще не были успешно заблокированы).

4.2 Разблокировать

Просто отправьте команду снятия блокировки всем экземплярам Redis, не важно, была ли ранее успешно получена блокировка от экземпляра Redis.


По поводу алгоритма RedLock есть еще один эпизод, представляющий собой взаимное противостояние Мартина Клеппманна и автора RedLock антирез по поводу алгоритма RedLock. Оригинальные слова официального сайта следующие

Martin Kleppmann analyzed Redlock here. I disagree with the analysis and posted my reply to his analysis here.

Подробнее об алгоритме RedLock здесь не рассказывается, если вам интересно, вы можете прочитать соответствующие статьи на официальном сайте.

5 Резюме

В этой статье описан процесс написания распределенной блокировки на основе Redis и идея решения проблемы, но распределенная блокировка, реализованная в этой статье, не подходит для производственных сред. В среде Java естьRedissonМожет использоваться в производственных средах, но распределенные блокировки лучше, чем Zookeeper (см. анализ Мартина Клеппманна и RedLock).

Анализ RedLock, проведенный Мартином Клеппманном:Мартин, Читать PP Mann.com/2016/02/08/…

Ответ от автора RedLock антирез:antirez.com/news/101

Адрес всего проекта хранится на Github, если нужно, можете глянуть:адрес гитхаба