Redis分布式锁实战

baojunzh
发布于 2022-10-31 16:41
浏览
0收藏

啥是分布式锁?

我们学习 Java 都知道锁的概念,例如基于 JVM 实现的同步锁 synchronized,以及 jdk 提供的一套代码级别的锁机制 lock,我们在并发编程中会经常用这两种锁去保证代码在多线程环境下运行的正确性。但是这些锁机制在分布式场景下是不适用的,原因是在分布式业务场景下,我们的代码都是跑在不同的JVM甚至是不同的机器上,synchronized 和 lock 只能在同一个 JVM 环境下起作用。所以这时候就需要用到分布式锁了。


例如,现在有个场景就是整点抢消费券(疫情的原因,支付宝最近在8点、12点整点开放抢消费券),消费券有一个固定的量,先到先得,抢完就没了,线上的服务都是部署多个的,大致架构如下:


Redis分布式锁实战-鸿蒙开发者社区



所以这个时候我们就得用分布式锁来保证共享资源的访问的正确性。

为什么要用分布式锁嗯?

假设不使用分布式锁,我们看看 synchronized 能不能保证?其实是不能的,我们来演示一下。


下面我写了一个简单的 springboot 项目来模拟这个抢消费券的场景,代码很简单,大致意思是先从 Redis 获取剩余消费券数,然后判断大于0,则减一模拟被某个用户抢到一个,然后减一后再修改 Redis 的剩余消费券数量,打印扣减成功,剩余还有多少,否则扣减失败,就没抢到。整块代码被 synchronized 包裹,Redis 设置的库存数量为50。

//假设库存编号是00001
private String key = "stock:00001";
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
 * 扣减库存 synchronized同步锁
*/
@RequestMapping("/deductStock")
public String deductStock(){
    synchronized (this){
        //获取当前库存
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
        if(stock>0){
            int afterStock = stock-1;
            stringRedisTemplate.opsForValue().set(key,afterStock+"");//修改库存
            System.out.println("扣减库存成功,剩余库存"+afterStock);
        }else {
            System.out.println("扣减库存失败");
        }
    }
    return "ok";
}


然后启动两个springboot项目,端口分别为8080,8081,然后在nginx里配置负载均衡


upstream redislock{
server 127.0.0.1:8080;
server 127.0.0.1:8081;
}
server {
listen       80;
server_name  127.0.0.1;
location / {
root   html;
index  index.html index.htm;
proxy_pass http://redislock;
    }
}

然后用jmeter压测工具进行测试

Redis分布式锁实战-鸿蒙开发者社区

Redis分布式锁实战-鸿蒙开发者社区


然后我们看一下控制台输出,可以看到我们运行的两个web实例,很多同样的消费券被不同的线程抢到,证明synchronized在这样的情况下是不起作用的,所以就需要使用分布式锁来保证资源的正确性。

Redis分布式锁实战-鸿蒙开发者社区



如何用Redis实现分布式锁?

在实现分布式锁之前,我们先考虑如何实现,以及都要实现锁的哪些功能。

1、分布式特性(部署在多个机器上的实例都能够访问这把锁)

2、排他性(同一时间只能有一个线程持有锁)

3、超时自动释放的特性(持有锁的线程需要给定一定的持有锁的最大时间,防止线程死掉无法释放锁而造成死锁)

4、...


基于以上列出的分布式锁需要拥有的基本特性,我们思考一下使用Redis该如何实现?

1、第一个分布式的特性Redis已经支持,多个实例连同一个Redis即可

2、第二个排他性,也就是要实现一个独占锁,可以使用Redis的setnx命令实现

3、第三个超时自动释放特性,Redis可以针对某个key设置过期时间

4、执行完毕释放分布式锁


科普时间

Redis Setnx 命令

Redis Setnx(SET if Not eXists) 命令在指定的 key 不存在时,为 key 设置指定的值

语法

redis Setnx 命令基本语法如下:

redis 127.0.0.1:6379> SETNX KEY_NAME VALUE

