Redis与分布式锁——实现分布式锁、看门狗线程、RedLock

概述

本篇文章你会了解到以下内容:

  • 分布式锁
  • 看门狗线程
  • RedLock(红锁)

分布式锁

什么是分布式锁?

分布式锁是一种用于在分布式系统或多台机器上控制对共享资源的并发访问的机制。其主要目的是确保在多个节点(服务器或进程)同时尝试访问或修改同一资源时,不会产生竞争条件或数据不一致问题。分布式锁可以防止多个节点在同一时间对共享资源进行冲突操作。

使用Redis实现简单的分布式锁

想要实现分布式锁,必须要求 Redis 有"互斥"的能力,我们可以使用 setnx 命令,这个命令表示 set if not exists,即如果 key 不存在,才会设置它的值,否则什么也不做。

两个客户端进程可以执行这个命令,达到互斥就可以实现一个分布式锁。

客户端 1 申请加锁,加锁成功。客户端 2 申请加锁,因为它后到达,所以加锁失败。

此时,加锁成功的客户端,就可以去操作共享资源。例如,修改 MySQL 的某一行数据,或者调用一个 API 请求。

操作完成后,还要及时释放锁,给后来者让出操作共享资源的机会。如何释放锁呢?其实也很简单,直接使用 DEL 命令删除这个 key 即可,这个逻辑非常简单。

bash 复制代码
del lock

但是,它存在一个很大的问题,当客户端 1 拿到锁后,如果发生下面的场景,就会造成"死锁":

  1. 程序处理业务逻辑异常,没及时释放锁。
  2. 业务进程挂了,没机会释放锁。

这时,这个客户端就会一直占用这个锁,而其它客户端就永远拿不到这把锁了。怎么解决这个问题呢?

如何避免死锁

我们很容易想到的方案是,在申请锁时给这把锁设置一个租期

在 Redis 中实现时,就是给这个 key 设置一个过期时间。这里我们假设,操作共享资源的时间不会超过 10s,那么在加锁时,给这个 key 设置 10s 过期即可:

bash 复制代码
# 加锁
setnx lock 1

# 10s 后自动过期
expire lock 10

这样一来,无论客户端是否异常,这个锁都可以在 10s 后被自动释放,其它客户端依旧可以拿到锁。

但现在还是有问题:

现在的操作,加锁、设置过期是 2 条命令,有没有可能只执行了第一条,第二条却来不及执行的情况发生呢?例如:

  • setnx 执行成功,执行 expire 时由于网络问题,执行失败。
  • setnx 执行成功,Redis 异常宕机,expire 没有机会执行。
  • setnx 执行成功,客户端异常崩溃,expire 也没有机会执行。

总之,这两条命令不能保证是原子操作(一起成功),就有潜在的风险导致过期时间设置失败,依旧发生死锁问题。

在 Redis 2.6.12 之后,Redis 扩展了 set 命令的参数,用这一条命令就可以了:

bash 复制代码
set lock 1 ex 10 nx

锁被别人释放了怎么办

上面的命令执行时,每个客户端在释放锁时,都是无脑操作,并没有检查这把锁的持有者是否为自己,所以就会发生释放别人锁的风险,这样的解锁流程,很不严谨!如何解决这个问题呢?

解决办法是客户端在加锁时,设置一个只有自己知道的唯一标识进去。

例如,可以是自己的线程 ID,也可以是一个 UUID,这里我们以 UUID 举例:

bash 复制代码
set lock $uuid ex 20 nx

之后在释放锁时,要先判断这把锁是否还归自己持有,伪代码可以这么写:

go 复制代码
if redis.get("lock") == $uuid {
    redis.del("lock")
}

这里释放锁使用的是 get + del 两条命令。这时,又会遇到我们前面讲的原子性问题了。这里可以使用 Lua 脚本代码来解决。

安全释放锁的 Lua 脚本代码如下:

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

实现分布式锁客户端代码

