基于Redis的分布式锁

在多线程的环境下,为了保证一个代码块在同一时间只能由一个线程访问,Java中我们一般可以使用synchronized语法和ReetrantLock去保证,这实际上是本地锁的方式。但是现在公司都是流行分布式架构,在分布式环境下,如何保证不同节点的线程同步执行呢?

实际上,对于分布式场景,我们可以使用分布式锁,它是控制分布式系统之间互斥访问共享资源的一种方式。

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
特点:

  1. 高可用
  2. 读线程可见
  3. 高性能
  4. 互斥
  5. 安全性

分布式锁的实现方式:

MySQL Redis Zookeeper
互斥 利用mysql本身的互斥锁机制 利用setnx命令 利用节点的唯一性和互斥性
高可用
高性能 一般 一般
安全性 断开连接自动释放锁 利用锁的超时时间自动释放 临时节点,断开连接自动释放

Redis的分布式锁实现

1. 利用setnx+expire命令 (错误的做法)

Redis的SETNX命令,setnx key value,将key设置为value,当键不存在时,才能成功,若键存在,什么也不做,成功返回1,失败返回0 。 SETNX实际上就是SET IF NOT Exists的缩写

因为分布式锁还需要超时机制,所以我们利用expire命令来设置,所以利用setnx+expire命令的核心代码如下:

public boolean tryLock(String key, String requset, int timeout) {
        Long result = jedis.setnx(key, requset);
        // result = 1时,设置成功,否则设置失败    
        if (result == 1L) {
            return jedis.expire(key, timeout) == 1L;
        } else {
            return false;
        }
    }

实际上上面的步骤是有问题的,setnx和expire是分开的两步操作,不具有原子性,如果执行完第一条指令应用异常或者重启了,锁将无法过期。
改善方式,可以通过 set命令去一次性的将参数添加上去,通过 help set 命令我们可以看到set的所有用法
大致如下:
SET key value[EX seconds][PX milliseconds][NX|XX]
EX seconds: 设定过期时间,单位为秒

PX milliseconds: 设定过期时间,单位为毫秒

NX: 仅当key不存在时设置值

XX: 仅当key存在时设置值

上诉代码通过图形总结
image

集合业务场景:
这是一个简单的分布式锁的实现

public class SimpleRedisLock implements ILock{

    private String name; // 锁的名称

    public static final String KEY_PREFIX = "lock";

    private StringRedisTemplate stringRedisTemplate;

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 尝试获取锁
     *
     * @param timeoutSec 锁持有的超时时间,过期后自动释放
     * @return true代表获取锁成功,false代表获取锁失败
     */
    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程的标识
        long threadID = Thread.currentThread().getId();
        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, String.valueOf(threadID), timeoutSec, TimeUnit.SECONDS);
        // 防止自动拆箱导致的空指针异常
        return Boolean.TRUE.equals(success);
    }

    /**
     * 释放锁
     */
    @Override
    public void unlock() {
        stringRedisTemplate.delete(KEY_PREFIX+name);
    }
}

场景代码如下:

  // 通过分布式锁实现一人一单线程安全问题
        SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
        // 获取锁
        boolean isLock = lock.tryLock(100);
        // 判断释放获取锁成功
        if (!isLock){
           // 获取锁失败,返回错误信息,或者重试
            return Result.fail("不允许重复下单");
        }
        try {
            //执行业务代码
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId, userId);
        }finally {
            // 释放锁
            lock.unlock();
        }

这个方案还是可能存在问题:

问题一:「锁过期释放了,业务还没执行完」。假设线程a获取锁成功,一直在执行临界区的代码。但是100s过去后,它还没执行完。但是,这时候锁已经过期了,此时线程b又请求过来。显然线程b就可以获得锁成功,也开始执行临界区的代码。那么问题就来了,临界区的业务代码都不是严格串行执行的啦。

问题二:「锁被别的线程误删」。假设线程a执行完后,去释放锁。但是它不知道当前的锁可能是线程b持有的(线程a去释放锁时,有可能过期时间已经到了,此时线程b进来占有了锁)。那线程a就把线程b的锁释放掉了,但是线程b临界区业务代码可能都还没执行完呢。
image

解决方案:
在释放锁之前,判断这个锁是否是自己的

image

image

代码如下:

public class SimpleRedisLock implements ILock{

    private String name; // 锁的名称

    // 锁的前缀
    public static final String KEY_PREFIX = "lock";
    // 锁的ID前缀,唯一标识,用于解决误删
    public static final String ID_PREFIX = cn.hutool.core.lang.UUID.randomUUID().toString(true)+"-";

    private StringRedisTemplate stringRedisTemplate;

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 尝试获取锁
     *
     * @param timeoutSec 锁持有的超时时间,过期后自动释放
     * @return true代表获取锁成功,false代表获取锁失败
     */
    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程的标识
        String threadID = ID_PREFIX+ Thread.currentThread().getId();
        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadID, timeoutSec, TimeUnit.SECONDS);
        // 防止自动拆箱导致的空指针异常
        return Boolean.TRUE.equals(success);
    }

    /**
     * 释放锁
     */
    @Override
    public void unlock() {
        // 获取线程标识
        String threadID = ID_PREFIX+ Thread.currentThread().getId();
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        // 判断标识是否一致
        if (threadID.equals(id)){
            // 释放锁
            stringRedisTemplate.delete(KEY_PREFIX+name);
        }

    }
}

为了更严谨,一般也是用lua脚本代替。lua脚本如下:
image
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性,Lua是一种编程语言,他的基本语法大家可以参考官网:https://www.runoob.com/lua/lua-tutorial.html

按照上诉lua脚本是这样的:

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

JAVA代码调用lua脚本:

private static final DefaultRedisScript<Long> unlock_script;
    static {
        unlock_script = new DefaultRedisScript<>();
        unlock_script.setLocation(new ClassPathResource("unlock.lua"));
        unlock_script.setResultType(Long.class);
    }
    @Override
    public void unlock() {
        // 调用lua 脚本
        stringRedisTemplate.execute(unlock_script,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX+Thread.currentThread().getId());
    }

image
目前基于 setnx实现的分布式锁 存在下面的问题:

  1. 不可重入,同一个线程无法多次获取同一把锁
  2. 不可重试,获取锁只尝试一次就返回false,没有重试机制
  3. 超时释放,锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患问题
  4. 主从一致性,如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现。
    以上都是基于stringRedisTemplate 实现,接下来使用redission去实现。
posted @ 2023-08-15 20:05  自学Java笔记本  阅读(272)  评论(0)    收藏  举报