Redis高并发分布式锁 Redisson的使用及源代码剖析(一)

1,518 阅读9分钟

「这是我参与2022首次更文挑战的第9天,活动详情查看:2022首次更文挑战

分布式锁场景

秒杀、抢优惠券、接口幂等性校验等

单机单线程

随着互联网的发展,老王看到了互联网发展的红利,因此自己辞职之后,就创办了一个电商服务平台,吭哧吭哧开发了一段时间,项目发布上线,正好618马上就到到了,想着发一些优惠券做一下大促,活跃一下用户,顺便小赚一笔!然后老王表叔家哥哥媳妇的弟弟王二麻子写了一个抢优惠券的程序。代码如下:

@RequestMapping("/get_coupon")
public String deductStock() {
    int coupon = Integer.parseInt(stringRedisTemplate.opsForValue().get("coupon_100")); // jedis.get("stock")
    if (coupon > 0) {
        int currCoupons = coupon - 1;
        stringRedisTemplate.opsForValue().set("coupon_100", currCoupons + ""); // jedis.set(key,value)
        System.out.println("扣减成功,剩余库存:" + currCoupons);
    } else {
        System.out.println("扣减失败,库存不足");
    }
    return "success";
}

代码解析:
1、首先从redis中获得优惠券数量
2、如果优惠券数量大于0则-1,并且将减后的数量再次放入redis。
王二麻子 心想这样不就实现了一个抢优惠券的功能,自己本地测试也没问题,然后就提交到了测试。

测试同学用jmeter并发压测一跑,结果本来1000的优惠券,超卖了何止几百个,马上提交测试报告,反馈给王二麻子。

单机多线程并发

王二麻子看到测试结果,仔细推敲了一下代码,发现如果并发多了,还真会能超卖
int coupon = Integer.parseInt(stringRedisTemplate.opsForValue().get("coupon_100"));
以上代码如果N条线程同时进来,获得的优惠券数量都是1000,后面代码再-1,也就没啥意义了。
A 线程:1000-1=999,然后放入redis
B 线程:1000-1=999,然后放入redis
这样redis里面的优惠券数量肯定会有问题。王二麻子灵机一动,之前学习过synchronized,给这段代码加上一把锁,这样即时并发多了,也是一个线程一个线程的执行库存-1操作,这样不就没问题了,于是代码更新成如下:

@RequestMapping("/get_coupon")
public String deductStock() {
    synchronized (this){
        int coupon = Integer.parseInt(stringRedisTemplate.opsForValue().get("coupon_100")); // jedis.get("stock")
        if (coupon > 0) {
            int currCoupons = coupon - 1;
            stringRedisTemplate.opsForValue().set("coupon_100", currCoupons + ""); // jedis.set(key,value)
            System.out.println("扣减成功,剩余库存:" + currCoupons);
        } else {
            System.out.println("扣减失败,库存不足");
        }
    }
    return "success";
}

然后王二麻子又重新提交了测试。
测试同学拿到提测,又用jmeter跑了一下并发,嘿!还真没事了,优惠券不会超卖了,心想王二麻子这小伙解决bug效率还不错嘛,于是测试同学就把代码发布到了预发布环境,预发布环境是这样的:

image.png 结果发现优惠券,又被抢超了,测试同学于是又把bug打回到了王二麻子。

分布式锁

redis setnx的应用

王二麻子发现bug又回了,然后又仔细推敲了一下,结果发现,如果是多态服务器,客户端线程通过nginx分发到tomcat,多个tomcat之间无法用synchronized进行加锁,于是得想一个分布式锁才行,于是经过王二麻子改造,代码又更新成这样:

@RequestMapping("/get_coupon")
public String deductStock() {

    String couponKey="coupon_100";
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(couponKey, "lock");
    if(!lock){
        return "排队中";
    }
    //----业务处理---begin
    int coupon = Integer.parseInt(stringRedisTemplate.opsForValue().get("coupon_100")); // jedis.get("stock")
    if (coupon > 0) {
        int currCoupons = coupon - 1;
        stringRedisTemplate.opsForValue().set("coupon_100", currCoupons + ""); // jedis.set(key,value)
        System.out.println("扣减成功,剩余库存:" + currCoupons);
    } else {
        System.out.println("扣减失败,库存不足");
    }
    //----业务处理---end
    stringRedisTemplate.delete(couponKey);
    return "success";
}

代码解析:通过使用redis的setnx方法,第一个线程进来之后先setnx一个值,这样后面的线程再setnx就无法成功执行了,只能等第一个线程delete key之后,后面的线程才能继续执行setnx,一个简单的分布式锁搞定。

通过finally确保redis delete key成功

不过经过前两次被测试两次打回bug,小王同学决定,再仔细推敲一下代码,万一再遗留问题,结果还真发现一个问题,万一在业务处理这部分代码出现异常,那stringRedisTemplate.delete(couponKey);就无法执行,那以后的线程就都无法进来了,因此代码又进行了一下升级,如下:

