基于 Redis 实现分布式锁

一、分布式锁

1.1 什么是分布式锁

        满足分布式系统或者集群模式下多进程可见并且互斥的锁,如下图:

1.2 分布式锁工作原理

        为了解决集群模式下 synchronized 锁失效的问题,我们需要使用分布式锁,即让多个 JVM 使用同一个锁监视器,如下图:

1.3 实现方式

        分布式锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有三种:

MySQLRedisZookeeper
互斥利用 mysql 本身的互斥锁机制利用 setnx 这样的互斥命令利用节点的唯一性和有序性实现互斥
高可用
高性能一般一般
安全性断开连接,自动释放锁利用锁超时时间,到期释放临时节点,断开连接自动释放

1.4 基于 Redis 的分布式锁

1.4.1 分布式锁初级版本

        使用 redis 实现分布式锁时主要使用下面的两个方法,如下:

        整体的流程图如下所示,首先尝试获取锁,获取锁成功之后,执行业务,最后释放锁。

        需求秒杀:定义一个类,实现下面的接口,利用 redis 实现分布式锁的功能

public interface ILock {
    /**
     * 尝试获取锁
     * @param timeoutSec:锁持有的超时时间,过期后自动释放
     * @return true 表示获取锁成功,false 表示获取锁失败
     */
    boolean tryLock(long timeoutSec);

    /**
     * 释放锁
     */
    void unlock();
}

        实现类如下所示:

public class LockImpl implements ILock {

    // name 表示不同的业务类型
    private String name;
    private static final String KEY_PREFIX = "lock:";

    StringRedisTemplate stringRedisTemplate;

