黑马点评学习笔记(二)

优惠卷秒杀

1.全局唯一ID

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:唯一性、高可用、高性能、递增性、安全性。

为了增加ID的安全性,我们不直接使用redis的自增的数值,而是拼接时间戳和序列号(就是redis在一个键上一直自增出来的,最大是2的64次方),第一位时符号位。

在这里插入图片描述

redis实现全局唯一ID

//redis实现全局唯一ID
@Component
public class RedisIdWorder {

    //2023年7月19日16点15分的时间戳
    private static final long BEGIN_TIMESTAMP = 1689783300L;
    //序列号长度
    private static final int COUNT_BITS = 32;

    @Resource
    private StringRedisTemplate stringRedisTemplate;


    //传入业务,生成id
    public long nextId(String keyPrefix){
        // 1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        //将当前时间根据时区转换成秒数
        long timestamp = now.toEpochSecond(ZoneOffset.UTC) - BEGIN_TIMESTAMP;
        // 2.生成序列号
        //获取当前日期的格式化,并按redis分层,方便统计每一天的订单量
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //在指定key上一直自增,并把自增的数作为序列号,自增标识+业务+日期
        Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
        // 3.拼接,由于long是64位,我们第一位是符号位,不管,只要是正数就行,接着是31位的时间戳,剩下32位是序列号
        // 我们在二进制上把时间戳往前移动32位然后或运算序列号,就可以拼接起来了
        return timestamp << COUNT_BITS | count;
    }
}

全局唯一ID生成策略
在这里插入图片描述

2.实现优惠卷秒杀下单

优惠卷中与普通优惠卷,也有秒杀优惠卷,点击秒杀优惠卷,会把优惠卷id传给我们,由于优惠卷主键id是是秒杀卷voucherId的外键,所以我们根据秒杀卷voucherId查询秒杀卷完成相应业务。

    //传入秒杀优惠卷id,抢购
    @Override
    @Transactional//涉及两张表的操作,添加事务
    public Result secKillVoucher(Long voucherId) {

        // 1. 查询秒杀优惠卷
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);

        // 2. 判断优惠卷秒杀时间是否开始、是否结束
        // 2.1 秒杀未开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
            return Result.fail("秒杀未开始!");
        }
        // 2.1 秒杀已结束
        if (LocalDateTime.now().isAfter(voucher.getEndTime())){
            return Result.fail("秒杀已结束!");
        }

        // 3. 查询库存是否充足

        // 3.1 库存不充足
        if (voucher.getStock()<1){
            return Result.fail("库存不足!");
        }

        // 3.2 库存充足
        // 3.2.1 扣减库存
        boolean success = seckillVoucherService.lambdaUpdate().setSql("stock = stock - 1")
                .eq(SeckillVoucher::getVoucherId, voucherId).update();
        // 3.2.1.1 扣减库存失败
        if (!success) {
            return Result.fail("库存不足!");
        }
        // 3.2.2 优惠卷下单
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorder.nextId("order");//生成订单id
        Long userId = UserHolder.getUser().getId();//获取用户id

        voucherOrder.setId(orderId);//设置订单id
        voucherOrder.setUserId(userId);//设置用户id
        voucherOrder.setVoucherId(voucherId);//设置优惠卷id

        boolean save = save(voucherOrder);//保存订单

        // 3.2.2.1 保存订单失败
        if (!save) {
            return Result.fail("下单失败!");
        }

        // 4. 返回订单id
        return Result.ok(orderId);
    }
3.超卖问题

超卖问题是典型的多线程安全问题,针对这一问题的解决方案就是加锁,锁有悲观锁和乐观锁。

乐观锁有两种方案(思想类似):1.版本号法。2.CAS法
在这里插入图片描述

扣减库存时添加乐观锁

// 3.2.1 扣减库存
boolean success = seckillVoucherService.lambdaUpdate()
    .setSql("stock = stock - 1")
    .eq(SeckillVoucher::getVoucherId, voucherId)
    .gt(SeckillVoucher::getStock,0)//添加乐观锁,保证线程安全问题
    .update();
