全面剖析redis分布式锁

序言

今天一起学习下分布式锁,分布式锁常见于集群环境下,用于做一些单机锁无法解决的问题,比如扣减库存的场景,如果扣减库存的业务机器是多台部署的就会出现超卖现象(JAVA中常见的lock和Synchronized都属于单机锁),此时就需要引入分布式锁了。

分布式锁的实现有很多种,最为常见的是通过redis实现和zookeeper实现,今天我们通过redis来实现一下分布式锁吧。

分布式锁

redis分布式锁相关视频讲解:学习视频

redis实现分布式锁,那么我们不妨思考如何用redis实现呢,条件为:这个操作是互斥的,redis中正好有一条指令可以实现互斥的效果即setnx(k)指令,这条指令的意思就是如果指定的k不存在则执行成功,如果redis中已存在指定的k,则返回false,通过它可以完美实现多台机器间的互斥了(eg:现有5台机器,第一台机器执行了setnx("lock")后,其他机器执行setnx("lock")都是失败的,直到第一台机器将这个key给释放删除掉)。

代码演示

简单模拟一个创建订单扣减库存的场景

    @Autowired
    private RedisTemplate redisTemplate;
    
    private final String LOCKKEY="DistributedLock";
    
    @PostMapping("/createOrder")
    public String createOrder(String goodsId){
        //尝试创建锁
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(LOCKKEY, "value");
        if (flag){
            if (!StringUtils.isEmpty(goodsId)){
                Integer stock = (Integer)redisTemplate.opsForValue().get(goodsId);
                if (stock>0){
                    stock--;
                    redisTemplate.opsForValue().set(goodsId,stock);
                    //......创建订单
                    //释放锁
                    redisTemplate.delete(LOCKKEY);
                    return "创建订单成功";
                }else {
                    redisTemplate.delete(goodsId);
                    //释放锁
                    redisTemplate.delete(LOCKKEY);
                    return "库存不足";
                }
            }else {
                //释放锁
                redisTemplate.delete(LOCKKEY);
                return "库存不足";
            }
        }else {
            //对于没有获取到锁的用户,给它返回一个提示信息,让他重试即可
            return "系统繁忙,请稍后重试";
        }
    }

现在我们实现了一个最为简单的分布式锁版本,但是其中还是存在着不少问题,下面我们一步步的将这些问题解决,并对其进行优化。

解决问题

问题一:程序异常导致的锁未释放

问题描述:很有可能出现在业务执行过程中出现了异常情况,导致没有执行到锁释放的代码,针对于这种情况我们需对代码进行完善,代码如下:

@SuppressWarnings("all")
@Controller
public class Lock {
    @Autowired
    private RedisTemplate redisTemplate;
    private final String LOCKKEY="DistributedLock";
    @PostMapping("/createOrder")
    public String createOrder(String goodsId){
        //尝试创建锁
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(LOCKKEY, "value");
        if (flag){
            try {
                if (!StringUtils.isEmpty(goodsId)){
                    Integer stock = (Integer)redisTemplate.opsForValue().get(goodsId);
                    if (stock>0){
                        stock--;
                        redisTemplate.opsForValue().set(goodsId,stock);
                        //......创建订单
                        return "创建订单成功";
                    }else {
                        redisTemplate.delete(goodsId);
                        return "库存不足";
                    }
                }else {
                    return "库存不足";
                }
            }finally {
                //释放锁,防止出现异常导致的锁无法释放场景
                redisTemplate.delete(LOCKKEY);
            }

        }else {
            //对于没有获取到锁的用户,给它返回一个提示信息,让他重试即可
            return "系统繁忙,请稍后重试";
        }
    }
}

分享更多关于 Linux后端开发网络底层原理知识学习提升 点击 学习资料 获取,完善技术栈,内容知识点包括Linux,Nginx,ZeroMQ,MySQL,Redis,线程池,MongoDB,ZK,Linux内核,CDN,P2P,epoll,Docker,TCP/IP,协程,DPDK等等。

问题二:宕机导致的锁无法释放

当成功添加锁后,出现了服务器宕机,锁此时就无法释放了,因此我们需要为锁添加过期时间,这样即使宕机了锁最终也是会被释放掉的。

  • 注意的点:添加锁过期时间这步操作必须得和添加锁成原子性操作,否则也出现添加完锁,正要添加过期时间时,服务器宕机情况。

正好redisTemplate中支持这种原子性操作,示例如下:

Boolean flag = redisTemplate.opsForValue().setIfAbsent(LOCKKEY, "value",20L, TimeUnit.SECONDS);

问题三:A线程释放B线程的锁