@RequestMapping("/get_coupon")
public String deductStock() {
    String couponKey="coupon_100";
    try {
        Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(couponKey, "lock");
        if(!lock){
            return "排队中";
        }
        //----业务处理---begin
        int coupon = Integer.parseInt(stringRedisTemplate.opsForValue().get("coupon_100")); // jedis.get("stock")
        if (coupon > 0) {
            int currCoupons = coupon - 1;
            stringRedisTemplate.opsForValue().set("coupon_100", currCoupons + ""); // jedis.set(key,value)
            System.out.println("扣减成功,剩余库存:" + currCoupons);
        } else {
            System.out.println("扣减失败,库存不足");
        }
        //----业务处理---end
    }finally {
        stringRedisTemplate.delete(couponKey);
    }
    return "success";
}

这样即使业务代码执行过程中出现异常,通过finally也能执行stringRedisTemplate.delete(couponKey);这样就不用担心程序发生异常无法执行delete key了。

redis key 有效期的使用(stringRedisTemplate.expire的使用)

小王同学还是不放心,又推敲了一下代码,发现万一程序在执行过程中,在业务代码这块,服务器宕机了,那stringRedisTemplate.delete(couponKey);就永远无法执行了,想起来一阵后怕,还好没提交测试,要不自己在测试大美丽心里又要受到鄙视了。于是代码又进行了改造,如下:

@RequestMapping("/get_coupon")
public String deductStock() {
    String couponKey="coupon_100";
    try {
        Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(couponKey, "lock");
        //只锁定线程10秒,10秒后自动删除key
        stringRedisTemplate.expire(couponKey,10, TimeUnit.SECONDS);
        if(!lock){
            return "排队中";
        }
        //----业务处理---begin
        int coupon = Integer.parseInt(stringRedisTemplate.opsForValue().get("coupon_100")); // jedis.get("stock")
        if (coupon > 0) {
            int currCoupons = coupon - 1;
            stringRedisTemplate.opsForValue().set("coupon_100", currCoupons + ""); // jedis.set(key,value)
            System.out.println("扣减成功,剩余库存:" + currCoupons);
        } else {
            System.out.println("扣减失败,库存不足");
        }
        //----业务处理---end
    }finally {
        stringRedisTemplate.delete(couponKey);
    }
    return "success";
}

这样加上下面这段代码,即使在处理业务过程中宕机,也可以删除key,不用单选宕机,无法删除key了,

//只锁定线程10秒,10秒后自动删除key
stringRedisTemplate.expire(couponKey,10, TimeUnit.SECONDS);

不过万一执行了下面这段代码之后就宕机,那上面这段代码也执行不到,还得想一个办法来处理

Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(couponKey, "lock");

redis set key同时设置有效时间

代码再次改造如下:

@RequestMapping("/get_coupon")
public String deductStock() {
    String couponKey="coupon_100";
    try {
        Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(couponKey, "lock",10,TimeUnit.SECONDS);
        if(!lock){
            return "排队中";
        }
        //----业务处理---begin
        int coupon = Integer.parseInt(stringRedisTemplate.opsForValue().get("coupon_100")); // jedis.get("stock")
        if (coupon > 0) {
            int currCoupons = coupon - 1;
            stringRedisTemplate.opsForValue().set("coupon_100", currCoupons + ""); // jedis.set(key,value)
            System.out.println("扣减成功,剩余库存:" + currCoupons);
        } else {
            System.out.println("扣减失败,库存不足");
        }
        //----业务处理---end
    }finally {
        stringRedisTemplate.delete(couponKey);
    }
    return "success";
}

在redis set key的时候,同时设置有效期

Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(couponKey, "lock",10,TimeUnit.SECONDS);

小王心想这样代码应该就没问题了吧?不过谨慎起见,还得再仔细考虑考虑,结果还真又发现了问题,如下图

image.png 讲解:
当第一个线程进来并加锁10s,但是线程1的业务代码执行时间超过了10s,线程1的锁就会自动失效,这个时候线程2就可以进来加锁,并执行业务代码,当线程2执行业务代码的时候,线程1的业务代码正好执行完成,进行删除锁操作,结果把线程2的锁删掉了,这个时候线程3就可以进来,并且加锁,这样所有的线程锁都不是自己删除,都被前面的线程给提前删除,数据锁也就失去了意义。
小王想,怎么才能让后面的线程无法删除前面线程的说呢?

设置锁的唯一值

于是小王经过深思熟虑对代码又进行了改造,如下