4.一人一单
  • 这个只能在单体应用实现,如果部署应用集群,由于多个jvm导致锁无法唯一,会失败,集群需要分布式锁。
  • 每个jvm维护了一个锁监视器,当某个线程拿到锁,jvm就会把该线程名放入锁监视器,其他线程就无法获得锁。
  1. 添加依赖,需要获取优惠卷订单的代理对象,这样事务才能生效
	<!--aop包-->
    <dependency>
        <groupId>org.aspectj</groupId>
        <artifactId>aspectjweaver</artifactId>
    </dependency>
  1. 启动类暴露代理对象,方便获取
@EnableAspectJAutoProxy(exposeProxy = true)//暴露代理对象,方便我们获取
  1. 添加逻辑判断一人一单(乐观锁)
        //根据用户id和优惠卷id查询订单数
        Integer count = lambdaQuery().eq(VoucherOrder::getUserId, userId)
                .eq(VoucherOrder::getVoucherId, voucherId).count();
        // 订单数大于0,返回异常
        if (count > 0) {
            return Result.fail("一人限购一单!");
        }
  1. 先提交事务然后释放锁(锁用户),解决多线程并发问题(悲观锁)。
    //传入秒杀优惠卷id,抢购
    @Override
    public Result secKillVoucher(Long voucherId) {

        // 1. 查询秒杀优惠卷
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);

        // 2. 判断优惠卷秒杀时间是否开始、是否结束
        // 2.1 秒杀未开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
            return Result.fail("秒杀未开始!");
        }
        // 2.1 秒杀已结束
        if (LocalDateTime.now().isAfter(voucher.getEndTime())){
            return Result.fail("秒杀已结束!");
        }

        // 3. 查询库存是否充足

        // 3.1 库存不充足
        if (voucher.getStock()<1){
            return Result.fail("库存不足!");
        }

        // 一人一单,根据用户id和优惠卷id查询订单
        Long userId = UserHolder.getUser().getId();//获取用户id

        //先提交事务,再释放锁,避免查询不一致问题
        //悲观锁,锁用户,相同的用户id只能拿到一把锁,加了intern()可以确保在常量池里拿,只要有值相等的用户id,就拿不到锁
        synchronized (userId.toString().intern()){
            //获取优惠卷订单的代理对象,因为事务需要使用代理对象才能生效
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId, userId);
        }
    }

    @Transactional//涉及两张表的操作,添加事务
    public Result createVoucherOrder(Long voucherId, Long userId) {
        Integer count = lambdaQuery().eq(VoucherOrder::getUserId, userId)
                .eq(VoucherOrder::getVoucherId, voucherId).count();
        // 订单数大于0,返回异常
        if (count > 0) {
            return Result.fail("一人限购一单!");
        }

        // 3.2 库存充足
        // 3.2.1 扣减库存
        boolean success = seckillVoucherService.lambdaUpdate()
                .setSql("stock = stock - 1")
                .eq(SeckillVoucher::getVoucherId, voucherId)
                .gt(SeckillVoucher::getStock,0)//添加乐观锁,保证线程安全问题
                .update();
        // 3.2.1.1 扣减库存失败
        if (!success) {
            return Result.fail("库存不足!");
        }
        // 3.2.2 优惠卷下单
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorder.nextId("order");//生成订单id

        voucherOrder.setId(orderId);//设置订单id
        voucherOrder.setUserId(userId);//设置用户id
        voucherOrder.setVoucherId(voucherId);//设置优惠卷id

        boolean save = save(voucherOrder);//保存订单

        // 3.2.2.1 保存订单失败
        if (!save) {
            return Result.fail("下单失败!");
        }

        // 4. 返回订单id
        return Result.ok(orderId);
    }
5.分布式锁
一、概念

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

一般分布式锁具有这些特征:多进程可见、互斥、高可用、高性能、安全性等
在这里插入图片描述

二、基于redis实现分布式锁基础代码

分布式锁接口

