【Redis学习08】Redis消息队列实现异步秒杀

文章目录

    • 1. 消息队列
      • 1.1 基于List结构模拟消息队列
      • 1.2 基于PubSub的消息队列
      • 1.3 基于Stream的消息队列
    • 2. 基于Stream的消息队列---消费者组
      • 2.1 消费者组介绍
      • 2.2 消费者监听消息基本思路
      • 2.3 消费者组总结
    • 3. 基于Stream的消息队列--消费者组实现异步秒杀
      • 3.1 需求分析
      • 3.2 代码实现
        • 3.2.1 创建Stream类型的消息队列
        • 3.2.2 编写用户下单资格的lua脚本
        • 3.2.3 实现异步秒杀完整代码

1. 消息队列

【Redis学习08】Redis消息队列实现异步秒杀_第1张图片
【Redis学习08】Redis消息队列实现异步秒杀_第2张图片

1.1 基于List结构模拟消息队列

【Redis学习08】Redis消息队列实现异步秒杀_第3张图片
【Redis学习08】Redis消息队列实现异步秒杀_第4张图片

1.2 基于PubSub的消息队列

【Redis学习08】Redis消息队列实现异步秒杀_第5张图片

【Redis学习08】Redis消息队列实现异步秒杀_第6张图片
【Redis学习08】Redis消息队列实现异步秒杀_第7张图片

1.3 基于Stream的消息队列

【Redis学习08】Redis消息队列实现异步秒杀_第8张图片
【Redis学习08】Redis消息队列实现异步秒杀_第9张图片
【Redis学习08】Redis消息队列实现异步秒杀_第10张图片

【Redis学习08】Redis消息队列实现异步秒杀_第11张图片
既然上述三种消息队列都有其不可避免的缺点,那我们有没有办法解决呢?

接下来我们介绍的基于Stream的消息队列—消费者组就能解决上述三种消息队列的弊端。

2. 基于Stream的消息队列—消费者组

2.1 消费者组介绍

【Redis学习08】Redis消息队列实现异步秒杀_第12张图片
【Redis学习08】Redis消息队列实现异步秒杀_第13张图片
【Redis学习08】Redis消息队列实现异步秒杀_第14张图片

2.2 消费者监听消息基本思路

我们分析一下下面的伪代码:

首先我们尝试监听消息队列,如果消息队列没有消息,则continue结束本次循环,重试获取信息。如果一直没有消息,则进入阻塞状态,直到消息队列存入消息。

当消息队列有消息后,就开始处理消息,处理完消息后一定要执行ACK命令确认消息已经执行

如果处理消息出现异常,则将消息存入pendingList(待处理)队列,程序执行过程中会尝试冲待处理队列中取消息,如果待处理队列没有数据,直接退出异常处理的循环,反之就处理异常信息。
【Redis学习08】Redis消息队列实现异步秒杀_第15张图片

2.3 消费者组总结

【Redis学习08】Redis消息队列实现异步秒杀_第16张图片

【Redis学习08】Redis消息队列实现异步秒杀_第17张图片

3. 基于Stream的消息队列–消费者组实现异步秒杀

3.1 需求分析

【Redis学习08】Redis消息队列实现异步秒杀_第18张图片

3.2 代码实现

3.2.1 创建Stream类型的消息队列

通过redis客户端创建名为stream.orders的消息队列
【Redis学习08】Redis消息队列实现异步秒杀_第19张图片

3.2.2 编写用户下单资格的lua脚本

用户下单资格的lua脚本我们可以在之前的基础上进行修改,添加订单id,最后将用户id,优惠券id,订单id添加到消息队列

-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单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.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
    -- 3.3.存在,说明是重复下单,返回2
    return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0

