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

为什么需要分布式锁?

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

主要目的:

  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之后的版本:

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

java 复制代码
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实例关闭前不会被释放。

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

Redisson 可重入性实现

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

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

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

lua 复制代码
"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的主从功能,主库提供写,保证数据一致性,从库备份主库的数据,并提供读,缓解主库的压力,以及当主库出问题时,可以升级为主库,保证集群的持续运行。 而主从同步的问题主要发生在数据复制的时刻,可以这样描述:

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

RedLock 方案

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

获取锁的流程:

  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。

java 复制代码
```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还是同时获取到了锁。解决这个问题的办法也有,例如Martin Kleppmann提出的控制redis重启时间 > 锁的过期时间,这样可以保证锁过期后才进行下一轮的锁竞争。或者从数据持久化方式入手,AOF使用appendfsync这种刷盘的方式,但是会影响redis的性能。

总结

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

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

参考

相关推荐
用户214118326360224 分钟前
OpenSpec 实战:用规范驱动开发破解 AI 编程协作难题
后端
Olrookie1 小时前
若依前后端分离版学习笔记(二十)——实现滑块验证码(vue3)
java·前端·笔记·后端·学习·vue·ruoyi
LucianaiB1 小时前
招聘可以AI面试,那么我制作了一个AI面试教练不过分吧
后端
无奈何杨2 小时前
CoolGuard更新,ip2region升级、名单增加过期时间
后端
猫林老师2 小时前
HarmonyOS线程模型与性能优化实战
数据库·分布式·harmonyos
摇滚侠3 小时前
Spring Boot 3零基础教程,WEB 开发 自定义静态资源目录 笔记31
spring boot·笔记·后端·spring
Anthony_49263 小时前
逻辑清晰地梳理Golang Context
后端·go
Github项目推荐3 小时前
你的错误处理一团糟-是时候修复它了-🛠️
前端·后端
进击的圆儿3 小时前
高并发内存池项目开发记录01
后端
左灯右行的爱情3 小时前
4-Spring SPI机制解读
java·后端·spring