//分布式锁接口(可以采用不同的技术来实现分布式锁)
public interface ILock {
    /**
     * 尝试获取锁
     *
     * @param timeoutSec 锁持有的超时时间,过期后自动释放
     * @return true代表获取锁成功; false代表获取锁失败
     */
    boolean tryLock(long timeoutSec);

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

分布式锁接口实现类

//基于redis实现分布式锁(原理:使用setnx设置的键只能设置一次)
public class SimpleRedisLock implements ILock{

    private String name;//使用分布式锁的业务名称,也就是待设置的键
    private StringRedisTemplate stringRedisTemplate;//操作redis
    private static final String KEY_PREFIX = "lock:";//拼接前缀名

    //实现类由外部调用者传入必要参数
    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    //获取锁
    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标识
        long threadId = Thread.currentThread().getId();
        //设置互斥值(相同的键只能设置一次)
        Boolean ifAbsent = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);//设置键值,以及超时剔除
        //如果ifAbsent为null,自动拆箱时会发生空指针异常,所以我们使用这种方式来判断
        return Boolean.TRUE.equals(ifAbsent);
    }

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

使用分布式锁代替synchronized锁

        //获取锁对象(钥匙带上用户id,相当于只锁相同用户的id)
        SimpleRedisLock simpleRedisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
        //获取锁
        boolean islock = simpleRedisLock.tryLock(1200);//这里超时剔除时间为1200秒,方便我们打断点调试
        //获取失败
        if (!islock) {
            return Result.fail("一个用户只能购买一单");
        }

        /**
         * 基于redis实现基础版的分布式锁,用户订单数大于零就无法下单,
         * 在多线程并发访问情况下,一瞬间打大量线程查询到订单数没有大于零,然后都执行下单业务,这样明显不行。
         * 所以我们使用悲观锁,当一个相同用户线程来时,就获取锁,然后相同用户其他线程就获取锁失败。
         * 即使后面释放锁了,相同用户又来下单,也没事,因为订单数已经大于零了
         * 锁住同一个用户的多个线程,只允许一个线程成功,实现一人一单
         */

        //先提交事务,然后释放锁(这样当用户下单了,相同用户就无法下单了)
        try {
            //获取优惠卷订单的代理对象,因为事务需要使用代理对象才能生效
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId, userId);
        } finally {
            //无论成功与否,最终都会释放锁
            simpleRedisLock.unlock();
        }
三、使用线程标识解决分布式锁误删问题

问题原因:一个线程阻塞了,锁由于超时剔除,其他线程获取到了锁,这个时候原来线程又醒了,手动释放锁但是这个时候锁不是自己的了,就把别人的锁给释放了,然后就导致其他线程获取到了锁

  1. 获取分布式锁时设置线程标识
	......
        
    private static final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";//每个线程生成一个uuid来标识,解决误删锁的情况

	......

    //获取锁
    @Override
    public boolean tryLock(long timeoutSec) {
        // 拼接线程标识UUID+线程id(只用线程id不行,会发生误删锁的情况,每个jvm里的线程id都是各自递增的,有几率出现相同的线程id)
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        //设置互斥值(相同的键只能设置一次)
        Boolean ifAbsent = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);//设置键值,以及超时剔除
		......
    }
  1. 释放分布式锁时检查标识
    //释放锁(释放锁之前需要根据线程标识来判断是不是自己的锁)
    @Override
    public void unlock() {
        //取出原来的线程标识
        String threadId = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        //拼接自己线程的线程标识
        String threadId2 = ID_PREFIX + Thread.currentThread().getId();
        //线程标识相同的情况下,说明时自己的锁,释放锁,反之不是自己的锁,不做任何处理
        if (threadId.equals(threadId2)){
            //直接删除锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }
四、使用Lua脚本解决多条命令原子性问题
  • 由于判断线程标识和释放锁不具有原子性,分布式锁依然有误删问题

  • 问题原因:一个线程执行完业务,判断完线程标识是自己的,准备释放锁时,线程阻塞了,这个时候其他线程获取到了锁,然后原来线程又恢复了,由于已经判断了线程标识,所以可以直接释放锁,但是这个时候锁已经不是自己的了,又发生误删问题。

Lua脚本改写业务逻辑(判断线程标识+释放锁)

-- lua脚本,保证redis中这两个操作的原子性
-- 1.判断线程标识是否相等
-- 2.释放锁

-- 获取key
local key = KEYS[1]
-- 获取原来的线程标识
local threadId = ARGV[1]

-- 比较标识是否相等
-- 标识相等,释放锁,成功返回1
if (redis.call('get', key) == threadId) then
    return redis.call('del', key)
end
-- 标识不相等,返回0
return 0

代码改动

//基于redis实现分布式锁(原理:使用setnx设置的键只能设置一次)
public class SimpleRedisLock implements ILock {

	......
        
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;//lua脚本对象,泛型是返回值类型

    //静态变量在静态代码块里初始化
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unLock.lua"));//加载Lua脚本
        UNLOCK_SCRIPT.setResultType(Long.class);//设置返回值类型
    }

	......

    //释放锁(释放锁之前需要根据线程标识来判断是不是自己的锁)
    @Override
    public void unlock() {
        //lua脚本检验是不是自己的锁,如果是就释放,两个操作具有原子性
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().getId());
    }
}

