Redis分布式锁,你真的写对了吗?

251 阅读3分钟

f370db9f289cee5d.webp

谈起redis实现分布式锁,如果你的第一感觉是setnx,恭喜你,正中圈套,下面来与我一起探究分布式锁应该如何写❤️

为什么需要分布式锁

  • 试想这个case:在商城系统中,对相同产品每次下单都会扣除对应的库存。那么为了保证数据的安全,将会在 “判断库存,如果库存足够,减去购买数量,如果库存不够,给出错误提示” 这段代码上面加上Lock或者synchronized关键字,进行上锁,目的是防止多线程情况下造成的数据错误。
  • 那么如果是单节点部署,完全没有问题,所有线程经过上锁的代码,都会乖乖串行进入,但是生产环境下往往都是集群环境,多个jvm,那么锁单个节点就失去了意义,因为一个节点串行执行,另一个节点也是串行执行,都是操作同一个库存数据,完美诠释了:同一时间,多个线程同时操作共享数据,并发问题这不就来了嘛~
  • 所以这个时候需要一个技能,在多个jvm中互斥对方,一个jvm中的一个线程获取了锁,不光同台机器的其他线程获取不到锁,其他jvm中的线程也获取不到。达到全局互斥的效果

第一版本

//使用setnx 如果返回true 说明获取锁成功 
Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, value); 

//释放锁,直接删除对应得key即可 
redisTemplate.delete(key);
  • 利用setnx 的特性,如果没有就设置,如果有不设置,可以达到全局互斥的效果

第二版本

  • 上面case,如果程序在获取锁之后宕机,或者出现其他故障,导致没有执行释放锁命令。那么这个key将会一直在Redis中存在,导致其他服务一直无法获取到锁,相关业务将会阻塞。
  • 需要有一个过期时间,即使出现意外,那么在一段时间以后,锁自动释放,业务不会阻塞
//还是使用setnx 只不过加上了过期时间 
Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, value, 30, TimeUnit.SECONDS);

第三版本

  • 上面case,由于引入的过期时间,锁的释放不是由程序员100%控制,那么必然会出现其他意外,例如:
  • 在一段业务逻辑中,A线程获取锁成功,在处理业务逻辑,这个时候锁过期了。B线程获取到了锁,线程B也在处理业务中。这个时候A的业务处理完成,执行删除锁逻辑,给线程B获取的锁key给删除了。导致删除了别人的锁 造成逻辑混乱
  • 这个时候想到的解决办法就是,在设置key的时候,将当前线程的唯一标识也存进去,释放锁的时候,只释放自己上的锁。
//在设置key的时候,value为当前线程id 
Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, Thread.currentThread().getId(), 30, TimeUnit.SECONDS); 

//在删除锁key得时候,判断这个锁是不是当前线程上的,也即判断value是否是当前线程设置的值 
if(Thread.currentThread().getId().equals(redisTemplate.opsForValue().get(key))){  
  redisTemplate.delete(key); 
}

第4版本

  • 上面case,在释放锁的时候,判断和删除不是原子操作,存在漏洞
  • 这个时候Lua 闪亮登场
  • Lua的入门介绍 可以看我的上一篇❤️
//redis-cli中的lua脚本示例 
eval "redis.call('set','k1','v111') redis.call('set','k2','222') return redis.call('get','k1')" 0 
eval "redis.call('mset',KEYS[1],ARGV[1],KEYS[2],ARGV[2])" 2 k1 k2 vvvvv1 vvvv2 

//判断是否值相等,相等就删除的lua脚本 
eval "if redis.call('get',KEYS[1]) == ARGV[1] then 
        return redis.call('del',KEYS[1]) 
      else return 0 end" 1 k1 vvvvv1 
      
//java代码 
String lua = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end"; 
redisTemplate.execute(new DefaultRedisScript<>(lua, Long.class), Arrays.asList("k1"), "vvvvv1");

第5版本

  • 上面case,如果在一个方法中,第一段代码获取锁并成功获取,下面的逻辑中也要对这个锁再次获取。那么将会陷入僵局,造成死锁
  • 也就是说不能保证锁的可重入。如果一个线程获取了锁,当前线程继续获取锁将会失败
  • 思路:参照ReentrantLock源码思路。获取锁的时候,设置进去当前线程id和锁的次数,下次同一个线程再次获取锁,将次数加一。释放锁的时候,将次数减一,减到0 锁释放成功
  • 这个时候setnxstring 类型已经无法满足要求,单值多value的类型是hash 闪亮登场
  • 继续使用lua脚本实现可重入锁
//lua脚本 获取锁 
eval "if redis.call('hget',KEYS[1],'id') then 
        if redis.call('hget',KEYS[1],'id') == 'v1'then 
            redis.call('hincrby',KEYS[1],'nextc','1') return 1 
        else return 0 end 
      else redis.call('hmset',KEYS[1],'id',ARGV[1],'nextc','1') redis.call('expire',KEYS[1],'100') return 1 end" 1 myclock vv 
      
//lua脚本 释放锁 
eval "if redis.call('hget','myclock','id') then 
        if redis.call('hget','myclock','id') == 'v1' then 
            if redis.call('hget','myclock','nextc') == '1' then 
                return redis.call('del','myclock') 
            else return redis.call('hincrby','myclock','nextc','-1') end 
        else return 0 end 
      else return 0 end" 0

第6版本

  • 上面case,如果线程在持有锁的过程中,由于key过期 导致锁释放,这个时候逻辑还没有处理完,容易造成数据错误
  • 也即需要锁的自动续期
  • 在代码中获取锁成功之后,开启一个定时任务调度,每隔锁的过期时间1/3的时间轮询一次,锁是否存在,存在说明当前线程业务还没有处理完毕,需要续期
//判断锁是否存在,存在就续期的lua脚本
eval "if redis.call('HEXISTS',KEYS[1], 'id') == 1 then return redis.call('expire',KEYS[1],'100') else return 0 end" 1 myclock

//java代码中 在获取锁成功之后,开启调度器,在一段时间之后执行上面lua脚本
new Timer().schedule(new TimerTask() {
    @Override 
    public void run() { 
        //执行lua脚本 
    } 
}, 10000);

结束语

  • 到此,一个基于Redis的分布式锁的案例演进结束了,一个严谨的锁需要具备以下条件
    • 独占:
    • 高可用
    • 防止死锁
    • 不乱抢
    • 可重入

分布式锁的实现有很多,基于 zookeeper, mysql的 大同小异。了解其思想,融会贯通🔥