可用版本:>= 1.0.0

返回值:设置成功,返回1, 设置失败,返回0


@RequestMapping("/stock_redis_lock")
public String stock_redis_lock(){
    //底层使用setnx命令
    Boolean aTrue = stringRedisTemplate.opsForValue().setIfAbsent(lock_key, "true");
    stringRedisTemplate.expire(lock_key,10, TimeUnit.SECONDS);//设置过期时间10秒
    if (!aTrue) {//设置失败则表示没有拿到分布式锁
        return "error";//这里可以给用户一个友好的提示
    }
    //获取当前库存
    int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
    if(stock>0){
        int afterStock = stock-1;
        stringRedisTemplate.opsForValue().set(key,afterStock+"");
        System.out.println("扣减库存成功,剩余库存"+afterStock);
    }else {
        System.out.println("扣减库存失败");
    }
    stringRedisTemplate.delete(lock_key);//执行完毕释放分布式锁
    return "ok";
}


仍然设置库存数量为50,我们再用jmeter测试一下,把jmeter的测试地址改为127.0.0.1/stock_redis_lock,同样的设置再来测一次。

Redis分布式锁实战-鸿蒙开发者社区


测试了5次没有出现脏数据,把发送时间改为0,测了5次也没问题,然后又把线程数改为600,时间为0 ,循环4次,测了几次也是正常的。


上面实现分布式锁的代码已经是一个较为成熟的分布式锁的实现了,对大多数软件公司来说都已经满足需求了。但是上面代码还是有优化的空间,例如:


1)上面的代码我们是没有考虑异常情况的,实际情况下代码没有这么简单,可能还会有别的很多复杂的操作,都有可能会出现异常,所以我们释放锁的代码需要放在finally块里来保证即使是代码抛异常了释放锁的代码他依然会被执行。


2)还有,你有没有注意到,上面我们的分布式锁的代码的获取和设置过期时间的代码是两步操作第4行和第5行,即非原子操作,就有可能刚执行了第4行还没来得及执行第5行这台机器挂了,那么这个锁就没有设置超时时间,其他线程就一直无法获取,除非人工干预,所以这是一步优化的地方,Redis也提供了原子操作,那就是SET key value EX seconds  NX


科普时间

SET key value [EX seconds] [PX milliseconds] [NX|XX]  将字符串值 value 关联到 key

可选参数

从 Redis 2.6.12 版本开始, SET 命令的行为可以通过一系列参数来修改:

  • EX second :设置键的过期时间为 second 秒。SET key value EX second 效果等同于 SETEX key second value
  • PX millisecond :设置键的过期时间为 millisecond 毫秒。SET key value PX millisecond 效果等同于 PSETEX key millisecond value
  • NX :只在键不存在时,才对键进行设置操作。SET key value NX 效果等同于 SETNX key value
  • XX :只在键已经存在时,才对键进行设置操作


SpringBoot的StringRedisTemplate也有对应的方法实现,如下代码:

//假设库存编号是00001
private String key = "stock:00001";
private String lock_key = "lock_key:00001";
@Autowired
private StringRedisTemplate stringRedisTemplate;
@RequestMapping("/stock_redis_lock")
public String stock_redis_lock() {
    //String uuid = UUID.randomUUID().toString();
    try {
        //原子的设置key及超时时间
        Boolean aTrue = stringRedisTemplate.opsForValue().setIfAbsent(lock_key, "true", 30, TimeUnit.SECONDS);
        if (!aTrue) {
            return "error";
        }
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
        if (stock > 0) {
            int afterStock = stock - 1;
            stringRedisTemplate.opsForValue().set(key, afterStock + "");
            System.out.println("扣减库存成功,剩余库存" + afterStock);
        } else {
            System.out.println("扣减库存失败");
        }
    } catch (NumberFormatException e) {
        e.printStackTrace();
    } finally {
        //避免死锁
        //if (uuid.equals(stringRedisTemplate.opsForValue().get(lock_key))) {
            stringRedisTemplate.delete(lock_key);
        //}
    }
    return "ok";
}