在这里插入图片描述

到此,这个分布式锁基本可以用于生产环境。

五、基于Redis分布式锁的优化

虽然咱们上面实现的分布式锁可以基本使用,但是在不同的业务场景下,依然有一些问题,比如:

  • 不可重入:同一个线程无法多次获取同一把锁
  • 不可重试:获取锁只尝试一次就返回false,没有重试机制
  • 超时释放:锁超时释放虽然可以避免死锁,但是如果业务执行耗时较长,也会导致锁释放,存在安全隐患。
  • 主从一致性:如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并未同步主中的锁数据,则会出现锁失效。

考虑这些业务场景,我们手动实现比较困难,所以我们可以采用组件Redisson。

一、Redisson简介

Redisson不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现,它就是一个集成了很多分布式解决方案的一个框架,比如分布式锁等。

二、Redisson入门

导入依赖

<!--使用Redisson实现分布式锁-->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.22.1</version>
</dependency>

编写配置类

//Redisson配置类
@Configuration
public class RedissonConfig {

    //创建一个Redisson客户端,后续使用分布式锁,都使用这个客户端
    @Bean
    public RedissonClient redissonClient(){
        // 创建配置对象
        Config config = new Config();
        // 设置配置参数
        config.useSingleServer().setAddress("redis://localhost:6379").setPassword("123456");
        // 创建Redisson客户端
        return Redisson.create(config);
    }
}

获取锁对象时,使用Redisson获取分布式锁,不在获取我们自定义的分布式锁。

//使用Redisson获取分布式锁对象
RLock lock = redissonClient.getLock("order:" + userId);
//获取锁
boolean islock = lock.tryLock();//采用无参构造,默认失败不重试,30超时剔除
三、Redisson可重入锁原理
  • Redisson无参那个锁就是可重入的。采用无参构造,默认失败不重试,看门狗30超时剔除。
  • 使用哈希结构+计数(获取锁加一,释放锁减一)。
四、Redisson的锁重试和WatchDog机制
  • 传入等待时间,就可以重试,使用了看门狗刷新锁的过期时间。
五、Redisson的multiLock原理
  • 连锁,多个Redis主节点,然后每个主节点各自有一些从节点,相当于多个主从集群,提高了可用性。

  • 获取锁必须每个Redis主节点都拿到锁,才能获取成功,解决主从集群锁丢失问题。

在这里插入图片描述

6.Redis优化秒杀
一、优化思路
  1. 先利用Redis完成库存余量、一人一单判断,完成抢单业务

  2. 再将下单业务放入阻塞队列,利用独立线程异步下单

二、基于阻塞队列的异步秒杀存在哪些问题?

内存限制问题

数据安全问题