@RequestMapping("/get_coupon")
public String deductStock() {
    String couponKey="coupon_100";
    String lockId= UUID.randomUUID().toString();
    try {
        Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(couponKey, lockId,10,TimeUnit.SECONDS);
        if(!lock){
            return "排队中";
        }
        //----业务处理---begin
        int coupon = Integer.parseInt(stringRedisTemplate.opsForValue().get("coupon_100")); // jedis.get("stock")
        if (coupon > 0) {
            int currCoupons = coupon - 1;
            stringRedisTemplate.opsForValue().set("coupon_100", currCoupons + ""); // jedis.set(key,value)
            System.out.println("扣减成功,剩余库存:" + currCoupons);
        } else {
            System.out.println("扣减失败,库存不足");
        }
        //----业务处理---end
    }finally {
        if(lockId.equals(stringRedisTemplate.opsForValue().get(couponKey))){
            stringRedisTemplate.delete(couponKey);
        }
    }
    return "success";
}

代码解析:
给每个线程都设置一个唯一ID(UUID)。在删除锁的时候,判断一下是不是自己线程的ID,这样就无法删除其他线程的锁了,如下:

if(lockId.equals(stringRedisTemplate.opsForValue().get(couponKey))){
    stringRedisTemplate.delete(couponKey);
}

这样虽然解决了线程只能删除自己的锁,但是业务代码执行时长是不可控的,也就是加锁时间也就无法做到准确,只要锁超时,业务代码就有可能被多个线程同时执行,锁也就无意义了。

锁续命

设想:如果业务代码在无异常,并且仍然在执行的情况下,我们加一个可以自动将锁时间延长的功能,等业务代码执行完毕删除了锁,然后自动延迟锁功能停止运行,这样就可以解决锁超时的问题了。如下图:

image.png

释放锁如何保持原子性

释放锁的时候,万一正好执行到了equals,正执行删除的时候,出现宕机,这样每10s的锁延长就停止运行,然后redis等到30s的时候,锁自动删除,不影响程序继续执行。那如果出现异常,每10s的锁延长还在继续执行,又该如何处理?又是一个问题

finally {
    if(lockId.equals(stringRedisTemplate.opsForValue().get(couponKey))){
        stringRedisTemplate.delete(couponKey);
    }
}

redisson 工具类

上面说到的锁延迟以及删除锁的原子性,它都帮忙做解决了,下面一步一步来简写使用,及源码分析。
redisson官网:
redisson.org/

image.png

springboot集成redisson

pom.xml引入redisson

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

springboot启动初始化redisson

在springboot启动文件中添加如下代码

@SpringBootApplication
public class MyredisApplication {

    public static void main(String[] args) {
        SpringApplication.run(MyredisApplication.class, args);
    }

    @Bean
    public Redisson redisson(){
        Config config=new Config();
        //redis单机使用redisson
        config.useSingleServer().setAddress("redis://192.168.253.131:6379").setDatabase(0);
       /* //redis使用集群
        config.useClusterServers()
                .addNodeAddress("redis://192.168.253.131:8001")
                .addNodeAddress("redis://192.168.253.131:8002")
                .addNodeAddress("redis://192.168.253.132:8003")
                .addNodeAddress("redis://192.168.253.132:8004")
                .addNodeAddress("redis://192.168.253.133:8005")
                .addNodeAddress("redis://192.168.253.133:8006");*/

        return (Redisson) Redisson.create(config);
    }

}

我这边测试使用,就使用单机连接
同时redisson提供了不同类型redis部署的连接方案

image.png

redisson的具体使用

public class CouponController {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Autowired
    private Redisson redisson;

    @RequestMapping("/get_coupon")
    public String deductStock() {
        String couponKey="coupon_100";
        //拿到锁对象
        RLock redissonLock=redisson.getLock(couponKey);
        try {
            //加锁(同时实现加锁,看门狗锁续命的功能)
            redissonLock.lock();
            //----业务处理---begin
            int coupon = Integer.parseInt(stringRedisTemplate.opsForValue().get("coupon_100")); // jedis.get("stock")
            if (coupon > 0) {
                int currCoupons = coupon - 1;
                stringRedisTemplate.opsForValue().set("coupon_100", currCoupons + ""); // jedis.set(key,value)
                System.out.println("扣减成功,剩余库存:" + currCoupons);
            } else {
                System.out.println("扣减失败,库存不足");
            }
            //----业务处理---end
        }finally {
            //解锁
            redissonLock.unlock();
        }
        return "success";
    }
}

以上代码主要添加:

1、将redisson注入进controller

@Autowired
private Redisson redisson;

2、获得redisson锁

//拿到锁对象
RLock redissonLock=redisson.getLock(couponKey);

3、加锁,底层代码同时实现加锁以及看门狗锁续命功能

//拿到锁对象
RLock redissonLock=redisson.getLock(couponKey);

4、释放锁

//解锁
redissonLock.unlock();

这样简单的引入就实现了高并发分布式锁的应用,因为篇幅过长,下篇文章再介绍redisson底层实现代码原理,有兴趣同学可以看一篇文章。