下面介绍一下如何使用 Java 结合 SpringBoot 开发分布式锁客户端代码,这里声明了一个 RedisDistLock 的 Bean:

java 复制代码
package org.codeart.redis.lock;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.params.SetParams;

import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

/**
 * 基于 JDK 17
 * 分布式锁的实现
 */
@Component
public class RedisDistLock implements Lock {

    private final static int LOCK_TIME = 5 * 1000;

    private final static String RS_DISTLOCK_NS = "tdln:";
    
    private final static String RELEASE_LOCK_LUA = """
        if redis.call('get', KEYS[1]) == ARGV[1]
        then
            return redis.call('del', KEYS[1])
        else
            return 0
        end
    """;
    
    /**
     * 保存每个线程的独有的 ID 值
     */
    private final ThreadLocal<String> lockerId = new ThreadLocal<>();

    /**
     * 解决锁的重入
     */
    private Thread ownerThread;

    private String lockName = "lock";

    @Autowired
    private JedisPool jedisPool;

    public String getLockName() {
        return lockName;
    }

    public void setLockName(String lockName) {
        this.lockName = lockName;
    }

    public Thread getOwnerThread() {
        return ownerThread;
    }

    public void setOwnerThread(Thread ownerThread) {
        this.ownerThread = ownerThread;
    }

    @Override
    public void lock() {
        while (!tryLock()) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        throw new UnsupportedOperationException("不支持可中断获取锁!");
    }

    @Override
    public boolean tryLock() {
        Thread t = Thread.currentThread();
        if (ownerThread == t) { // 说明本线程持有锁
            return true;
        } else if (ownerThread != null) { // 本进程里有其他线程持有分布式锁
            return false;
        }
        try (Jedis jedis = jedisPool.getResource()) {
            String id = UUID.randomUUID().toString();
            SetParams params = new SetParams();
            params.px(LOCK_TIME);
            params.nx();
            synchronized (this) { // 线程本地抢锁
                if ((ownerThread == null) && "OK".equals(jedis.set(RS_DISTLOCK_NS + lockName, id, params))) {
                    lockerId.set(id);
                    setOwnerThread(t);
                    return true;
                } else {
                    return false;
                }
            }
        } catch (Exception e) {
            throw new RuntimeException("分布式锁尝试加锁失败!");
        }
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) {
        throw new UnsupportedOperationException("不支持等待尝试获取锁!");
    }

    @Override
    public void unlock() {
        if (ownerThread != Thread.currentThread()) {
            throw new RuntimeException("试图释放无所有权的锁!");
        }
        try (Jedis jedis = jedisPool.getResource()) {
            Long result = (Long) jedis.eval(RELEASE_LOCK_LUA, List.of(RS_DISTLOCK_NS + lockName), Collections.singletonList(lockerId.get()));
            if (result != 0L) {
                System.out.println("Redis上的锁已释放!");
            } else {
                System.out.println("Redis上的锁释放失败!");
            }
        } catch (Exception e) {
            throw new RuntimeException("释放锁失败!", e);
        } finally {
            lockerId.remove();
            setOwnerThread(null);
            System.out.println("本地锁所有权已释放!");
        }
    }

    @Override
    public Condition newCondition() {
        throw new UnsupportedOperationException("不支持等待通知操作!");
    }
}

这里也简单介绍一下如何开发一个单机锁,不使用 ReentrantLock 等内置的锁类:

java 复制代码
package org.codeart.redis.lock;


import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.LockSupport;

public class SingleLock implements Lock {

    AtomicReference<Thread> owner = new AtomicReference<>();

    // 队列--存放哪些没有抢到锁的线程
    LinkedBlockingQueue<Thread> waiters = new LinkedBlockingQueue<>();
    