三、代码实现
  1. 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
//保存优惠卷信息到redis中(键是秒杀库存前缀+优惠卷id,值是优惠卷库存)
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());
  1. 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
  • 编写lua脚本
-- 秒杀优惠卷下单脚本

-- 1.获取参数
-- 获取优惠卷id
local voucherId = ARGV[1]
-- 获取用户id
local userId = ARGV[2]

-- 2.判断是否有下单资格
-- 根据优惠卷id查询优惠卷库存是否充足(redis中的键值对都是String类型)
if (tonumber(redis.call('get', 'seckill:stock:' .. voucherId)) <= 0) then
    -- 库存不足
    return 1
end
-- 根据优惠劵id的集合判断是否含有用户id
if (redis.call('sismember', 'seckill:order:' .. voucherId, userId) == 1) then
    -- 已经购买过
    return 2
end

-- 3.可以下单购买
-- 扣减库存
redis.call('incrby', 'seckill:stock:' .. voucherId, -1)
-- 加入用户id
redis.call('sadd', 'seckill:order:' .. voucherId, userId)
return 0

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

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

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

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdWorder redisIdWorder;

    @Resource
    private RedissonClient redissonClient;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    private static final DefaultRedisScript<Long> SECKILL_SCRIPT;//lua脚本对象,泛型是返回值类型

    //静态变量在静态代码块里初始化
    static {
        SECKILL_SCRIPT = new DefaultRedisScript();
        SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));//加载Lua脚本
        SECKILL_SCRIPT.setResultType(Long.class);//设置返回值类型
    }

    //初始化一个阻塞队列
    private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
    //创建一个单线程池
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

    //这个注解标记的方法会在类初始化完成后执行
    @PostConstruct
    public void init() {
        SECKILL_ORDER_EXECUTOR.submit(new voucherOrderHandler());//类初始化完成后,线程提交任务
    }


    //内部类实现Runnable接口,定义任务
    private class voucherOrderHandler implements Runnable {

        @Override
        public void run() {
            while (true) {
                try {
                    // 1. 获取订单信息
                    //一直取队列中的订单,如果没有订单会阻塞在这里
                    VoucherOrder voucherOrder = orderTasks.take();

                    // 2. 创建订单
                    handleVoucherOrder(voucherOrder);
                } catch (Exception e) {
                    log.error("处理订单异常!", e);
                }
            }
        }
    }

    //异步创建订单
    private void handleVoucherOrder(VoucherOrder voucherOrder) {
        //由于异步,所以无法从原来线程获取用户id
        Long userId = voucherOrder.getUserId();

        //使用Redisson获取分布式锁对象
        RLock lock = redissonClient.getLock("order:" + userId);
        //获取锁
        boolean islock = lock.tryLock();//采用无参构造,默认失败不重试,30超时剔除
        //获取失败
        if (!islock) {
            log.error("一个用户只能购买一单!");
            return;
        }

        /**
         * 基于redis实现基础版的分布式锁,用户订单数大于零就无法下单,
         * 在多线程并发访问情况下,一瞬间打大量线程查询到订单数没有大于零,然后都执行下单业务,这样明显不行。
         * 所以我们使用悲观锁,当一个相同用户线程来时,就获取锁,然后相同用户其他线程就获取锁失败。
         * 即使后面释放锁了,相同用户又来下单,也没事,因为订单数已经大于零了
         * 锁住同一个用户的多个线程,只允许一个线程成功,实现一人一单
         */

        //先提交事务,然后释放锁(这样当用户下单了,相同用户就无法下单了)
        try {
            proxy.createVoucherOrder(voucherOrder);
        } finally {
            //无论成功与否,最终都会释放锁
            lock.unlock();
        }
    }

    //代理对象提成成员变量,方便异步线程获取
    IVoucherOrderService proxy;

    //异步秒杀优惠卷
    @Override
    public Result secKillVoucher(Long voucherId) {
        // 1.获取用户id
        Long userId = UserHolder.getUser().getId();
        // 2.执行lua脚本
        Long res = stringRedisTemplate.execute(
                //redis中的键值对都是String类型,所以使用lua脚本查询时也需要字符串,所以我们要传递字符串
                SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString()
        );
        // 3.判断是否含有下单资格
        // 没有资格
        if (res != 0) {
            return Result.fail(res == 1 ? "库存不足" : "一人限购一单");
        }

        // 4. 有下单资格,成功就将订单信息加入阻塞队列
        // 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorder.nextId("order");// 生成订单id
        voucherOrder.setId(orderId);//设置订单id
        voucherOrder.setUserId(userId);//设置用户id
        voucherOrder.setVoucherId(voucherId);//设置优惠卷id

        //添加订单到阻塞队列
        orderTasks.add(voucherOrder);

        //获取优惠卷订单的代理对象,因为事务需要使用代理对象才能生效
        proxy = (IVoucherOrderService) AopContext.currentProxy();

        // 5.返回订单id
        return Result.ok(orderId);
    }

    @Transactional//涉及两张表的操作,添加事务
    public void createVoucherOrder(VoucherOrder voucherOrder) {
        //获取订单id
        Long voucherId = voucherOrder.getVoucherId();

        // 3.2 库存充足
        // 3.2.1 扣减库存
        boolean success = seckillVoucherService.lambdaUpdate()
                .setSql("stock = stock - 1")
                .eq(SeckillVoucher::getVoucherId, voucherId)
                .gt(SeckillVoucher::getStock, 0)//添加乐观锁,保证线程安全问题
                .update();
        // 3.2.1.1 扣减库存失败
        if (!success) {
            log.error("库存不足!");
            return ;
        }
        // 3.2.2 优惠卷下单
        boolean save = save(voucherOrder);//保存订单

        // 3.2.2.1 保存订单失败
        if (!save) {
            log.error("下单失败!");
            return ;
        }
    }
}
7.Redis消息队列实现异步秒杀
一、消息队列简介和redis实现消息队列的方式