这种情况也是可能发生的,当A线程添加好锁之后,由于某些原因业务代码一直没有执行完,此时锁自动释放了,B线程成功获取到锁,在B线程执行完毕之前,A线程执行到了finally代码块,直接将B线程的锁给释放掉了。

  • 解决方法:我们可以为每个线程都随机生成不同的value,在释放前比较一下value是否相同即可
    @PostMapping("/createOrder")
    public String createOrder(String goodsId){
        //生成value
        String value = UUID.randomUUID().toString().replace("-", "");
        //尝试创建锁,并设置过期时间
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(LOCKKEY, value,20L, TimeUnit.SECONDS);
        if (flag){
            try {
                if (!StringUtils.isEmpty(goodsId)){
                    Integer stock = (Integer)redisTemplate.opsForValue().get(goodsId);
                    if (stock>0){
                        stock--;
                        redisTemplate.opsForValue().set(goodsId,stock);
                        //......创建订单
                        return "创建订单成功";
                    }else {
                        redisTemplate.delete(goodsId);
                        return "库存不足";
                    }
                }else {
                    return "库存不足";
                }
            }finally {
                //释放锁,防止出现异常导致的锁无法释放场景
                Object o = redisTemplate.opsForValue().get(LOCKKEY);
                if (o!=null){
                    String redisValue=(String)o;
                    //当且仅当redis中的value和当前线程value相同时,释放锁
                    if (value.equals(redisValue)){
                        redisTemplate.delete(LOCKKEY);
                    }
                }
            }

        }else {
            //对于没有获取到锁的用户,给它返回一个提示信息,让他重试即可
            return "系统繁忙,请稍后重试";
        }
    }

问题四:释放锁的操作不是原子性

我们可以看到刚才的代码中,释放锁操作不是原子性的,它是先查询再删除的操作,这其中也会出现问题,即A线程查询并获取到锁的value,此时value也和A线程的value完全相同,正要准备释放锁的时候,锁自动过期了,B线程加锁成功,A又一次释放掉了B的锁。

解决方式:

  • 第一种方式:通过lua脚本来让释放锁的操作变成原子性的.
 		//释放锁,防止出现异常导致的锁无法释放场景
                //lua脚本保证原子性
                String script="if redis.call(‘get’,KEYS[1]) == ARGV[1]" +
                      "then" +
                      "    return redis.call(‘del’,KEYS[1])" +
                      "else" +
                      "    return 0" +
                      "end";
                Object eval = jedis.eval(script, Collections.singletonList(LOCKKEY),
                        Collections.singletonList(value));
                if ("0".equals(eval.toString())){
                    System.out.println("删除失败");
                }else {
                    System.out.println("删除锁成功");
                }
                jedis.close();
  • 第二种方式:引入redis中的事务,其中有一个watch命令,它可以监听一个key,开启事务,如果被监听的key被其他线程进行了操作,那么提交是不成功的,因为我们对这个key进行了watch操作
//释放锁,防止出现异常导致的锁无法释放场景
//开启手动提交事务
redisTemplate.setEnableTransactionSupport(true);
//监听锁
redisTemplate.watch(LOCKKEY);
//开启事务
redisTemplate.multi();
if (value.equals(redisTemplate.opsForValue().get(LOCKKEY))){
          redisTemplate.delete(LOCKKEY);
}
//提交事务(如果在监听到现在提交这段时间,锁被其他线程占用,那么删除锁操作不会执行,否则删除锁成功)
redisTemplate.exec();   
//取消监听
redisTemplate.unwatch();

问题五:锁自动失效及主从架构下的问题

  • 锁自动失效问题

可以回顾刚才的问题三和问题四,归根结底的原因就是因为锁自动过期,导致其他线程占用到锁。

锁自动过期无非就是因为锁的存活时间小于业务执行时间,如何解决呢?其实我们可以再启动一个线程让它定时为锁增加存活时间,当删除锁时一并将该线程停止了。

  • 主从架构下的问题

这个问题主要体现在,假设有主从两个库,向主库添加锁成功,数据还没来得及同步给从库,此时主库宕机了,从库成了新的主库,一个线程来了向新主库中添加锁,添加成功了,此时其实就出现了两个线程同时添加锁成功的问题了,怎么解决呢?可以设置从库的延时重启,当锁过期后再让从库成为新的主库。

总结

可以看出通过redis实现分布式锁,有很多需要注意的地方,还有一些注意的点我也没写全,所以说想要自己动手实现一个分布式锁还是十分有难度的,那么我们其实可以用市面上已经封装好的分布式锁就可以了,比如很有名的Redission框架,它就帮我们实现了redis分布式锁,使用方法也很简单,直接getlock,然后lock,unlock就可以了。

猜你喜欢

转载自blog.csdn.net/Linuxhus/article/details/115144974