探究 Redis 分布式锁各方案的利弊

发布于:2024-04-27 ⋅ 阅读:(24) ⋅ 点赞:(0)

为什么需要分布式锁?

大前提:在分布式架构中, 分布式锁可以保证在多节点场景下,保证一个任务同时只有一个节点在执行。

主要目的:

  1. 节省资源,提高性能
  2. 处理共享资源的竞争问题

基于Redis的实现方案:

  1. redis setnx
  2. redison
  3. redlock

Redis SetNX 方案

方案方案可以分为2中,在redis2.6.1之前,使用两条命令setnx + expire,为了保证原子性,使用lua脚本进行执行。 在2.6.1之后,setnx命令中添加了expire参数,使用起来简单一些,下面代码是2.6.1之后的版本:

import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.UUID;

public class RedisLock {

    private StringRedisTemplate redisTemplate; // redis操作类
    private String lockKey; // 锁的键
    private String lockValue; // 锁的值,用于标识锁的持有者
    private long expireTime; // 锁的过期时间,单位毫秒

    // 构造方法,初始化相关参数
    public RedisLock(StringRedisTemplate redisTemplate, String lockKey, long expireTime) {
        this.redisTemplate = redisTemplate;
        this.lockKey = lockKey;
        this.expireTime = expireTime;
        this.lockValue = UUID.randomUUID().toString(); // 生成随机的锁值
    }

    // 获取锁,返回是否成功
    public boolean lock() {
        // 调用setIfAbsent方法,传入键值和过期时间,执行setnx和expire命令
        Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, expireTime, TimeUnit.MILLISECONDS);
        // 判断返回结果,如果是true,表示加锁成功,如果是false,表示加锁失败
        return result != null && result;
    }

    // 释放锁
    public void unlock() {
        // 创建一个RedisScript对象,用于执行lua脚本
        RedisScript<Long> unlockScript = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);
        // 调用execute方法,传入RedisScript对象和键值参数,执行lua脚本
        redisTemplate.execute(unlockScript, Collections.singletonList(lockKey), lockValue);
    }

    // 解锁的lua脚本,判断锁的值是否匹配,保证原子性
    private static final String UNLOCK_SCRIPT =
            "if redis.call('get', KEYS[1]) == ARGV[1] then\n" +
            "    return redis.call('del', KEYS[1])\n" +
            "else\n" +
            "    return 0\n" +
            "end";
}

setnx方案的问题

总结有一下2类问题:

  1. 过期时间
  2. 非重入锁

过期时间

lock的时候,为了防止服务异常无法解锁的情况,通常会设置锁的过期时间,这个时间的设置依赖历史任务平均耗时得出,过期时间要大于任务执行需要的时间。这个时间的设置有一定学问,设置得太大,当节点宕机时,意味着会阻塞更久;短了又会容易提早释放锁,导致多节点同时拿到锁情况的发生。 解决这个问题的一个方案是"续约机制",也可以叫"看门狗'。大致的原理是设置一个守护线程执行定时任务,当发现锁快过期时,对锁的过期时间进行延长。

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;