3.2.3 实现异步秒杀完整代码

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

    @Autowired
    private ISeckillVoucherService seckillVoucherService;
    @Autowired
    private RedisIdWorker RedisIdWorker;
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private RedissonClient redissonClient;

    private static final DefaultRedisScript<Long> SECKILL_SCRIPT;

    static {
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        SECKILL_SCRIPT.setResultType(Long.class);
    }



    //定义一个线程池,异步执行下单操作
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

    //spring提供的PostConstruct注解:类初始化完毕就执行
    @PostConstruct
    public void init(){
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderRunnable());
    }

    //定义处理秒杀的线程,该线程应该在类初始化就应该开始执行任务————如何做到?
    //使用spring提供的PostConstruct注解:类初始化完毕就执行
    private class VoucherOrderRunnable implements Runnable{
        @Override
        public void run() {
            String queueName = "stream.orders";
            while (true){
                try {
                    //1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAM  >
                    //从消息队列中读消息,g1组,消费者c1,一次读一个,阻塞时间2秒 ,从stream.orders队列读,>未消费的消息
                    List<MapRecord<String, Object, Object>> list = redisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                            StreamOffset.create(queueName, ReadOffset.lastConsumed())
                    );
                    //2.判断消息队列是否为空
                    if(list==null||list.isEmpty()){
                        //如果为空,则说明没有消息,进行下一次循环
                        continue;
                    }
                   //解析消息
                    //因为我们一次读一个,所以索引为0,而我们存入消息队列的是键值对,因此解析出来是map
                    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 stream.orders,g1,id
                    redisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());

                } catch (Exception e) {
                    log.error("处理订单异常",e);
                    handlePendingList();
                }

            }
        }

        private void handlePendingList() {
            String queueName = "stream.orders";
            while (true){
                try {
                    //1.获取PendingList中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAM  0
                    //从消息队列中读消息,g1组,消费者c1,一次读一个,阻塞时间2秒 ,
                    List<MapRecord<String, Object, Object>> list = redisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1),
                            StreamOffset.create(queueName, ReadOffset.from("0"))
                    );
                    //2.判断消息队列是否为空
                    if(list==null||list.isEmpty()){
                        //如果为空,则说明PendingList没有消息,结束循环
                        break;
                    }
                    //解析消息
                    //因为我们一次读一个,所以索引为0,而我们存入消息队列的是键值对,因此解析出来是map
                    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 stream.orders,g1,id
                    redisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());

                } catch (Exception e) {
                    log.error("处理订单异常",e);
                }

            }
        }
    }

    private void createVoucherOrder(VoucherOrder voucherOrder) {
        Long userId = voucherOrder.getUserId();
        Long voucherId = voucherOrder.getVoucherId();
        RLock lock = redissonClient.getLock("lock:order" + userId);
        //tryLock的三个参数:最大等待时间,锁释放时间,时间单位
        boolean flag = lock.tryLock();//不设置参数默认不等待,释放时间三十秒
        if(!flag){
            return;
        }

        try {
            //一人一单
            int count = this.query().eq("user_id", userId)
                    .eq("voucher_id", voucherId).count();
            if(count>0){
                return ;
            }


            //当更新时查询的库存大于0时进行库存减一
            boolean success = seckillVoucherService.update()
                    .setSql("stock=stock-1")
                    .gt("stock", 0)
                    .eq("voucher_id", voucherId).update();

            if (!success) {
                return;
            }

            //6. 创建订单
            this.save(voucherOrder);

            return ;
        } finally {
            lock.unlock();
        }
    }
    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {

        Long userId = UserHolder.getUser().getId();
        long voucherOrderId = RedisIdWorker.nextId("order");

        //使用lua脚本执行原子级别的操作,不会因为线程阻塞导致释放锁发生错误。
        Long result = redisTemplate.execute(SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString(),String.valueOf(voucherOrderId));

        //拆箱
        int res = result.intValue();

        //1. 判断库存是否大于0和用户是否已经下单
        if (res != 0) {
            return Result.fail(res == 1 ? "库存不足" : "用户已下单");
        }

        return Result.ok(voucherOrderId);
    }
}

读取消息队列中的消息使用的方法是lastConsumed,也就是从未消费的第一个消息开始消费。
【Redis学习08】Redis消息队列实现异步秒杀_第20张图片

而读取待处理队列中的消息是从队列第一个开始
【Redis学习08】Redis消息队列实现异步秒杀_第21张图片

用户下单到提示用户下单成功只需要经过下面的程序,比之前同步执行下单方法的速度快了不少。我们可以使用Jmeter进行响应时间的测试。
【Redis学习08】Redis消息队列实现异步秒杀_第22张图片
到这里,我们这一部分通过优惠券秒杀介绍了好多内容:

  • 全局唯一ID生成器
  • 实现优惠券秒杀下单
  • 超卖问题如何解决
  • 一人一单如何控制
  • 从解决一人一单的悲观锁到分布式锁
  • 使用阻塞队列实现异步秒杀
  • 使用消息队列实现异步秒杀

这一部分到这里就算结局了,看到这里的小伙伴不妨好好回顾一下每一部分是如何完成的,我们又是如何进行优化的。

念念不忘,必有回响!!!

你可能感兴趣的:(Redis,redis,学习,java)