    /**
     * 实现加锁
     */
    @Override
    public void lock() {
        while (!owner.compareAndSet(null, Thread.currentThread())) {
            waiters.add(Thread.currentThread());
            // 让当前线程阻塞
            LockSupport.park();
            // 解锁了,就需要把线程从等待列表中删除
            waiters.remove(Thread.currentThread());
        }
    }

    /**
     * 实现解锁
     */
    @Override
    public void unlock() {
        if (owner.compareAndSet(Thread.currentThread(), null)) {
            for (Object object : waiters.toArray()) {
                Thread next = (Thread) object;
                LockSupport.unpark(next);
            }
        }
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        throw new UnsupportedOperationException("不支持可中断获取锁");
    }

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

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


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

这里使用了 LockSupport 类,LockSupport 类是 Java 并发包中的一个工具类,主要用于实现线程的阻塞和唤醒功能。它提供了一些静态方法,允许线程在没有占用锁的情况下挂起和恢复。

不好评估锁过期的时间

上面这张图,加入 key 的失效时间是 10s,但是 Client C 在拿到分布式锁之后,然后业务逻辑执行超过 10s,那么问题来了,在 Client C 释放锁之前,其实这把锁已经失效了,那么 Client A 和 Client B 都可以去拿锁,这样就已经失去了分布式锁的功能了。

比较简单的妥协方案是,尽量冗余过期时间,降低锁提前过期的概率,但是这个并不能完美解决问题,那怎么办呢?

看门狗线程

加锁时,先设置一个过期时间,然后我们开启一个守护线程 ,定时去检测这个锁的失效时间。如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行续期,重新设置过期时间。

这个守护线程我们一般也把它叫做看门狗线程。

下面来看一下看门狗分布式锁的代码实现,基于上面的代码稍加改造:

java 复制代码
package org.codeart.redis.lock;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.params.SetParams;

import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

/**
 * 基于 JDK 17
 * 带看门狗线程的分布式锁的实现
 */
@Component
public class RedisDistLock implements Lock {

    private final static int LOCK_TIME = 5 * 1000;

    private final static String RS_DISTLOCK_NS = "tdln:";
    
    private final static String RELEASE_LOCK_LUA = """
        if redis.call('get', KEYS[1]) == ARGV[1]
        then
            return redis.call('del', KEYS[1])
        else
            return 0
        end
    """;
    
    /**
     * 保存每个线程的独有的 ID 值
     */
    private final ThreadLocal<String> lockerId = new ThreadLocal<>();

    /**
     * 解决锁的重入
     */
    private Thread ownerThread;

    private String lockName = "lock";

    @Autowired
    private JedisPool jedisPool;

    public String getLockName() {
        return lockName;
    }

    public void setLockName(String lockName) {
        this.lockName = lockName;
    }

    public Thread getOwnerThread() {
        return ownerThread;
    }

    public void setOwnerThread(Thread ownerThread) {
        this.ownerThread = ownerThread;
    }

    @Override
    public void lock() {
        while (!tryLock()) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        throw new UnsupportedOperationException("不支持可中断获取锁!");
    }

    @Override
    public boolean tryLock() {
        Thread t = Thread.currentThread();
        if (ownerThread == t) { // 说明本线程持有锁
            return true;
        } else if (ownerThread != null) { // 本进程里有其他线程持有分布式锁
            return false;
        }
        try (Jedis jedis = jedisPool.getResource()) {
            String id = UUID.randomUUID().toString();
            SetParams params = new SetParams();
            params.px(LOCK_TIME);
            params.nx();
            synchronized (this) { // 线程本地抢锁
                if ((ownerThread == null) && "OK".equals(jedis.set(RS_DISTLOCK_NS + lockName, id, params))) {
                    lockerId.set(id);
                    setOwnerThread(t);
                    // 看门狗线程启动
                    if (expireThread == null) {
                        expireThread = new Thread(new ExpireTask(), "expireThread");
                        expireThread.setDaemon(true);
                        expireThread.start();
                    }
                    // 往延迟阻塞队列中加入元素(让看门口可以在过期之前一点点的时间去做锁的续期)
                    delayDog.add(new ItemVo<>(LOCK_TIME, new LockItem(lockName, id)));
                    System.out.println(Thread.currentThread().getName() + "已获得锁----");
                    return true;
                } else {
                    return false;
                }
            }
        } catch (Exception e) {
            throw new RuntimeException("分布式锁尝试加锁失败!");
        }
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) {
        throw new UnsupportedOperationException("不支持等待尝试获取锁!");
    }