这样实现是否就完美了呢?嗯,对于并发量要求不高或者非大并发的场景的话这样实现已经可以了。但是对于抢购 ,秒杀这样的场景,当流量很大,这时候服务器网卡、磁盘IO、CPU负载都可能会达到极限,那么服务器对于一个请求的的响应时间势必变得比正常情况下慢很多,那么假设就刚才设置的锁的超时时间为10秒,如果某一个线程拿到锁之后因为某些原因没能在10秒内执行完毕锁就失效了,这时候其他线程就会抢占到分布式锁去执行业务逻辑,然后之前的线程执行完了,会去执行 finally 里的释放锁的代码就会把正在占有分布式锁的线程的锁给释放掉,实际上刚刚正在占有锁的线程还没执行完,那么其他线程就又有机会获得锁了...这样整个分布式锁就失效了,将会产生意想不到的后果。如下图模拟了这个场景。

Redis分布式锁实战-鸿蒙开发者社区


所以这个问题总结一下,就是因为锁的过期时间设置的不合适或因为某些原因导致代码执行时间大于锁过期时间而导致并发问题以及锁被别的线程释放,以至于分布式锁混乱。在简单的说就是两个问题,1)自己的锁被别人释放 2)锁超时无法续时间。


第一个问题很好解决,在设置分布式锁时,我们在当前线程中生产一个唯一串将value设置为这个唯一值,然后在finally块里判断当前锁的value和自己设置的一样时再去执行delete,如下:


String uuid = UUID.randomUUID().toString();
try {
    //原子的设置key及超时时间,锁唯一值
    Boolean aTrue = stringRedisTemplate.opsForValue().setIfAbsent(lock_key,uuid,30,TimeUnit.SECONDS);
    //...
} finally {
    //是自己设置的锁再执行delete
    if(uuid.equals(stringRedisTemplate.opsForValue().get(lock_key))){
        stringRedisTemplate.delete(lock_key);//避免死锁
    }
}


问题一解决了(设想一下上述代码还有什么问题,一会儿讲),那锁的超时时间就很关键了,不能太大也不能太小,这就需要评估业务代码的执行时间,比如设置个10秒,20秒。即使是你的锁设置了合适的超时时间,也避免不了可能会发生上述分析的因为某些原因代码没在正常评估的时间内执行完毕,所以这时候的解决方案就是给锁续超时时间。大致思路就是,业务线程单独起一个分线程,定时去监听业务线程设置的分布式锁是否还存在,存在就说明业务线程还没执行完,那么就延长锁的超时时间,若锁已不存在则业务线程执行完毕,然后就结束自己。


“锁续命”的这套逻辑属实有点复杂啊,要考虑的问题太多了,稍不注意就会有bug。不要看上面实现分布式锁的代码没有几行,就认为实现起来很简单,如果说自己去实现的时候没有实际高并发的经验,肯定也会踩很多坑,例如,


1)锁的设置和过期时间的设置是非原子操作的,就可能会导致死锁。


2)还有上面遗留的一个,在finally块里判断锁是否是自己设置的,是的话再删除锁,这两步操作也不是原子的,假设刚判断完为true服务就挂了,那么删除锁的代码不会执行,就会造成死锁,即使是设置了过期时间,在没过期这段时间也会死锁。所以这里也是一个注意的点,要保证原子操作的话,Redis提供了执行Lua脚本的功能来保证操作的原子性,具体怎么使用不再展开。


所以,“锁续命”的这套逻辑实现起来还是有点复杂的,好在市面上已经有现成的开源框架帮我们实现了,那就是Redisson。


Redisson分布式锁的实现原理

Redis分布式锁实战-鸿蒙开发者社区


实现原理:

1、首先Redisson会尝试进行加锁,加锁的原理也是使用类似Redis的setnx命令原子的加锁,加锁成功的话其内部会开启一个子线程