    public LockImpl(String name,StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        // 1、获取线程标识
        long threadId = Thread.currentThread().getId();
        // 定义 key
        String key = KEY_PREFIX+name;
        // 2、获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, threadId+"", timeoutSec, TimeUnit.SECONDS);
        // 避免出现空指针异常
        // 3、返回结果
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        // 释放锁
        stringRedisTemplate.delete(KEY_PREFIX+name);
    }
}

        修改我们的一人一单的业务代码,将我们实现的分布式锁应用进去,代码如下:

    public Result seckillVoucher(Long voucherId) {
        // 1、查询优惠券
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        // 2、判断秒杀是否开始
        if(seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
            return Result.fail("秒杀尚未开始");
        }
        // 3、判断秒杀是否结束
        if(seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){
            return Result.fail("秒杀已经结束");
        }
        // 4、判断库存是否充足
        if(seckillVoucher.getStock() < 1){
            return Result.fail("库存不足");
        }
        // 5、一人一单,以 userId 来加锁,缩小范围
        Long userId = UserHolder.getUser().getId();
       // 创建锁对象
        LockImpl lock = new LockImpl("order:"+userId,stringRedisTemplate);
        // 判断获取锁是否成功
        boolean success = lock.tryLock(1200);
        if(!success){
            // 获取锁失败,返回错误信息
            return Result.fail("不允许重复下单");
        }
        try {
            VoucherOrderServiceImpl proxy = (VoucherOrderServiceImpl)AopContext.currentProxy();
            return  proxy.createVoucherOrder(voucherId);
        } catch (IllegalStateException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }

        继续使用 postman 发送两次请求进行测试(打断点),可以看到,一次请求成功了,一次请求失败了,如下:

1.4.1.1 存在的问题

        我们刚刚实现了基于 redis 的分布式锁的初级版本,在这个版本中,获取锁使用的是 set nx ex 命令来实现互斥,在释放锁的时候我们使用 del 直接把锁删除掉,其他的线程就可以继续获取锁了,实现方式很简单,在大多数的情况下,这个锁都可以正常的工作,但在一些极端的情况下,这个锁依然存在一些问题。

        以下图为例,第一条线表示 redis 锁持有的一个周期,线程一在执行的过程中首先要发起请求去获取锁,因为它是第一个来的,肯定能获取的到。拿到锁之后线程一就去执行自己的业务了,但是由于某种原因,线程一的业务阻塞了,这样一来,线程一锁的持有周期就会变长,这里分为两种情况,第一种是线程一业务执行完了锁释放;第二种是阻塞的时间超过了锁的过期时间,锁被自动释放了。如下图:

        此时线程二就可以趁虚而入,尝试获取锁成功了,然后去执行线程二的业务,就在线程二刚刚获取锁了以后,假设此时线程一业务完成了,线程一直接执行了 del 命令,此时锁被释放了,即线程二的锁被释放了,此时线程二还不知道自己的锁被释放了。如下图:

        就在线程二正在执行自己的业务过程中,线程三来了,它也是趁虚而入,也获取到锁,由于锁被线程一删除了,它也能获取成功,开始执行自己的业务,此时此刻就有两个线程同时都拿到了锁,都在执行业务,又出现了线程安全问题。如下图:

1.4.1.2 问题原因分析

        产生上面这种极端情况的原因是:其一是线程一业务阻塞,导致锁被释放;其二是线程一执行完毕后,把不属于自己的锁给删除了。

        解决方案是当线程一删除锁的时候判断需要删除的锁和获取到的锁的标识是否一致,一致则删除,不一致则什么都不做,这样就可以避免误删别人的锁。

        此时完美的执行顺序,如下图所示:

1.4.1.3 误删问题解决方案

        此时的业务流程就需要发生一些变化,在获取锁的时候存入线程的标识,在释放锁的时候判断下标识是不是自己的,是自己的可以释放,不是自己的不能删除

1.4.2 分布式锁的中级版本

        为了解决分布式锁初级版本存在的问题,修改代码,满足下面的条件

        修改 LockImpl 代码,满足上面的条件,代码如下:

public class LockImpl implements ILock {

    // name 表示不同的业务类型
    private String name;
    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";
    StringRedisTemplate stringRedisTemplate;

    public LockImpl(String name,StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        // 1、获取线程标识,拼接起来
        String threadId = ID_PREFIX+Thread.currentThread().getId();
        // 定义 key
        String key = KEY_PREFIX+name;
        // 2、获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, threadId, timeoutSec, TimeUnit.SECONDS);
        // 避免出现空指针异常
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        // 获取锁中的标识
        String s = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        // 用锁中的标识和当前线程的标识做比较,一样可以删,不一样就不删
        String threadId = ID_PREFIX+Thread.currentThread().getId();
        if(threadId.equals(s)){
            // 释放锁
            stringRedisTemplate.delete(KEY_PREFIX+name);
        }
    }
}

        测试方法:使用 postman 发送两次请求,打断点,让线程一获取锁,然后删除 redis 里面的 key(模拟 key 过期),然后放第二条线程过去,此时线程二会获取锁,并创建一个 key,此时让线程一继续执行,执行结束后会删除 key,然后看线程一是否可以成功的删除 key,答案是删除不掉的,因为锁的标识是线程二创建的,对不上。

1.4.2.1 存在的问题

        我们通过获取锁时存入线程标识,并在删除锁时判断标识是否为自己解决了误删的问题,但在某些极端的情况下还是会存在一些问题。

        以下图为例,第一条线表示 redis 锁持有的一个周期,线程一在执行的过程中首先要发起请求去获取锁,因为它是第一个来的,肯定能获取的到。拿到锁之后线程一就去执行自己的业务了,假设业务并没有阻塞,成功执行完毕后,紧接着就要去释放锁了,先去判断锁标识,肯定是成功的。紧接着就要去执行释放锁的操作。

        需要注意的是:判断锁标识和释放锁是两个动作,判断是成功了,就在要释放时产生了阻塞(比如垃圾回收)

        此时,线程二就趁虚而入了,他去尝试获取锁,因为线程一的锁被超时释放掉了,线程二就可以成功获取,于是线程二开始执行自己的业务,就在线程二获取锁成功的那一刻,假如 GC 结束了,即线程一阻塞结束,线程一就要去执行释放锁的动作了,他认为锁还是自己的,但其实锁是线程二的,所以线程一直接执行释放锁,就把线程二的锁给删掉了,又一次发生了误删操作。如下图:

        此时,又来了一个线程三,获取锁成功,执行自己的任务。这种并发的问题又一次发生了。

1.4.2.2 问题原因分析

        这次产生并发问题的原因是:判断锁标识和释放锁是两个动作,这两个动作之间产生了阻塞,最后出现了问题。要想避免这个问题的发生,必须确保判断锁标识的动作和释放锁这两个操作呈原子性操作。

        那如何保证两个动作的原子性呢?就需要使用 Lua 脚本了。

1.4.3 Lua 脚本

        Redis 提供了 Lua 脚本功能,在一个脚本中编写多条 Redis 命令,确保多条命令执行时的原子性。Lua 是一种编程语言,它的基本语法大家可以参考网站

1.4.3.1 基本语法

        这里重点介绍 Redis 提供的调用函数,语法如下:

        例如,我们要执行 set name jack,则脚本是这样:

        例如,我们要先执行 set name Rose,再执行 get name,则脚本如下:

        写好 lua 脚本,需要用 Redis 命令来调用脚本,调用脚本的常见命令如下:

        例如,我们要执行 redis.call('set', 'name', 'jack') 这个脚本,语法如下:

        如果脚本中的 keyvalue 不想写死,可以作为参数传递。key 类型参数会放入 KEYS 数组,其它参数会放入 ARGV 数组,在脚本中可以从 KEYS ARGV 数组获取这些参数:

1.4.3.2 释放锁流程

        1、获取锁中的线程标示

        2、判断是否与指定的标示(当前线程标示)一致

        3、如果一致则释放锁(删除)

        4、如果不一致则什么都不做

        如果用 Lua 脚本来表示则是这样的:

1.4.4 分布式锁的 Lua 版本

        接下来我们再次改进 Redis 的分布式锁,基于 Lua 脚本实现分布式锁的释放锁逻辑,RedisTemplate 调用 Lua 脚本的 API 如下:

         首先在 idea 中安装 EmmyLua 插件,然后在 resources 目录下创建 lua 文件,内容如下:

-- 比较线程标识和锁中的标识是否一致
if(redis.call('get',KEYS[1]) == ARGV[1]) then
    -- 释放锁 del key
    return redis.call('del',KEYS[1])
end
return 0

        修改 LockImpl unlock() 方法,代码如下:

public class LockImpl implements ILock {

    // name 表示不同的业务类型
    private String name;
    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";
    StringRedisTemplate stringRedisTemplate;

    private static final DefaultRedisScript defaultRedisScript;
    static {
        defaultRedisScript = new DefaultRedisScript();
        // 指定 lua 脚本的位置
        defaultRedisScript.setLocation(new ClassPathResource("unlock.lua"));
        // 设置返回值的类型
        defaultRedisScript.setResultType(Long.class);
    }

    public LockImpl(String name,StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        // 1、获取线程标识,拼接起来
        String threadId = ID_PREFIX+Thread.currentThread().getId();
        // 定义 key
        String key = KEY_PREFIX+name;
        // 2、获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, threadId, timeoutSec, TimeUnit.SECONDS);
        // 避免出现空指针异常
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        // 调用 lua 脚本
        // 一共三个参数,第一个参数在初始化状态下就加载了,第二个擦书为 List 类型的集合,第三个参数为可变参数
        stringRedisTemplate.execute(defaultRedisScript,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX+Thread.currentThread().getId());
    }
}

        测试方法:使用 postman 发送两次请求,打断点,让线程一获取锁,然后删除 redis 里面的 key(模拟 key 过期),然后放第二条线程过去,此时线程二会获取锁,并创建一个 key,此时让线程一继续执行,执行结束后会删除 key,然后看线程一是否可以成功的删除 key,答案是删除不掉的,因为锁的标识是线程二创建的,对不上。

1.4.5 分布式锁的实现思路

1.5 基于 Redis 的分布式锁优化

1.5.1 目前的问题

        基于 setnx 实现的分布式锁存在下面的问题:

        1、不可重入:同一个线程无法多次获取同一把锁

        2、不可重试:获取锁只尝试一次就返回 false,没有重试机制

        3、超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患。

        4、主从一致性:如果 Redis 提供了主从集群,主从同步存在延迟,当主宕机时,如果从节点没有同步主节点中的锁数据,此时其他线程就会再次拿到锁,会出现线程安全问题。

1.5.2 Redisson 简介

        Redisson 是一个在 Redis 的基础上实现的分布式工具的集合。它不仅提供了一系列的分布式的 Java 常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。官网地址在这,工具内容如下图:

1.5.3 Redisson 快速入门

        1、首先引入 maven 依赖,如下:

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

        2、然后配置 Redisson 的配置类,建议使用下面的这种配置,不要和 redis 其他的配置混在一起,代码如下:

@Configuration
public class RedisConfig {

    @Bean
    public RedissonClient redissonClient(){
        // 配置类
        Config config = new Config();
        // 添加 redis 地址,这里添加了单点的地址,也可以使用 config.useClusterServers() 添加集群地址
        config.useSingleServer().setAddress("redis://192.168.229.175:6379").setPassword("123456");
        // 创建客户端
        return Redisson.create(config);
    }
}

        3、使用 Redisson 的分布式锁,代码如下:

    @Resource
    private RedissonClient redissonClient;

    @Test
    void testRedisson() throws InterruptedException {
        // 获取锁(可重入),指定锁的名称
        RLock lock = redissonClient.getLock("anyLock");
        // 尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
        boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
        // 判断释放获取成功
        if (isLock) {
            try {
                System.out.println("执行业务");
            } finally {
                // 释放锁
                lock.unlock();

            }
        }
    }

         4、修改我们之前的代码,使用 RedissonClient 来获取锁,如下:

        使用 jmeter 进行压测,最终存入到数据库就一条数据,没有任何问题。

1.5.4 Redisson 可重入锁原理

        我们自定义的分布式锁的缺陷是无法实现可重入,而 redisson 就可以做到锁重入,那到底是为什么呢?

        我们自定义的分布式锁采用的是 String 的数据类型,整个获取锁的流程如下所示,一开始尝试获取锁是使用 set nx ex 命令,在获取锁的同时存入线程的标识,其目的是将来在释放锁的时候做判断,避免误删。

        我们来看下面的代码,首先创建一个锁的对象,在 method1() 中会尝试获取锁,获取失败报错,获取成功会调用 method2() ,而在 method2() 中又一次尝试获取锁,由于 method1() 去调用 method2() ,它们两个是在同一个线程里面的,即一个线程连续两次去获取锁,这个就是锁的重入了。

        如果按照我们的流程,一开始线程 set lock = thread1,接下来向下执行调用 method2()method2() 又一次尝试获取锁,又要执行 set lock = thread1 操作,由于 method1() 已经 set 完了,所以 method2() 获取锁一定是失败的,所以目前我们的实现方案是没有办法实现重入的。 

      那到底该如何解决这个问题呢?我们可以参考 JDK 里面的 Reentrantlock 的实现原理,即当这个锁有人的时候,看下这个人是不是我自己,即是不是同一个线程,若是同一个线程,也让其获取锁,但是它里面维护了一个计数器,用于统计重入的次数,即总共获取了几次,等到将来释放锁的时候,计数器再减一,这个就是可重入锁的一个基本原理。

        此时我们就需要使用 Hash 这种结构来存储数据,在 key 的位置记录锁的名称,field 的位置记录线程标识,在 value 的位置记录锁的重入次数。

        以上面的例子来说,method1() 第一次执行的时候,我们就以下面的方式存储:

        等到调用 method2() 时,又一次尝试获取锁,首先先判断这个锁是不是有人了,如果有人了,看看这个锁是不是自己的,是自己的,此时只需要把重入的次数加一即可,如下图:

        依次类推,再有重入的就继续累加即可,等到 method2() 执行完业务之后,需要释放锁,此时不能直接删除锁,因为还有其他的线程使用这把锁,此时只能将重入次数减一,如下图:

        等到所有的业务都执行完,重入次数变为 0 了,次数就可以删除锁了,如下图:

        此时整体的架构流程图,如下所示:

        像这种复杂的逻辑,基本上就不能使用 java 代码来实现了,需要通过 lua 脚本来实现以确保获取锁和释放锁的原子性。

        获取锁的 Lua 脚本如下图所示:

        释放锁的 Lua 脚本如下图所示:

1.5.5 Redisson 分布式锁原理

        一开始线程先来尝试获取锁,即执行获取锁的 lua 脚本,执行完脚本之后会返回结果,可能为 nil,也可能为锁剩余的有效期。

        如果获取的结果为 nil,则证明获取锁成功了,此时判断传入的 leaseTime 字段的值是否为 -1,如果不是 -1,则证明获取锁成功了,直接结束。如果为 -1,此时会开启 watchDog 看门狗,不停的去更新你的有效期,直到返回 true 为止。

        如果获取的结果不为 nil,此时就需要判断剩余的有效期是否大于 0,如果小于 0,则证明没有机会了,直接返回 false;如果大于 0,则订阅并等待释放锁的信号,还需要实时的去判断等待时间是否超时,若超时则返回 false,如果没有超时,则再去尝试获取锁。

        释放锁的逻辑就很简单了,一开始尝试释放锁,即执行释放锁的脚本,判断释放锁是否成功,若释放失败,则记录异常,然后结束。如果释放锁成功,此时就需要发送释放锁的消息,因为获取锁失败的人还在等着信号,最后还需要取消 watchDog 看门狗,最后整个释放锁的逻辑结束。

        总结起来就是:

        可重入:利用 hash 结构记录线程 id 和重入次数。

        可重试:利用信号量和 PubSub 功能实现等待、唤醒,获取锁失败的重试机制

        超时续约:利用 watchDog,每隔一段时间(releaseTime / 3),重置超时时间

1.5.6 Redisson 分布式锁主从一致性问题

        首先分析下主从一致性问题产生的原因,如果我们采用的是单节点的 redis,假设它宕机了之后,整个和 redis 有关的业务也就都崩了,所以在实际项目中,我们一般都采用主从的 redis 集群,也会采用读写的分离,即主节点负责写,从节点负责读;主从节点也会做数据的同步,确保数据的一致,如下图:

        但是主从节点毕竟不再同一台机器上,主从同步会有一定的延迟,就是因为这一部分延迟,才会造成主从不一致问题的原因。当主节点宕机,会重新选举一个主节点,此时锁就有可能失效,如下图:

        那么 redisson 是如何解决主从一致性问题呢?不再需要主从节点,全部都是独立的节点,相互之间没有任何的关系,都可以进行读写,此时就需要向所有的节点全部获取锁,如下图:

        Redisson 使用联锁来解决主从一致性问题,代码如下:

@Configuration
public class RedisConfig {

    @Bean
    public RedissonClient redissonClient(){
        // 配置类
        Config config = new Config();
        // 添加 redis 地址,这里添加了单点的地址,也可以使用 config.useClusterServers() 添加集群地址
        config.useSingleServer().setAddress("redis://192.168.229.175:6379");
        // 创建客户端
        return Redisson.create(config);
    }
    @Bean
    public RedissonClient redissonClient2(){
        // 配置类
        Config config = new Config();
        // 添加 redis 地址,这里添加了单点的地址,也可以使用 config.useClusterServers() 添加集群地址
        config.useSingleServer().setAddress("redis://192.168.229.176:6379");
        // 创建客户端
        return Redisson.create(config);
    }
    @Bean
    public RedissonClient redissonClient3(){
        // 配置类
        Config config = new Config();
        // 添加 redis 地址,这里添加了单点的地址,也可以使用 config.useClusterServers() 添加集群地址
        config.useSingleServer().setAddress("redis://192.168.229.177:6379");
        // 创建客户端
        return Redisson.create(config);
    }
}

        使用时的测试代码如下:

    @Resource
    RedissonClient redissonClient1;
    @Resource
    RedissonClient redissonClient2;
    @Resource
    RedissonClient redissonClient3;

    private RLock lock;
    @BeforeEach
    void setUp(){
        RLock lock1 = redissonClient1.getLock("oreder");
        RLock lock2 = redissonClient2.getLock("oreder");
        RLock lock3 = redissonClient3.getLock("oreder");

        // 创建联锁 multiLock
        lock = redissonClient1.getMultiLock(lock1,lock2,lock3);
    }
    @Test
    void method1() throws InterruptedException{
        // 尝试获取锁
        boolean isLock = lock.tryLock(1l, TimeUnit.SECONDS);
        if(!isLock){
            System.out.println("获取锁失败");
            return ;
        }
    }

1.6 总结

不可重入 Redis 分布式锁

        原理:利用 setnx 的互斥性;利用ex避免死锁;释放锁时判断线程标示。

        缺陷:不可重入、无法重试、锁超时失效

可重入的 Redis 分布式锁:

        原理:利用 hash 结构,记录线程标示和重入次数;利用 watchDog 延续锁时间;利用信号量控制锁重试等待。

        缺陷:redis 宕机引起锁失效问题

Redisson multiLock

        原理:多个独立的 Redis 节点,必须在所有节点都获取重入锁,才算获取锁成功。

        缺陷:运维成本高、实现复杂

二、Redis 优化秒杀

2.1 流程分析

        现有的流程图如下所示,前端发起请求到达 nginxnginx 会把请求负载均衡到我们的 tomcat,而在 tomcat 内部的业务流程为串行执行,其中有四步都和数据库有交互,所以秒杀业务的并发能力就比较弱了。

        那我们该如何优化现有流程,提高我们的并发能力呢?我们可以把业务分成两部分,第一部分是对秒杀资格的判断(判断秒杀库存和校验一人一单),这部分耗时比较短,第二部分为减库存和创建订单,它们是对数据库的写操作,所以耗时较久。我们现在需要做的就是把这两部分交给两个线程去做。

        如下图,首先我们将优惠券信息和订单信息缓存到 redis 中,然后把对秒杀资格的判断放到 redis 中,主线程进来后,首先就到 redis 中完成对秒杀资格的判断,判断结束后,若有资格,再去执行后续的减库存和下单操作。

        如果想在 redis 中判断库存是否充足以及一人一单,我们就需要把优惠券的库存信息和相关的订单信息缓存到 redis 中,我们应该选择哪种数据结构来存储呢?

        优惠券的库存比较简单,就是一个数值,使用 String 结构就可以了,如下图:

        如果想要实现一人一单功能,我们就需要在 redis 中记录当前优惠券被哪些用户购买过,以后再有用户来的时候,只需要判断是否存在就可以,可以使用 Set,如下图:

        首先判断库存是否充足,若不足,返回提示信息,若充足,则判断是否符合一人一单,若以前下过单,返回提示信息,若没下过单,则扣减库存,然后将用户 id 存入到 set 集合中,最后返回结果即可,为了确保原子性,上述的这些操作均需要使用 lua 脚本实现,整体的流程如下所示:

2.2 需求描述

        改进秒杀业务,提高并发性能。现有需求如下:

        1、新增秒杀优惠券的同时,将优惠券信息保存到 Redis

        2、基于 Lua 脚本,判断秒杀库存、一人一单,决定用户是否抢购成功

        3、如果抢购成功,将优惠券 id 和用户 id 封装后存入阻塞队列

        4、开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能

2.3 需求实现

        1、新增秒杀优惠券的同时,将优惠券信息保存到 Redis 中,修改 VoucherServiceImpl 代码,代码如下:

    @Override
    @Transactional
    public void addSeckillVoucher(Voucher voucher) {
        // 保存优惠券
        save(voucher);
        // 保存秒杀信息
        SeckillVoucher seckillVoucher = new SeckillVoucher();
        seckillVoucher.setVoucherId(voucher.getId());
        seckillVoucher.setStock(voucher.getStock());
        seckillVoucher.setBeginTime(voucher.getBeginTime());
        seckillVoucher.setEndTime(voucher.getEndTime());
        seckillVoucherService.save(seckillVoucher);

        // 保存秒杀库存到 redis 中
        stringRedisTemplate.opsForValue().set("seckill:stock:"+voucher.getId(),voucher.getStock().toString());
    }

        使用 postman 发送一条数据,如下,然后去 redis 中查看是否存储成功

http://localhost:8081/voucher/seckill/
{
    "shopId":1,
    "title":"100元代金券",
    "subTitle":"周一至周五都可使用",
    "rules":"怎么用都行",
    "payValue":8000,
    "actualValue":10000,
    "type":1,
    "stock":200,
    "beginTime":"2022-01-25T10:09:12",
    "endTime":"2029-01-25T10:09:12"
}

        2、基于 Lua 脚本,判断秒杀库存、一人一单,决定用户是否抢购成功,在 resources 目录下新增一个 seckill.lua 文件,内容如下:

-- 1、参数列表
-- 1.1 优惠券id
local voucherId = ARGV[1]
-- 1.2 用户 id
local userId = ARGV[2]
-- 2、数据 key
-- 2.1 库存 key,.. 用于拼接字符串
local stockKey = 'seckill:stock:'..voucherId
-- 2.2 订单 key
local orderKey = 'seckill:order:'..voucherId

-- 3、脚本业务
-- 3.1 判断库存是否充足,get stockKey
if(tonumber(redis.call('get',stockKey)) <=0) then
    -- 3.2 库存不足,返回1
    return 1
end
-- 3.3 判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember',orderKey,userId) == 1) then
    -- 3.4 存在,说明是重复下单,返回2
    return 2
end
-- 3.5 扣库存 incrby stockKey -1
redis.call('incrby',stockKey,-1)
-- 3.6 下单(保存用户)
redis.call('sadd',orderKey,userId)
-- 3.7 若满足一切条件则返回 0
return 0

        修改秒杀实现类的 service 层代码,如下:

    private static final DefaultRedisScript<Long> defaultRedisScript;
    static {
        defaultRedisScript = new DefaultRedisScript();
        // 指定 lua 脚本的位置
        defaultRedisScript.setLocation(new ClassPathResource("seckill.lua"));
        // 设置返回值的类型
        defaultRedisScript.setResultType(Long.class);
    }
    @Override
    public Result seckillVoucher(Long voucherId) {
        // 1、执行 lua 脚本
        UserDTO user = UserHolder.getUser();
        Long result = stringRedisTemplate.execute(defaultRedisScript,
                Collections.emptyList(),
                voucherId.toString(), user.getId().toString());
        int r = result.intValue();
        // 2、判断结果是否为 0
        if(r != 0){
            // 2.1、不为 0,代表没有购买资格
            return Result.fail(r==1?"库存不足":"不能重复下单");
        }
        // 2.2、为0,有购买资格,把下单的信息保存到阻塞队列当中
        // todo
        // 获取订单 id
        long orderId = redisIdWorker.nextId("order");
        // 3、返回订单 id
        return Result.ok(orderId);
    }

        使用 postman 进行测试,如下图,第一次可以秒杀成功,且 redis 中库存减少了一个,且订单缓存里面增加了一条数据,第二个则提示不能重复下单,证明我们的 lua 脚本写的没有问题

        3、继续修改 VoucherServiceImpl  类的代码,完成需求里面的第三点和第四点,代码如下:

@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    ISeckillVoucherService seckillVoucherService;
    @Resource
    RedisIdWorker redisIdWorker;
    @Resource
    StringRedisTemplate stringRedisTemplate;

    @Resource
    RedissonClient redissonClient;

    private static final DefaultRedisScript<Long> defaultRedisScript;
    static {
        defaultRedisScript = new DefaultRedisScript();
        // 指定 lua 脚本的位置
        defaultRedisScript.setLocation(new ClassPathResource("seckill.lua"));
        // 设置返回值的类型
        defaultRedisScript.setResultType(Long.class);
    }
    // 定义一个数组类型的阻塞队列
    private BlockingQueue<VoucherOrder> orederTask = new ArrayBlockingQueue<>(1024*1024);
    // 定义线程池处理任务
    private ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

    private VoucherOrderServiceImpl proxy;
	
    // 等到类初始化完毕之后执行
    @PostConstruct
    private void init(){
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }
    // 在用户秒杀任务之前就应该开启此方法
    private class VoucherOrderHandler implements  Runnable{

        @Override
        public void run() {
            while(true){
                try {
                    // 1、获取队列中的订单信息
                    VoucherOrder voucherOrder = orederTask.take();
                    // 2、创建订单
                    handleVoucherOrder(voucherOrder);
                } catch (Exception e) {
                  log.error("处理订单异常",e);
                }
            }
        }
    }

    private void handleVoucherOrder(VoucherOrder voucherOrder){
        // 使用 redissonClient 来获取锁
        Long userId = voucherOrder.getUserId();
        RLock lock = redissonClient.getLock("order:" + userId.toString());
        // 判断获取锁是否成功
        boolean success = lock.tryLock();
        if(!success){
            // 获取锁失败,返回错误信息
            log.error("不允许重复下单");
            return ;
        }
        try {
            proxy.createVoucherOrder(voucherOrder);
        } catch (IllegalStateException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
    @Override
    public Result seckillVoucher(Long voucherId) {
        // 1、执行 lua 脚本
        UserDTO user = UserHolder.getUser();
        Long result = stringRedisTemplate.execute(defaultRedisScript,
                Collections.emptyList(),
                voucherId.toString(), user.getId().toString());
        int r = result.intValue();
        // 2、判断结果是否为 0
        if(r != 0){
            // 2.1、不为 0,代表没有购买资格
            return Result.fail(r==1?"库存不足":"不能重复下单");
        }
        // 2.2、为0,有购买资格,把下单的信息保存到阻塞队列当中
        // todo
        // 2.3、创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 2.4 订单ID
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 2.5 用户 ID
        voucherOrder.setUserId(user.getId());
        // 2.6 订单 ID
        voucherOrder.setVoucherId(voucherId);
        // 2.7 放入阻塞队列
        orederTask.add(voucherOrder);
        proxy = (VoucherOrderServiceImpl)AopContext.currentProxy();
        // 3、返回订单 id
        return Result.ok(orderId);
    }
    @Transactional
    public void createVoucherOrder(VoucherOrder voucherOrder){
        // 6.1 查询订单
        Long voucherId = voucherOrder.getVoucherId();
        Long userId = voucherOrder.getUserId();
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 6.2 判断订单是否已经存在
        if(count !=0){
            log.error("用户已经购买过一次");
        }
        // 7、扣减库存
        boolean result = seckillVoucherService.update()
                // 相当于 set stock = stock-1
                .setSql("stock = stock-1")
                // 添加乐观锁,判断库存是否大于0
                // 相当于 where id =? and stock >0
                .gt("stock",0)
                .eq("voucher_id", voucherId).update();
        if(!result){
            log.error("库存不足");
        }
        // 创建订单
        save(voucherOrder);
    }
}

2.4 小结

        秒杀业务的优化思路是什么?先利用 Redis 完成库存余量、一人一单判断,完成抢单业务;再将下单业务放入阻塞队列,利用独立线程异步下单。

        基于阻塞队列的异步秒杀存在哪些问题?第一个问题是内存限制问题,现在的阻塞队列是基于 jdk 的,如果需要创建的对象较多,可能会造成内存溢出;第二个问题是数据安全问题,如果我们的服务突然宕机了,内存里面的数据就都丢失了。

三、Redis 消息队列实现异步秒杀

        为了解决内存限制问题和数据安全问题,我们需要使用消息队列来替代阻塞队列。

3.1 什么是消息队列

        消息队列(Message Queue),字面意思就是存放消息的队列。最简单的消息队列模型包括 3 个角色:

        消息队列:存储和管理消息,也被称为消息代理(Message Broker

        生产者:发送消息到消息队列

        消费者:从消息队列获取消息并处理消息

Redis 提供了三种不同的方式来实现消息队列:

        1、list 结构:基于 List 结构模拟消息队列

        2、PubSub:基本的点对点消息模型

        3、Stream:比较完善的消息队列模型

3.2 List 结构

        基于 List 结构模拟消息队列。Redis list 数据结构是一个双向链表,很容易模拟出队列效果。

        队列是入口和出口不在一边,因此我们可以利用:LPUSH 结合 RPOP、或者 RPUSH 结合  LPOP 来实现。

         不过要注意的是,当队列中没有消息时 RPOP LPOP 操作会返回 null,并不像 JVM 的阻塞队列那样会阻塞并等待消息。因此这里应该使用 BRPOP 或者 BLPOP 来实现阻塞效果。

优点:

        1、利用 Redis 存储,不受限于 JVM 内存上限

        2、基于 Redis 的持久化机制,数据安全性有保证

        3、可以满足消息有序性

缺点:

        1、无法避免消息丢失

        2、只支持单消费者

3.3 PubSub

        PubSub(发布订阅)是 Redis2.0 版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个 channel,生产者向对应 channel 发送消息后,所有订阅者都能收到相关消息。

SUBSCRIBE channel [channel] :订阅一个或多个频道

PUBLISH channel msg :向一个频道发送消息

PSUBSCRIBE pattern[pattern] :订阅与 pattern 格式匹配的所有频道

优点:

        1、采用发布订阅模型,支持多生产、多消费

缺点:

        1、不支持数据持久化

        2、无法避免消息丢失

        3、消息堆积有上限,超出时数据丢失

3.4 Stream

        Stream Redis 5.0 引入的一种新数据类型,可以实现一个功能非常完善的消息队列。

        发送消息的命令 xadd,具体参数内容如下:

        示例如下:

        读取消息的命令 XREAD,具体参数内容如下:

        比如使用 XREAD 读取第一个消息,如下: 

        XREAD 阻塞方式,读取最新的消息,如下:

        在业务开发中,我们可以循环的调用 XREAD 阻塞方式来查询最新消息,从而实现持续监听队列的效果,伪代码如下:

        需要注意的是:当我们指定起始 ID $ 时,代表读取最新的消息,如果我们处理一条消息的过程中,又有超过1条以上的消息到达队列,则下次获取时也只能获取到最新的一条,会出现漏读消息的问题。 

特点:

        1、消息可回溯

        2、一个消息可以被多个消费者读取

        3、可以阻塞读取

        4、有消息漏读的风险

3.5 基于 Stream 的消费者组

        消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列。具备下列特点:

        创建消费者组命令如下:

-- key:队列名称
-- groupName:消费者组名称 
-- ID:起始 ID 标示,$ 代表队列中最后一个消息,0 则代表队列中第一个消息
-- MKSTREAM:队列不存在时自动创建队列
XGROUP CREATE  key groupName ID [MKSTREAM]

        其他常见的命令如下:

-- 删除指定的消费者组
XGROUP DESTORY key groupName

-- 给指定的消费者组添加消费者
XGROUP CREATECONSUMER key groupname consumername

-- 删除消费者组中的指定消费者
XGROUP DELCONSUMER key groupname consumername

        从消费者组读取消息命令如下:

-- group:消费组名称
-- consumer:消费者名称,如果消费者不存在,会自动创建一个消费者
-- count:本次查询的最大数量
-- BLOCK milliseconds:当没有消息时最长等待时间
-- NOACK:无需手动 ACK,获取到消息后自动确认
-- STREAMS key:指定队列名称
-- ID:获取消息的起始ID:
    -- ">":从下一个未消费的消息开始
    -- 其它:根据指定 id 从 pending-list 中获取已消费但未确认的消息,例如0,是从 pending-list 中的第一个消息开始
XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]

        消费者监听消息的基本思路:

STREAM 类型消息队列的 XREADGROUP 命令特点:

        1、消息可回溯

        2、可以多消费者争抢消息,加快消费速度

        3、可以阻塞读取

        4、没有消息漏读的风险

        5、有消息确认机制,保证消息至少被消费一次

3.6 Redis 消息队列对比

        对比的内容如下图:

3.7 代码演示

3.7.1 需求描述

        现有需求如下,基于 Redis Stream 结构作为消息队列,实现异步秒杀下单

        1、创建一个 Stream 类型的消息队列,名为 stream.orders

        2、修改之前的秒杀下单 Lua 脚本,在认定有抢购资格后,直接向 stream.orders 中添加消息,内容包含 voucherIduserIdorderId

        3、项目启动时,开启一个线程任务,尝试获取 stream.orders 中的消息,完成下单

3.7.2 需求实现

        首先打开 redis 的客户端,手动的去创建消息队列,如下图:

        修改秒杀下单的 lua 脚本,接收订单 id 参数,并把消息发送到消息队列中,内容如下:

-- 1、参数列表
-- 1.1 优惠券id
local voucherId = ARGV[1]
-- 1.2 用户 id
local userId = ARGV[2]
-- 1.2 订单 id
local orderId = ARGV[3]
-- 2、数据 key
-- 2.1 库存 key,.. 用于拼接字符串
local stockKey = 'seckill:stock:'..voucherId
-- 2.2 订单 key
local orderKey = 'seckill:order:'..voucherId

-- 3、脚本业务
-- 3.1 判断库存是否充足,get stockKey
if(tonumber(redis.call('get',stockKey)) <=0) then
    -- 3.2 库存不足,返回1
    return 1
end
-- 3.3 判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember',orderKey,userId) == 1) then
    -- 3.4 存在,说明是重复下单,返回2
    return 2
end
-- 3.5 扣库存 incrby stockKey -1
redis.call('incrby',stockKey,-1)
-- 3.6 下单(保存用户)
redis.call('sadd',orderKey,userId)
-- 3.7 发送消息到队列当中,XADD stream.orders * k1 v1 k2 v2
redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,'id',orderId)
-- 3.8 若满足一切条件则返回 0
return 0

        修改秒杀的代码,使其能够实现异步秒杀,代码如下:

@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    ISeckillVoucherService seckillVoucherService;
    @Resource
    RedisIdWorker redisIdWorker;
    @Resource
    StringRedisTemplate stringRedisTemplate;

    @Resource
    RedissonClient redissonClient;

    private static final DefaultRedisScript<Long> defaultRedisScript;
    static {
        defaultRedisScript = new DefaultRedisScript();
        // 指定 lua 脚本的位置
        defaultRedisScript.setLocation(new ClassPathResource("seckill.lua"));
        // 设置返回值的类型
        defaultRedisScript.setResultType(Long.class);
    }
    // 定义线程池处理任务
    private ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

    private VoucherOrderServiceImpl proxy;
    // 等到类初始化完毕之后执行
    @PostConstruct
    private void init(){
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

    private class VoucherOrderHandler implements  Runnable{

        @Override
        public void run() {
            while (true) {
                try {
                    // 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                            StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
                    );
                    // 2.判断订单信息是否为空
                    if (list == null || list.isEmpty()) {
                        // 如果为null,说明没有消息,继续下一次循环
                        continue;
                    }
                    // 解析数据
                    MapRecord<String, Object, Object> record = list.get(0);
                    Map<Object, Object> value = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                    // 3.创建订单
                    createVoucherOrder(voucherOrder);
                    // 4.确认消息 XACK
                    stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
                } catch (Exception e) {
                    log.error("处理订单异常", e);
                    handlePendingList();
                }
            }
        }
        private void handlePendingList() {
            while (true) {
                try {
                    // 1.获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 0
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1),
                            StreamOffset.create("stream.orders", ReadOffset.from("0"))
                    );
                    // 2.判断订单信息是否为空
                    if (list == null || list.isEmpty()) {
                        // 如果为null,说明没有异常消息,结束循环
                        break;
                    }
                    // 解析数据
                    MapRecord<String, Object, Object> record = list.get(0);
                    Map<Object, Object> value = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                    // 3.创建订单
                    createVoucherOrder(voucherOrder);
                    // 4.确认消息 XACK
                    stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
                } catch (Exception e) {
                    log.error("处理订单异常", e);
                }
            }
        }
    }
    private void handleVoucherOrder(VoucherOrder voucherOrder){
        // 使用 redissonClient 来获取锁
        Long userId = voucherOrder.getUserId();
        RLock lock = redissonClient.getLock("order:" + userId.toString());
        // 判断获取锁是否成功
        boolean success = lock.tryLock();
        if(!success){
            // 获取锁失败,返回错误信息
            log.error("不允许重复下单");
            return ;
        }
        try {
            proxy.createVoucherOrder(voucherOrder);
        } catch (IllegalStateException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
    @Override
    public Result seckillVoucher(Long voucherId) {
        // 1、执行 lua 脚本
        UserDTO user = UserHolder.getUser();
        // 2 订单ID
        long orderId = redisIdWorker.nextId("order");
        Long result = stringRedisTemplate.execute(defaultRedisScript,
                Collections.emptyList(),
                voucherId.toString(), user.getId().toString(),String.valueOf(orderId));
        int r = result.intValue();
        // 3、判断结果是否为 0
        if(r != 0){
            // 3.1、不为 0,代表没有购买资格
            return Result.fail(r==1?"库存不足":"不能重复下单");
        }
        proxy = (VoucherOrderServiceImpl)AopContext.currentProxy();
        // 4、返回订单 id
        return Result.ok(orderId);
    }

    @Transactional
    public void createVoucherOrder(VoucherOrder voucherOrder){
        // 6.1 查询订单
        Long voucherId = voucherOrder.getVoucherId();
        Long userId = voucherOrder.getUserId();
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 6.2 判断订单是否已经存在
        if(count !=0){
            log.error("用户已经购买过一次");
        }
        // 7、扣减库存
        boolean result = seckillVoucherService.update()
                // 相当于 set stock = stock-1
                .setSql("stock = stock-1")
                // 添加乐观锁,判断库存是否大于0
                // 相当于 where id =? and stock >0
                .gt("stock",0)
                .eq("voucher_id", voucherId).update();
        if(!result){
            log.error("库存不足");
        }
        // 创建订单
        save(voucherOrder);
    }
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

快乐的小三菊

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值