分布式锁:5个案例,从入门到入土

今天给大家分享的是分布式锁,本文使用五个案例、图、源码分析等来分析。

常见的synchronized、Lock等这些锁都是基于单个JVM的实现的,如果分布式场景下怎么办呢?这时候分布式锁就出现了。

关于分布式的实现方案,在业界流行的有三种:

1、基于数据库

2、基于Redis

3、基于Zookeeper

另外,还有使用etcdconsul来实现的。

在开发中使用最多的是RedisZookeeper两种方案,并且两种方案中最复杂的,最容易出问题的就是Redis的实现方案,所以,我们今天就来把Redis实现方案都聊聊。

本文主要内容

分布式锁:5个案例,从入门到入土

分布式锁场景

估计部分朋友还不太清楚分布式的使用场景,下面我简单罗列三种:

分布式锁:5个案例,从入门到入土

案例1

如下代码模拟了下单减库存的场景,我们分析下在高并发场景下会存在什么问题

@RestController public class IndexController { @Autowired private StringRedisTemplate stringRedisTemplate; /** * 模拟下单减库存的场景 * @return */ @RequestMapping(value = "/duduct_stock") public String deductStock(){ // 从redis 中拿当前库存的值 int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); if(stock > 0){ int realStock = stock - 1; stringRedisTemplate.opsForValue().set("stock",realStock + ""); System.out.println("扣减成功,剩余库存:" + realStock); }else{ System.out.println("扣减失败,库存不足"); } return "end"; } }登录后复制

现在有5个客户端同时请求该接口,可能就会存在同时执行

int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));登录后复制

案例2-使用synchronized 实现单机锁

在遇到案例1的问题后,大部分人的第一反应都会想到加锁来控制事务的原子性,如下代码所示:

@RequestMapping(value = "/duduct_stock") public String deductStock(){ synchronized (this){ // 从redis 中拿当前库存的值 int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); if(stock > 0){ int realStock = stock - 1; stringRedisTemplate.opsForValue().set("stock",realStock + ""); System.out.println("扣减成功,剩余库存:" + realStock); }else{ System.out.println("扣减失败,库存不足"); } } return "end"; }登录后复制

但我们都知道,synchronized 锁是属于JVM级别的,也就是我们俗称的“单机锁”。但现在基本大部分公司使用的都是集群部署,现在我们思考下以上代码在集群部署的情况下还能保证库存数据的一致性吗?

分布式锁:5个案例,从入门到入土

答案是不能,如上图所示,请求经Nginx分发后,可能存在多个服务同时从Redis中获取库存数据,此时只加synchronized (单机锁)是无效的,并发越高,出现问题的几率就越大。

案例3-使用SETNX实现分布式锁

setnx:将 key 的值设为 value,当且仅当 key 不存在。

若给定 key 已经存在,则 setnx 不做任何动作。

使用setnx实现简单的分布式锁:

/** * 模拟下单减库存的场景 * @return */ @RequestMapping(value = "/duduct_stock") public String deductStock(){ String lockKey = "product_001"; // 使用 setnx 添加分布式锁 // 返回 true 代表之前redis中没有key为 lockKey 的值,并已进行成功设置 // 返回 false 代表之前redis中已经存在 lockKey 这个key了 Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "wangcp"); if(!result){ // 代表已经加锁了 return "error_code"; } // 从redis 中拿当前库存的值 int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); if(stock > 0){ int realStock = stock - 1; stringRedisTemplate.opsForValue().set("stock",realStock + ""); System.out.println("扣减成功,剩余库存:" + realStock); }else{ System.out.println("扣减失败,库存不足"); } // 释放锁 stringRedisTemplate.delete(lockKey); return "end"; }登录后复制

当setnx设置成功后,可执行业务代码对库存扣减,执行完成后对锁进行释放。

我们再来思考下以上代码已经完美实现分布式锁了吗?能够支撑高并发场景吗?答案并不是,上面的代码还是存在很多问题的,离真正的分布式锁还差的很远。

我们分析一下,上面的代码存在的问题:

死锁:假如第一个请求在setnx加锁完成后,执行业务代码时出现了异常,那释放锁的代码就无法执行,后面所有的请求也都无法进行操作了。

针对死锁的问题,我们对代码再次进行优化,添加try-finally,在finally中添加释放锁代码,这样无论如何都会执行释放锁代码,如下所示:

/** * 模拟下单减库存的场景 * @return */ @RequestMapping(value = "/duduct_stock") public String deductStock(){ String lockKey = "product_001"; try{ // 使用 setnx 添加分布式锁 // 返回 true 代表之前redis中没有key为 lockKey 的值,并已进行成功设置 // 返回 false 代表之前redis中已经存在 lockKey 这个key了 Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "wangcp"); if(!result){ // 代表已经加锁了 return "error_code"; } // 从redis 中拿当前库存的值 int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); if(stock > 0){ int realStock = stock - 1; stringRedisTemplate.opsForValue().set("stock",realStock + ""); System.out.println("扣减成功,剩余库存:" + realStock); }else{ System.out.println("扣减失败,库存不足"); } }finally { // 释放锁 stringRedisTemplate.delete(lockKey); } return "end"; }登录后复制

案例4-加入过期时间

针对想到的问题,对代码再次进行优化,加入过期时间,这样即便出现了上述的问题,在时间到期后锁也会自动释放掉,不会出现“死锁”的情况。

@RequestMapping(value = "/duduct_stock") public String deductStock(){ String lockKey = "product_001"; try{ Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,"wangcp",10,TimeUnit.SECONDS); if(!result){ // 代表已经加锁了 return "error_code"; } // 从redis 中拿当前库存的值 int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); if(stock > 0){ int realStock = stock - 1; stringRedisTemplate.opsForValue().set("stock",realStock + ""); System.out.println("扣减成功,剩余库存:" + realStock); }else{ System.out.println("扣减失败,库存不足"); } }finally { // 释放锁 stringRedisTemplate.delete(lockKey); } return "end"; }登录后复制

超时时间设置的10s真的合适吗?如果不合适设置多少秒合适呢?如下图所示

分布式锁:5个案例,从入门到入土图片

假设同一时间有三个请求。

  • 请求1首先加锁后需执行15秒,但在执行到10秒时锁失效释放。
  • 请求2进入后加锁执行,在请求2执行到5秒时,请求1执行完成进行锁释放,但此时释放掉的是请求2的锁。
  • 请求3在请求2执行5秒时开始执行,但在执行到3秒时请求2执行完成将请求3的锁进行释放。

我们现在只是模拟3个请求便可看出问题,如果在真正高并发的场景下,可能锁就会面临“一直失效”或“永久失效”。

那么具体问题出在哪里呢?总结为以下几点:

  • 1.存在请求释放锁时释放掉的并不是自己的锁
  • 2.超时时间过短,存在代码未执行完便自动释放

针对问题我们思考对应的解决方法:

  • 针对问题1,我们想到在请求进入时生成一个唯一id,使用该唯一id作为锁的value值,释放时先进行获取比对,比对相同时再进行释放,这样就可以解决释放掉其它请求锁的问题。
  • 针对问题2,我们思考不断的延长过期时间真的合适吗?设置短了存在超时自动释放的问题,设置长了又会出现宕机后一段时间锁无法释放的问题,虽然不会再出现“死锁”。针对这个问题,如何解决呢?

案例5-Redisson分布式锁

Spring Boot集成Redisson步骤

引入依赖

org.redisson redisson 3.6.5 登录后复制