    @Override
    public void unlock() {
        if (ownerThread != Thread.currentThread()) {
            throw new RuntimeException("试图释放无所有权的锁!");
        }
        try (Jedis jedis = jedisPool.getResource()) {
            Long result = (Long) jedis.eval(RELEASE_LOCK_LUA, List.of(RS_DISTLOCK_NS + lockName), Collections.singletonList(lockerId.get()));
            if (result != 0L) {
                System.out.println("Redis上的锁已释放!");
            } else {
                System.out.println("Redis上的锁释放失败!");
            }
        } catch (Exception e) {
            throw new RuntimeException("释放锁失败!", e);
        } finally {
            lockerId.remove();
            setOwnerThread(null);
            System.out.println("本地锁所有权已释放!");
        }
    }

    @Override
    public Condition newCondition() {
        throw new UnsupportedOperationException("不支持等待通知操作!");
    }
    

    // 看门狗线程
    private Thread expireThread;

    // 通过delayDog 避免无谓的轮询,减少看门狗线程的轮序次数
    private static final DelayQueue<ItemVo<LockItem>> delayDog = new DelayQueue<>();

    // 续锁逻辑:判断是持有锁的线程才能续锁
    private final static String DELAY_LOCK_LUA = """
        if redis.call('get', KEYS[1]) == ARGV[1]
        then
            return redis.call('pexpire', KEYS[1], ARGV[2])
        else
            return 0
        end
    """;

    private class ExpireTask implements Runnable {

        @Override
        public void run() {
            System.out.println("看门狗线程已启动......");
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    LockItem lockItem = delayDog.take().getData();
                    try (Jedis jedis = jedisPool.getResource()) {
                        Long result = (Long) jedis.eval(DELAY_LOCK_LUA, List.of(RS_DISTLOCK_NS + lockItem.getKey()), Arrays.asList(lockItem.getValue(), LOCK_TIME_STR));
                        if (result == 0L) {
                            System.out.println("Redis上的锁已释放,无需续期!");
                        } else {
                            delayDog.add(new ItemVo<>(LOCK_TIME, new LockItem(lockItem.getKey(), lockItem.getValue())));
                            System.out.println("Redis上的锁已续期:" + LOCK_TIME);
                        }
                    } catch (Exception e) {
                        throw new RuntimeException("锁续期失败!", e);
                    }
                } catch (InterruptedException e) {
                    System.out.println("看门狗线程被中断");
                    break;
                }
            }
            System.out.println("看门狗线程准备关闭......");
        }
    }

    @PreDestroy
    public void closeExpireThread() {
        if (expireThread != null) {
            expireThread.interrupt();
        }
    }
}

ItemVO

java 复制代码
package org.codeart.redis.lock;

import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;

/**
 * 类说明:存放到延迟队列的元素,比标准的 delay 的实现要提前一点时间
 */
public class ItemVo<T> implements Delayed {

    private final long activeTime;

    private final T data;

    /**
     * 传入的数值代表过期的时长,单位毫秒,需要乘1000转换为毫秒和到期时间
     * 同时提前100毫秒续期,具体的时间可以自己决定
     * @param expirationTime 过期时间
     * @param data           数据
     */
    public ItemVo(long expirationTime, T data) {
        super();
        this.activeTime = expirationTime + System.currentTimeMillis() - 100;
        this.data = data;
    }

    public long getActiveTime() {
        return activeTime;
    }

    public T getData() {
        return data;
    }