在这里插入图片描述

二、基于List实现消息队列

在这里插入图片描述
在这里插入图片描述

三、基于Redis的PubSub的消息队列

在这里插入图片描述
在这里插入图片描述

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

单消费模式

  • 发送消息

在这里插入图片描述

  • 读取消息

在这里插入图片描述
在这里插入图片描述

消费者组模式

  • 消费者组简介

    消费者读取消息后,队列中消息就没了,都存入pending-list中了,消息处于pending状态,都会把消息存入pending-list中,如果后续我们读取了消息,但是没有确认,说明发生异常,我们可以去pending-list中重新读取,然后需要我们确认消费ack,然后消息才会从pending-list中移除。
    在这里插入图片描述

  • 创建消费者组
    在这里插入图片描述

  • 读取消息
    在这里插入图片描述

  • 特点

在这里插入图片描述

基于Stream的消息队列实现异步秒杀

  1. 创建队列。

    xgroup create stream.orders g1 0 mkstream
    
  2. 修改Lua脚本逻辑,获取订单id参数,发送订单消息到消息队列。

    -- 秒杀优惠卷下单脚本
    
    -- 1.获取参数
    -- 获取优惠卷id
    local voucherId = ARGV[1]
    -- 获取用户id
    local userId = ARGV[2]
    -- 获取订单id
    local orderId = ARGV[3]
    
    -- 2.判断是否有下单资格
    -- 根据优惠卷id查询优惠卷库存是否充足(redis中的键值对都是String类型)
    if (tonumber(redis.call('get', 'seckill:stock:' .. voucherId)) <= 0) then
        -- 库存不足
        return 1
    end
    -- 根据优惠劵id的集合判断是否含有用户id
    if (redis.call('sismember', 'seckill:order:' .. voucherId, userId) == 1) then
        -- 已经购买过
        return 2
    end
    
    -- 3.可以下单购买
    -- 扣减库存
    redis.call('incrby', 'seckill:stock:' .. voucherId, -1)
    -- 加入用户id
    redis.call('sadd', 'seckill:order:' .. voucherId, userId)
    -- 发送订单消息到消息队列
    redis.call('xadd','stream.orders','*','id',orderId,'userId',userId,'voucherId',voucherId)
    return 0
    
  3. 执行lua脚本,传递参数。

        //异步秒杀优惠卷
        @Override
        public Result secKillVoucher(Long voucherId) {
            // 1.获取用户id
            Long userId = UserHolder.getUser().getId();
            // 生成订单id
            Long orderId = redisIdWorder.nextId("order");
            // 2.执行lua脚本
            Long res = stringRedisTemplate.execute(
                    //redis中的键值对都是String类型,所以使用lua脚本查询时也需要字符串,所以我们要传递字符串
                    SECKILL_SCRIPT, Collections.emptyList(), 
                    voucherId.toString(), userId.toString(),orderId.toString()
            );
            // 3.判断是否含有下单资格
            // 没有资格
            if (res != 0) {
                return Result.fail(res == 1 ? "库存不足" : "一人限购一单");
            }
            
            //获取优惠卷订单的代理对象,因为事务需要使用代理对象才能生效
            proxy = (IVoucherOrderService) AopContext.currentProxy();
    
            // 5.返回订单id
            return Result.ok(orderId);
        }
    
  4. 异步调用线程获取订单消息下单,完善业务逻辑

        //内部类实现Runnable接口,定义任务
        private class voucherOrderHandler implements Runnable {
            @Override
            public void run() {
                while (true) {
                    try {
                        // 1. 从队列中读取消息
                        List<MapRecord<String, Object, Object>> list = stringRedisTemplate.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;
                        }
                        // 3. 获取订单对象,创建订单
                        //由于我们知道每次只读取1条消息,所以直接取第一个,record的string是消息id,Object是消息
                        MapRecord<String, Object, Object> record = list.get(0);
                        Map<Object, Object> map = record.getValue();//获取订单信息
                        //map转对象,忽略异常,因为如果发生异常,我们后续可以在pending-list中再次处理
                        VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(map, new VoucherOrder(), true);
                        handleVoucherOrder(voucherOrder);
    
                        // 4. ACK确认
                        stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
    
                    } catch (Exception e) {
                        log.error("处理订单异常!", e);
                        //读取消息过程中发生异常,消息没有确认,从pending-list中重新获取下单
                        handdlePendingList();
    
                    }
                }
            }
    
            //内部类,处理订单异常
            private void handdlePendingList() {
                while (true) {
                    try {
                        // 1. 从pending-list队列中读取消息,指定0,不需要阻塞
                        List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                                //指定消费者组和消费者
                                Consumer.from("g1", "c1"),
                                //指定消费参数,每次读一条,阻塞两秒
                                StreamReadOptions.empty().count(1),
                                //指定在哪个队列读取消息,从最近一次未消费的消息开始读取
                                StreamOffset.create(queueName, ReadOffset.from("0"))//指定0读取pending-list的数据
                        );
                        // 2. 没有异常消息,直接退出
                        if (list == null || list.isEmpty()) {
                            break;
                        }
                        // 3. 获取订单对象,创建订单
                        //由于我们知道每次只读取1条消息,所以直接取第一个,record的string是消息id,Object是消息
                        MapRecord<String, Object, Object> record = list.get(0);
                        Map<Object, Object> map = record.getValue();//获取订单信息
                        //map转对象,忽略异常,因为如果发生异常,我们后续可以在pending-list中再次处理
                        VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(map, new VoucherOrder(), true);
                        handleVoucherOrder(voucherOrder);
    
                        // 4. ACK确认
                        stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
    
                    } catch (Exception e) {
                        log.error("处理订单异常!", e);
                        //休眠20毫秒,防止太频繁
                        try {
                            Thread.sleep(20);
                        } catch (InterruptedException interruptedException) {
                            interruptedException.printStackTrace();
                        }
                    }
                }
            }
        }
    
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

一码一上午

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

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

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

打赏作者

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

抵扣说明:

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

余额充值