import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class RedisLock {

    private StringRedisTemplate redisTemplate; // redis操作类
    private String lockKey; // 锁的键
    private String lockValue; // 锁的值,用于标识锁的持有者
    private long expireTime; // 锁的过期时间,单位毫秒
    private long heartbeatInterval; // 心跳间隔时间,单位毫秒
    private ScheduledExecutorService heartbeatExecutor; // 心跳线程池

    // 加锁的lua脚本,使用setnx和pexpire命令,保证原子性
    private static final String LOCK_SCRIPT =
            "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then\n" +
            "    return redis.call('pexpire', KEYS[1], ARGV[2])\n" +
            "else\n" +
            "    return 0\n" +
            "end";

    // 解锁的lua脚本,判断锁的值是否匹配,保证原子性
    private static final String UNLOCK_SCRIPT =
            "if redis.call('get', KEYS[1]) == ARGV[1] then\n" +
            "    return redis.call('del', KEYS[1])\n" +
            "else\n" +
            "    return 0\n" +
            "end";

    // 心跳的lua脚本,判断锁的值是否匹配,如果匹配则更新过期时间
    private static final String HEARTBEAT_SCRIPT =
            "if redis.call('get', KEYS[1]) == ARGV[1] then\n" +
            "    return redis.call('pexpire', KEYS[1], ARGV[2])\n" +
            "else\n" +
            "    return 0\n" +
            "end";

    // 构造方法,初始化相关参数
    public RedisLock(StringRedisTemplate redisTemplate, String lockKey, long expireTime, long heartbeatInterval) {
        this.redisTemplate = redisTemplate;
        this.lockKey = lockKey;
        this.expireTime = expireTime;
        this.heartbeatInterval = heartbeatInterval;
        this.lockValue = UUID.randomUUID().toString(); // 生成随机的锁值
        this.heartbeatExecutor = new ScheduledThreadPoolExecutor(1); // 创建一个单线程的心跳线程池
    }

    // 获取锁,返回是否成功
    public boolean lock() {
        // 创建一个RedisScript对象,用于执行lua脚本
        RedisScript<Long> lockScript = new DefaultRedisScript<>(LOCK_SCRIPT, Long.class);
        // 调用execute方法,传入RedisScript对象和键值参数,执行lua脚本
        Long result = redisTemplate.execute(lockScript, Collections.singletonList(lockKey), lockValue, String.valueOf(expireTime));
        // 判断返回结果,如果是1,表示加锁成功,如果是0,表示加锁失败
        if (result != null && result == 1L) {
            // 启动一个定时任务,每隔一定时间发送心跳信号,更新锁的过期时间
            heartbeatExecutor.scheduleAtFixedRate(this::heartbeat, heartbeatInterval, heartbeatInterval, TimeUnit.MILLISECONDS);
            return true;
        } else {
            return false;
        }
    }

    // 释放锁
    public void unlock() {
        // 创建一个RedisScript对象,用于执行lua脚本
        RedisScript<Long> unlockScript = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);
        // 调用execute方法,传入RedisScript对象和键值参数,执行lua脚本
        redisTemplate.execute(unlockScript, Collections.singletonList(lockKey), lockValue);
        // 停止心跳任务
        heartbeatExecutor.shutdownNow();
    }

    // 心跳方法,发送心跳信号,更新锁的过期时间
    private void heartbeat() {
        // 创建一个RedisScript对象,用于执行lua脚本
        RedisScript<Long> heartbeatScript = new DefaultRedisScript<>(HEARTBEAT_SCRIPT, Long.class);
        // 调用execute方法,传入RedisScript对象和键值参数,执行lua脚本
        Long result = redisTemplate.execute(heartbeatScript, Collections.singletonList(lockKey), lockValue, String.valueOf(expireTime));
        // 判断返回结果,如果是0,表示锁已经失效,停止心跳任务
        if (result == null || result == 0L) {
            heartbeatExecutor.shutdownNow();
        }
    }
}

非重入锁

顾名思义,客户端A拿到锁后,无法重复获取锁,这可能会导致死锁的发生。 为了解决问题,可以在锁中记录获取的次数,可以使用redis的hash类型,其中包含两个字段,客户端唯一表示uid和获取次数count。 这里的代码可以自行修改,下面重点看看Redisson是如何解决这几个问题的。

Redisson 方案

Redission是一个基于Redis的分布式锁和事务框架,它提供了一些高级的功能,比如超时、重入、公平性等。 其中一个重要功能就是看门门狗机制,它可以在Redisson实例被关闭前,不断地延长锁的有效期,防止锁被其他线程或进程占用。

Redisson 看门狗(锁续约)实现方案

redission内部维护了一个时间轮(执行轮询任务,使用Timeout类实现),每隔一定时间(默认为30秒),就会检查当前线程对锁的持有情况,并根据一定的规则(默认为internalLockLeaseTime/3),更新锁的过期时间。这样就可以保证锁在Redisson实例关闭前不会被释放。

下面这个文章给出了比较详细的分析过程,包括代码部分,有兴趣的可以阅读

Redisson 可重入性实现

和上面我们提到的自己实现方案一样,value使用hash结构,有字段保存了上锁次数。

如果同一个机器同一个线程再次来请求,hexists判断的结果会是1,然后执行hincrby 对字段+1,然后继续设置过期时间。

同理,一个线程重入后,解锁时value - 1

"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + 
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " + 
"return nil; " + 
"end; " + 
"return redis.call('pttl', KEYS[1]);"

