Redis实现分布式锁+可重入性

2 篇文章 0 订阅
1 篇文章 0 订阅

Redis实现分布式锁

一、为什么需要分布式锁

比如现在有A和B两个操作对数据库中的数据account进行操作,account初始值为42。

A:account=db.getAccount();      >      account=42

      account+= 10;     >     account=52

     db.setAccount(account);

以上A要对account所做的get+set不是原子性操作,所以A在get之后set之前,如果B也参与到对account的操作,那么就会出现并发问题。下面用一个图来说明这个问题。

那么为了解决这个问题,我们可以认为在A操作account的时候,其他的线程不能对account进行操作。此时就可以使用分布式锁来进行“占坑”。

二、分布式锁的初步设计

Redis中string数据结构提供了一直令setnx(set if not exists),只允许被一个客户端“占坑”,用完之后del key即可,是不是很简单,你以为这就完了吗?

1、锁内逻辑异常

试想一下上面说的setnx之后业务逻辑处理完再del key,如果业务逻辑正常执行完后del key当然没什么问题,那要是锁内的业务逻辑异常退出了,那我们setnx了一个永久key,锁永远不会被释放,导致其他的客户端或线程永远加锁失败,这个怎么处理呢?

(1)、你可能会想到对整个加锁逻辑进行try finally,在finally中del key释放锁。这种也可以,那要是在执行锁内逻辑时,服务停机导致没有走到finally去释放锁怎么办呢?

(2)、你可能会想到Redis设置一个key过期时间,不关心锁内逻辑是否异常。未异常则会走到del key释放锁;异常未释放锁也没关系,到了key的过期时间,Redis自然释放锁。那我们说说过期时间设置多久合适呢?设置的太长会导致其他线程一直处于等待锁的状态,会影响系统性能。设置的太短有可能会在锁内逻辑未执行完,Redis过期策略主动释放锁,还是会导致线程不安全。所以key过期时间的设置应该取决于锁内逻辑的执行耗时。

2、setnx之后设置一个key过期时间真的可行吗?

如果在setnx和expire之间Redis突然挂掉了(机器断电或人为终止服务等)导致setnx执行了但expire没有执行,key还是会变成永久key,也会造成死锁。引发这个问题的根本原因在于setnx和expire不是原子性操作。那么如果是Redis2.8之前的版本,可以使用Lua脚本或者Redis开源社区的分布式锁library来处理;如果Redis是2.8及以上版本,那么恭喜你,Redis2.8中加入了set指令的扩展参数使得setnx和expire可以成为原子性操作一起执行。

3、原子性的设置过期时间后还有没有其他问题?

假设此时有AB两个线程,A已获得锁并设置过期时间是5秒,锁内业务逻辑执行耗时>5秒,那么就会出现A在业务逻辑还未执行完锁就被释放,此时B就可以获得锁,如果在B成功获得锁后,A的业务逻辑执行结束去释放锁,结果把B的锁给释放掉了,也会出现线程不安全的问题。解决这个问题,我们可以在获得锁时设置一个唯一标识(时间戳或UUID等)的value值,在释放锁时进行value的判断,value相等时表示要释放的锁就是当前线程起初获得的锁,进行所释放即可。

4、做了锁判断后再释放一定可以保证锁不错位释放吗?

很明显答案是不一定,因为判断锁和释放锁两个操作结合在一起不是原子性操作。设想一下线程A在判断锁和释放锁之间,锁的过期时间到了,Redis主动释放了A加的锁,此时线程B就可以获得锁,在线程B获得锁之后,线程A执行到释放锁,如此一来,线程A还是将线程B的锁释放了。原因是什么呢?还是原子性操作。但是Redis并没有提供del if equals这样的操作,不过上面也说到过Lua脚本可以是Redis的多个操作原子性的执行,所以我们可以这样做:

if  redis.call("get",  KEYS[1])  ==  ARGV[1]  then

        return  redis.call("del",  KEYS[1])

else

        return  0

end

三、遗留问题

我们为什么要在锁释放前去判断锁?是为了防止锁的错位释放。那锁的错位释放是由什么引起的呢?因为我们不能准确的知道锁内业务逻辑的耗时,再加上根据机器当时的状态及各种原因锁内业务逻辑耗时不是一成不变的固定值,所以对锁的过期时间设置不能足够的准确。一旦线程A的锁在逻辑结束之前被Redis过期释放掉了,其他线程就有可能获得锁就如锁内逻辑,此时A的逻辑还没执行结束,那么就存在锁内逻辑同时被两个线程执行,依旧存在线程安全问题。针对可这问题,你不妨考虑一下可重入锁。

四、代码实现

1、非可重入

@Component
public class RedisLock {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 加锁
     *
     * @param key 加锁key
     * @param value 加锁value,释放锁时需要根据value进行判断
     * @param expire 过期时间,单位:秒
     * @return true:加锁成功
     */
    public boolean lock(String key, String value, @NonNull Long expire) {
        if (StringUtils.isBlank(key)) {
            throw new IllegalArgumentException("class:RedisLock,method:public boolean lock(String key),error message:key is blank");
        }
        Boolean result = stringRedisTemplate.boundValueOps(key).setIfAbsent(value, expire, TimeUnit.SECONDS);
        return Optional.ofNullable(result).orElse(false);
    }

    /**
     * 释放锁
     *
     * @param key 释放锁key
     * @param value 当释放锁value与加锁value相等时才会释放锁
     * @return true:释放锁成功
     */
    public void unLock(String key, String value) {
        String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        RedisScript<Integer> script = new DefaultRedisScript<>(luaScript, Integer.class);
        stringRedisTemplate.execute(script, Stream.of(key).collect(Collectors.toList()), value);
    }
}

2、可重入

public class RedisReentrantLock {

    private static final ThreadLocal<Map<String, Integer>> threadLocal = new ThreadLocal<>();

    private RedisTemplate<String, String> redisTemplate;

    public RedisReentrantLock(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 加锁
     *
     * @param key 加锁key
     * @return true:加锁成功; false:加锁失败
     */
    public boolean lock(String key) {
        boolean lockFlag = false;
        Map<String, Integer> currentLocks = this.currentLocks();
        Integer lockCount = currentLocks.get(key);
        if (Objects.nonNull(lockCount) && lockCount.compareTo(0) > 0) {
            currentLocks.put(key, lockCount + 1);
            lockFlag = true;
        } else if (Optional.ofNullable(redisTemplate.opsForValue().setIfAbsent(key, "", 5, TimeUnit.SECONDS)).orElse(false)) {
            currentLocks.put(key, 1);
            lockFlag = true;
        }
        return lockFlag;
    }

    /**
     * 释放锁
     *
     * @param key 加锁key
     * @return true:释放成功; false:释放失败
     */
    public boolean unLock(String key) {
        boolean unLockFlag = false;
        Map<String, Integer> currentLocks = this.currentLocks();
        Integer lockCount = currentLocks.get(key);
        if (Objects.nonNull(lockCount)) {
            lockCount -= 1;
            if (lockCount.compareTo(0) > 0) {
                currentLocks.put(key, lockCount);
            } else {
                threadLocal.remove();
                redisTemplate.delete(key);
            }
            unLockFlag = true;
        }
        return unLockFlag;
    }

    /**
     * 获取当前线程所持有的锁
     *
     * @return
     */
    private Map<String, Integer> currentLocks() {
        Map<String, Integer> map = threadLocal.get();
        if (Objects.isNull(map)) {
            map = new HashMap<>(0);
            threadLocal.set(map);
        }
        return map;
    }
}

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值