2、子线程主要负责监听,其实就是一个定时器,定时监听主线程是否还持有锁,持有则将锁的时间延时,否则结束线程

3、如果加锁失败则自旋不断尝试加锁

4、执行完代码主线程主动释放锁


那我们看一下使用后Redisson后的代码是什么样的。


1、首先在pom.xml文件添加Redisson的maven坐标

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.12.5</version>
</dependency>

2、我们要拿到Redisson的这个对象,如下配置Bean

@SpringBootApplication
public class RedisLockApplication {
    public static void main(String[] args) {
        SpringApplication.run(RedisLockApplication.class, args);
    }
    @Bean
    public Redisson redisson(){
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379")
                .setDatabase(0);
        return (Redisson) Redisson.create(config);
    }
}

3、然后我们获取Redisson的实例,使用其API进行加锁释放锁操作


//假设库存编号是00001
private String key = "stock:00001";
private String lock_key = "lock_key:00001";
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
 * 使用Redisson实现分布式锁
 * @return
 */
@RequestMapping("/stock_redisson_lock")
public String stock_redisson_lock() {
    RLock redissonLock = redisson.getLock(lock_key);
    try {
        redissonLock.lock();
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
        if (stock > 0) {
            int afterStock = stock - 1;
            stringRedisTemplate.opsForValue().set(key, afterStock + "");
            System.out.println("扣减库存成功,剩余库存" + afterStock);
        } else {
            System.out.println("扣减库存失败");
        }
    } catch (NumberFormatException e) {
        e.printStackTrace();
    } finally {
        redissonLock.unlock();
    }
    return "ok";
}


看这个Redisson的分布式锁提供的API是不是非常的简单?就像Java并发变成里AQS那套Lock机制一样,如下获取一把RedissonLock

RLock redissonLock = redisson.getLock(lock_key);

默认返回的是RedissonLock的对象,该对象实现了RLock接口,而RLock接口继承了JDK并发编程报包里的Lock接口

Redis分布式锁实战-鸿蒙开发者社区


在使用Redisson加锁时,它也提供了很多API,如下

Redis分布式锁实战-鸿蒙开发者社区


现在我们选择使用的是最简单的无参lock方法,简单的点进去跟一下看看他的源码,我们找到最终的执行加锁的代码如下:

Redis分布式锁实战-鸿蒙开发者社区


我们可以看到其底层使用了Lua脚本来保证原子性,使用Redis的hash结构实现的加锁,以及可重入锁。

比我们自己实现分布式锁看起来还要简单,但是我们自己写的锁功能他都有,我们没有的他也有。比如,他实现的分布式锁是支持可重入的,也支持可等待,即尝试等待一定时间,没拿到锁就返回false。上述代码中的redissonLock.lock();是一直等待,内部自旋尝试加锁。



Distributed Java locks and synchronizers 

Lock 

FairLock 

MultiLock 

RedLock 

ReadWriteLock 

Semaphore 

PermitExpirableSemaphore 

CountDownLatch


redisson.org

Redisson提供了丰富的API,内部运用了大量的Lua脚本保证原子操作,篇幅原因redisson实现锁的代码暂不分析了。


结语

到这里,Redis分布式锁实战基本就讲完了,总结一下Redis分布式锁吧。

1、如果说是自己实现的话,需要特别注意四点:

1) 原子加锁 

2)设置锁超时时间  

3)谁加的锁谁释放,且释放时的原子操作  

4)锁续命问题。

2、如果使用现成的分布式锁框架Redisson,就需要熟悉一下其常用的API以及实现原理,或者选择其他开源的分布式锁框架,充分考察,选择适合自己业务需求的即可。


参考:

http://doc.redisfans.com/string/set.html

https://www.runoob.com/redis/strings-setnx.html

https://github.com/redisson/redisson/wiki/8.-distributed-locks-and-synchronizers#81-lock



本文转载自公众号BiggerBoy

分类
标签
已于2022-10-31 16:41:54修改
收藏
回复
举报
回复
    相关推荐