Redisson 主从不一致的问题

为了提高redis的高可用和性能,通常会启用redis的主从功能,主库提供写,保证数据一致性,从库备份主库的数据,并提供读,缓解主库的压力,以及当主库出问题时,可以升级为主库,保证集群的持续运行。 而主从同步的问题主要发生在数据复制的时刻,可以这样描述:

image.png

如何解决主从问题? redis的作者给了答案,这就是著名的Redlock(红锁)。

RedLock 方案

主从问题的核心是只有一个master,如何解决?让数据存在很多个节点上,这样就可以降低问题的风险,这是软件系统设计里的一个通用思想。 首先,redlock是一个解决方案,并不是一个框架,这个方案核心思想是这样,它要求你的架构里有多个redis集群,

image.png

获取锁的流程:

  1. 获取当前Unix时间,以毫秒为单位。
  2. 依次尝试从N个Master实例使用相同的key和随机值获取锁(假设这个key是LOCK_KEY)。当向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例。
  3. 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
  4. 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
  5. 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功)。

如何使用redlock

redis官方这里有一份清单,列举了所有已经支持redlock的框架,其中我看到了熟悉的 redisson , 简单说明一下redisson中如何使用redlock。


```text
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://192.168.0.1:6379")
        .setPassword("afeiblog").setDatabase(0);
RedissonClient redissonClient1 = Redisson.create(config1);

Config config2 = new Config();
config2.useSingleServer().setAddress("redis://192.168.0.2:6379")
        .setPassword("afeiblog").setDatabase(0);
RedissonClient redissonClient2 = Redisson.create(config2);

Config config3 = new Config();
config3.useSingleServer().setAddress("redis://192.168.0.3:6379")
        .setPassword("afeiblog").setDatabase(0);
RedissonClient redissonClient3 = Redisson.create(config3);

String resourceName = "LOCK_KEY";

RLock lock1 = redissonClient1.getLock(resourceName);
RLock lock2 = redissonClient2.getLock(resourceName);
RLock lock3 = redissonClient3.getLock(resourceName);
// 向3个redis实例尝试加锁
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
boolean isLock;
try {
    // isLock = redLock.tryLock();
    // 500ms拿不到锁, 就认为获取锁失败。10000ms即10s是锁失效时间。
    isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
    System.out.println("isLock = "+isLock);
    if (isLock) {
        //TODO if get lock success, do something;
    }
} catch (Exception e) {
} finally {
    // 无论如何, 最后都要解锁
    redLock.unlock();
}

Redlock一定安全吗?

Redlock要求N/2+1个节点上锁成功,这个方案确实可以极大提高锁的安全性,但是一定吗?这和redis的持久化有关。 现在假设架构中有5个Master节点a,b,c,d,e ,以及服务端s1,s2 ,持久化使用AOF,1秒刷盘1次:

  • s1请求所有节点,从a,b,c节点中获取到了锁,超过半数,s1成功获取到锁。
  • c节点在数据持久化之前宕机了,不久后,c节点恢复
  • s2请求所有节点,从c,d,e中获取到了锁,超过半数,s2也获取到了锁。 这种情况下s1,s2还是同时获取到了锁。解决这个问题的办法也有,例如提出的控制redis重启时间 > 锁的过期时间,这样可以保证锁过期后才进行下一轮的锁竞争。或者从数据持久化方式入手,AOF使用appendfsync这种刷盘的方式,但是会影响redis的性能。

总结

我们到底该使用哪一种分布式方案呢? 这是一个开放性问题,我的观点是取决于你觉得你用分布式锁的目的,通常来说有两种原因:

  • 使用分布式锁减少不必要的资源消耗,而非数据一致性问题。 例如我们有一个定时任务,凌晨2点将本地文件上传到云端,这个任务其实只需要执行一次就可以了,如果所有实例都执行一次,没什么大问题,就是增加了不必要的资源消耗。
  • 使用分布式锁解决共享数据一致性问题,对于这个情况,考虑到业务场景的一致性要求高不高,一般场景下没有那么高的要求,我们使用单机方案足矣,如果是金融等场景,我更推荐使用zookeeper 、etcd等基于CP的分布式组件,redlock对架构的要求太麻烦了。

参考