    /**
     * 返回元素到激活时刻的剩余时长
     */
    public long getDelay(TimeUnit unit) {
        return unit.convert(this.activeTime - System.currentTimeMillis(), unit);
    }

    /**
     * 按剩余时长排序
     */
    public int compareTo(Delayed o) {
        long d = (getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS));
        if (d == 0) {
            return 0;
        } else {
            if (d < 0) {
                return -1;
            } else {
                return 1;
            }
        }
    }

}

LockItem

java 复制代码
/**
 * 类说明:Redis 的 key-value 结构
 */
public class LockItem {

    private final String key;

    private final String value;

    public LockItem(String key, String value) {
        this.key = key;
        this.value = value;
    }

    public String getKey() {
        return key;
    }

    public String getValue() {
        return value;
    }
}

简单做一下测试:

java 复制代码
@SpringBootTest
public class TestRedisDistLockWithDog {

    @Autowired
    private RedisDistLockWithDog redisDistLockWithDog;

    private int count = 0;

    @Test
    public void testLockWithDog() throws InterruptedException {
        int clientCount = 3;
        CountDownLatch countDownLatch = new CountDownLatch(clientCount);
        ExecutorService executorService = Executors.newFixedThreadPool(clientCount);
        for (int i = 0; i < clientCount; i++) {
            executorService.execute(() -> {
                try {
                    redisDistLockWithDog.lock(); // 锁的有效时间1秒
                    System.out.println(Thread.currentThread().getName() + "准备进行累加。");
                    Thread.sleep(2000);
                    count++;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    redisDistLockWithDog.unlock();
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        System.out.println(count);
    }
}

输出如下:

text 复制代码
看门狗线程已启动......
pool-1-thread-1已获得锁----
pool-1-thread-3无法获得锁----
pool-1-thread-2无法获得锁----
pool-1-thread-1准备进行累加。
Redis上的锁已续期:1000
Redis上的锁已续期:1000
Redis上的锁已释放!
pool-1-thread-2已获得锁----
pool-1-thread-2准备进行累加。
Redis上的锁已释放,无需续期!
Redis上的锁已续期:1000
Redis上的锁已续期:1000
Redis上的锁已释放!
pool-1-thread-3已获得锁----
pool-1-thread-3准备进行累加。
Redis上的锁已释放,无需续期!
Redis上的锁已续期:1000
Redis上的锁已续期:1000
Redis上的锁已释放!
result: 3
看门狗线程被中断
看门狗线程准备关闭......

使用Redisson库的分布式锁

其实可以使用第三方开源的 Redisson 库实现上面的功能。

首先导入依赖库:

xml 复制代码
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.20.0</version> <!-- 请根据需要选择合适的版本 -->
</dependency>

然后编写 application.yml 文件的配置:

yaml 复制代码
redisson:
  config:
    singleServerConfig:
      address: "redis://127.0.0.1:6379"
      password: "your_password" # 如果 Redis 有密码,填写此项
      connectionPoolSize: 10
      idleConnectionTimeout: 10000

在代码里面使用 RedissonClient 这个 Bean。

java 复制代码
@SpringBootTest
public class TestRedissionLock {

    private int count = 0;
    
    @Autowired
    private RedissonClient redisson;

    @Test
    public void testLockWithDog() throws InterruptedException {
        int clientCount = 3;
        RLock lock = redisson.getLock("RD-lock");
        CountDownLatch countDownLatch = new CountDownLatch(clientCount);
        ExecutorService executorService = Executors.newFixedThreadPool(clientCount);
        for (int i = 0; i < clientCount; i++) {
            executorService.execute(() -> {
                try {
                    lock.lock(10, TimeUnit.SECONDS);
                    System.out.println(Thread.currentThread().getName()+"准备进行累加。");
                    Thread.sleep(2000);
                    count++;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    lock.unlock();
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        System.out.println("result: " + count);
    }
}

集群下的锁

基于 Redis 的实现分布式锁,前面遇到的问题,以及对应的解决方案:

  1. 死锁:设置过期时间。
  2. 过期时间评估不好,锁提前过期:守护线程,自动续期。
  3. 锁被别人释放:锁写入唯一标识,释放锁先检查标识,再释放。

之前分析的场景都是,锁在单个 Redis 实例中可能产生的问题,并没有涉及到 Redis 的部署架构细节。

而我们在使用 Redis 时,一般会采用主从集群 + 哨兵的模式部署,这样做的好处在于,当主库异常宕机时,哨兵可以实现故障自动切换,把从库提升为主库,继续提供服务,以此保证可用性。

但是因为主从复制是异步的,那么就不可避免会发生的锁数据丢失问题:加了锁却没来得及同步过来。从库被哨兵提升为新主库,这个锁在新的主库上丢失了!

RedLock的方案

Redis 作者提出的 Redlock 方案,是如何解决主从切换后,锁失效问题的。

Redlock 的方案基于一个前提:

不再需要部署从库和哨兵实例,只部署主库。但主库要部署多个,官方推荐至少 5 个实例。

注意:不是部署 Redis Cluster,就是部署 5 个简单的 Redis 实例。它们之间没有任何关系,都是一个个孤立的实例。

基于 Redis 的 Redisson 红锁 RedissonRedLock 对象实现了 Redlock 介绍的加锁算法。该对象也可以用来将多个 RLock 对象关联为一个红锁,每个 RLock 对象实例可以来自于不同的 Redisson 实例。

java 复制代码
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");

RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
...
lock.unlock();

大家都知道,如果负责储存某些分布式锁的某些 Redis 节点宕机以后,而且这些锁正好处于锁住的状态时,这些锁会出现锁死的状态。为了避免这种情况的发生,Redisson 内部提供了一个监控锁的看门狗,它的作用是在 Redisson 实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是 30 秒钟,也可以通过修改 Config.lockWatchdogTimeout 来另行指定。

另外 Redisson 还通过加锁的方法提供了 leaseTime 的参数来指定加锁的时间。超过这个时间后锁便自动解开了。

java 复制代码
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 给 lock1,lock2,lock3 加锁,如果没有手动解开的话,10 秒钟后将会自动解开
lock.lock(10, TimeUnit.SECONDS);

// 为加锁等待 100 秒时间,并在加锁成功 10 秒钟后自动解开
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
// ...
lock.unlock();

RedLock整体流程

  1. 客户端先获取当前时间戳 <math xmlns="http://www.w3.org/1998/Math/MathML"> T 1 T_1 </math>T1。
  2. 客户端依次向这 5 个 Redis 实例发起加锁请求。
  3. 如果客户端从 >= 3 个(大多数)以上 Redis 实例加锁成功,则再次获取当前时间戳 <math xmlns="http://www.w3.org/1998/Math/MathML"> T 2 T_2 </math>T2,如果 <math xmlns="http://www.w3.org/1998/Math/MathML"> T 2 − T 1 < 锁的过期时间 T_2 - T_1 < 锁的过期时间 </math>T2−T1<锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。
  4. 加锁成功,去操作共享资源。
  5. 加锁失败或者释放锁,向全部节点发起释放锁请求。

所以总的来说:客户端在多个 Redis 实例上申请加锁;必须保证大多数节点加锁成功;大多数节点加锁的总耗时,要小于锁设置的过期时间;释放锁,要向全部节点发起释放锁请求。

我们来看 Redlock 为什么要这么做?

为什么要在多个实例上加锁?

本质上是为了容错,部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用。

为什么大多数加锁成功,才算成功?

多个 Redis 实例一起来用,其实就组成了一个分布式系统 。在分布式系统中,总会出现异常节点 ,所以,在谈论分布式系统问题时,需要考虑异常节点达到多少个,也依旧不会影响整个系统的正确性

这是一个分布式系统容错 问题,这个问题的结论是:如果只存在故障节点,只要大多数节点正常,那么整个系统依旧是可以提供正确服务的。

为什么步骤 3 加锁成功后,还要计算加锁的累计耗时?

因为操作的是多个节点,所以耗时肯定会比操作单个实例耗时更久,而且因为是网络请求,网络情况是复杂的,有可能存在延迟、丢包、超时等情况发生,网络请求越多,异常发生的概率就越大。

所以,即使大多数节点加锁成功,但如果加锁的累计耗时已经超过了锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁就没有意义了。

为什么释放锁,要操作所有节点?

在某一个 Redis 节点加锁时,可能因为网络原因导致加锁失败。

例如,客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,网络问题导致读取失败,那这把锁其实已经在 Redis 上加锁成功了。

所以释放锁时,不管之前有没有加锁成功,需要释放所有节点 的锁,以保证清理节点上残留的锁。

好了,明白了 Redlock 的流程和相关问题,看似 Redlock 确实解决了 Redis 节点异常宕机锁失效的问题,保证了锁的安全性。

但事实真的如此吗?

一个分布式系统,更像一个复杂的野兽,存在着你想不到的各种异常情况。

这些异常场景主要包括三大块,这也是分布式系统会遇到的三座大山:NPC。

  • N:Network Delay,网络延迟
  • P:Process Pause,进程暂停(GC)
  • C:Clock Drift,时钟漂移

比如一个进程暂停(GC)的例子

  1. 客户端 1 请求锁定节点 A、B、C、D、E。
  2. 客户端 1 的拿到锁后,进入 GC(时间比较久)。
  3. 所有 Redis 节点上的锁都过期了。
  4. 客户端 2 获取到了 A、B、C、D、E 上的锁。
  5. 客户端 1 GC 结束,认为成功获取锁。
  6. 客户端 2 也认为获取到了锁,发生冲突。

GC 和网络延迟问题:这两点可以在红锁实现流程的第3步来解决这个问题。

但是最核心的还是时钟漂移,因为时钟漂移,就有可能导致第3步的判断本身就是一个BUG,所以当多个 Redis 节点时钟发生问题时,也会导致 Redlock 锁失效。

Redlock 只有建立在时钟正确的前提下,才能正常工作。如果你可以保证这个前提,那么可以拿来使用。

但是时钟偏移在现实中是存在的:

第一,从硬件角度来说,时钟发生偏移是时有发生,无法避免。例如,CPU 温度、机器负载、芯片材料都是有可能导致时钟发生偏移的。

第二,人为错误也是很难完全避免的。

所以,Redlock 尽量不用它,而且它的性能不如单机版 Redis,部署成本也高,优先考虑使用主从 + 哨兵的模式实现分布式锁(只会有很小的概率发生主从切换时的锁丢失问题)。

相关推荐
i7i8i9com4 分钟前
java 1.8+springboot文件上传+vue3+ts+antdv
java·spring boot·后端
秋意钟4 分钟前
Spring框架处理时间类型格式
java·后端·spring
我叫啥都行15 分钟前
计算机基础复习12.22
java·jvm·redis·后端·mysql
Stark、31 分钟前
【Linux】文件IO--fcntl/lseek/阻塞与非阻塞/文件偏移
linux·运维·服务器·c语言·后端
coding侠客1 小时前
Spring Boot 多数据源解决方案:dynamic-datasource-spring-boot-starter 的奥秘
java·spring boot·后端
阿乾之铭1 小时前
Redis四种模式在Spring Boot框架下的配置
redis
技术路上的苦行僧2 小时前
分布式专题(8)之MongoDB存储原理&多文档事务详解
数据库·分布式·mongodb
龙哥·三年风水2 小时前
workman服务端开发模式-应用开发-后端api推送修改二
分布式·gateway·php
37手游后端团队2 小时前
谈谈golang的错误处理
后端