黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀

  • 1、redis应用场景一:全局唯一ID
    • 1.1 生成策略
    • 1.2 实践
    • 1.3 总结
  • 2、优惠券秒杀下单
    • 2.1 流程分析
    • 2.2 代码实现
  • 3、超卖问题
    • 3.1 原因分析
    • 3.2 解决方案选择:悲观锁or乐观锁
    • 3.3 乐观锁实现
      • 方案一:版本号法
      • 方案二:CAS法
      • CAS法代码实现
    • 3.4 线程安全总结
  • 4、一人一单
    • 4.1 实现流程
    • 4.2 代码实现
  • 5、分布式锁
    • 5.1 集群模式下的并发安全问题
    • 5.2 分布式锁的工作原理
    • 5.3 分布式锁方案对比
    • 5.4 基于Redis的分布式锁(初级版本,不可重入)
      • 5.4.1 流程
      • 5.4.2 代码实现
      • 5.4.3中 可能存在的问题
        • 1、误删问题
        • 2、原子性问题
      • 基于Redis的分布式锁(5.4, 5.5)总结
    • 5.5 基于Redis的分布式锁优化(可重入)
      • 5.5.1 Redisson入门
      • 5.5.2 Redisson分布式锁原理
        • 1 Redisson可重入原理
        • 2 Redisson可重试原理
        • 3 Redisson防止业务未完成时锁的超时释放
    • 5.6 Redisson解决主从一致原理
    • (5.4-5.6总结)
  • 6、Redis优化秒杀(异步)
    • 6.1 流程图
    • 6.2 代码实现(基于阻塞队列的异步下单)
    • 6.3优化思路总结
  • 7、Redis消息队列实现异步秒杀
    • 7.1 基于List结构模拟消息队列
    • 7.2 PubSub(发布订阅)基本的点对点消息模型
    • 7.3 Stream 比较完善的消息队列模型
      • 7.3.1 单消费者模式
      • 7.3.2 消费者组模式
    • 7.4三种Redis消息队列总结
    • 7.5 代码实现基于stream消息队列的异步秒杀
      • 7.5.1、redis客户端创建消息队列
      • 7.5.2、修改Lua脚本
      • 7.5.3、修改对应java脚本
      • 7.5.4、修改线程对象
  • 8、【总结】Redis在秒杀业务中的应用

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第1张图片


1、redis应用场景一:全局唯一ID

问题:
黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第2张图片

1.1 生成策略

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第3张图片
黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第4张图片

1.2 实践

@Component
public class RedisIdWorker {
    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1672531200;
    /**
     * 序列号位数
     */
    private static final int COUNT_BITS = 32;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * ID自动生成器并返回
     *
     * @param keyPrefix 业务前缀
     * @return
     */
    public long nextId(String keyPrefix) {
        //1 生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timeStamp = nowSecond - BEGIN_TIMESTAMP;

        //2 生成序列号
        //2.1 获取当前日期精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //2.2 自增长
        long count = stringRedisTemplate.opsForValue().increment("icr" + keyPrefix + ":" + date);

        //3 拼接并返回
        return timeStamp << COUNT_BITS | count;
    }

    public static void main(String[] args) {
        LocalDateTime time = LocalDateTime.of(2023, 1, 1, 0, 0, 0);
        long second = time.toEpochSecond(ZoneOffset.UTC);
        System.out.println("second:" + second);
    }
}

1.3 总结

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第5张图片


2、优惠券秒杀下单

2.1 流程分析

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第6张图片使用postman添加秒杀券
黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第7张图片

//秒杀券信息
{
    "shopId": 1,
    "title": "100元代金券",
    "subTitle": "周一到周日均可使用",
    "rules": "全场通用\\n无需预约\\n可无限叠加\\不兑现、不找零\\n仅限堂食",
    "payValue": 8000,
    "actualValue": 10000,
    "type": 1,
    "stock": 100,
    "beginTime":"2023-04-18T15:40:00",
    "endTime":"2023-04-18T23:40:00"
}

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第8张图片

2.2 代码实现

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第9张图片

//VoucherOrderController类中
	@PostMapping("seckill/{id}")
    public Result seckillVoucher(@PathVariable("id") Long voucherId) {
        return voucherOrderService.seckillVoucher(voucherId);
    }
//IVoucherOrderService接口中声明方法
    Result seckillVoucher(Long voucherId);
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;

    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        //1 查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2 判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            //未开始
            return Result.fail("秒杀尚未开始!");
        }
        //3 判断秒杀是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            //已结束
            return Result.fail("秒杀已结束");
        }
        //4 判断库存是否充足
        if(voucher.getStock()<1){
            //库存不足
            return Result.fail("库存不足!");
        }
        //5 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id",voucherId).update();
        if(!success){
            //扣减失败
            return Result.fail("库存不足!");
        }
        //6 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1 订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //6.2 用户id
        voucherOrder.setUserId(UserHolder.getUser().getId());
        //6.3 代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //7 返回订单id
        return Result.ok(orderId);
    }
}

3、超卖问题

3.1 原因分析

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第10张图片

3.2 解决方案选择:悲观锁or乐观锁

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第11张图片

3.3 乐观锁实现

方案一:版本号法

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第12张图片

方案二:CAS法

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第13张图片

CAS法代码实现

在VoucherOrderServiceImpl类的seckillVoucher方法中修改操作数据库条件

		//5 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") //set stock=stock-1
                .eq("voucher_id",voucherId).eq("stock",voucher.getStock()) //where id=? and stock=?
                .update();

改进,提高成功率

		//5 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") //set stock=stock-1
                .eq("voucher_id",voucherId).gt("stock",0) //where id=? and stock>0
                .update();

3.4 线程安全总结

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第14张图片


4、一人一单

4.1 实现流程

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第15张图片

4.2 代码实现

这个实现过程比较复杂,包含spring事务失效、aop代理对象、synchronized锁等知识点,可以多看几遍。

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

    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;

    @Override
    public Result seckillVoucher(Long voucherId) {
        //1 查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2 判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            //未开始
            return Result.fail("秒杀尚未开始!");
        }
        //3 判断秒杀是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            //已结束
            return Result.fail("秒杀已结束");
        }
        //4 判断库存是否充足
        if(voucher.getStock()<1){
            //库存不足
            return Result.fail("库存不足!");
        }
        //synchronized悲观锁,给用户加锁
        //要在事务外层加锁,因为要在事务提交之后释放锁,才能确保线程安全
        Long userId = UserHolder.getUser().getId();
        synchronized(userId.toString().intern()){
            //使用代理对象的createVouterOrder方法才能开启事务
            //获取代理对象
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVouterOrder(voucherId);
        }
    }


    @Transactional
    public Result createVouterOrder(Long voucherId) {
        //A 新增一人一单业务
        Long userId = UserHolder.getUser().getId();


        //A1 查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        //A2 判断是否存在当前id订单
        if(count>0){
            //用户已购买过
            return Result.fail("用户已购买过一次!");
        }
        //5 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") //set stock=stock-1
                .eq("voucher_id", voucherId).gt("stock",0) //where id=? and stock=?
                .update();
        if(!success){
            //扣减失败
            return Result.fail("库存不足!");
        }
        //6 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1 订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //6.2 用户id
        voucherOrder.setUserId(userId);
        //6.3 代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //7 返回订单id
        return Result.ok(orderId);

    }
}

在接口中声明createVouterOrder方法

	Result createVouterOrder(Long voucherId);

在获取代理对象防止事务失效时,要在pom文件中增加一个依赖

	
        org.aspectj
        aspectjweaver
    

并在启动类HmDianPingApplication增加注解

@EnableAspectJAutoProxy(exposeProxy = true)

5、分布式锁

5.1 集群模式下的并发安全问题

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第16张图片黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第17张图片集群中,有多个JVM存在,每个JVM内部都维护着各自的锁,因此仍然有若干个线程能获取到不同JVM中的锁,这就是集群导致的线程安全问题。因此需要让多个JVM共用一把锁。

5.2 分布式锁的工作原理

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第18张图片

5.3 分布式锁方案对比

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第19张图片

5.4 基于Redis的分布式锁(初级版本,不可重入)

5.4.1 流程

**Redis分布式锁原理:**基于setnx命令–>key存在的情况下,不更新value,而是返回nil
利用key是唯一的特性来加锁,比如一人一单业务,key名称精确到userId,那么同一个用户无论发多少次请求,能成功创建键值的只有一个,因为setnx命令,后面的请求在获取锁创建键值就会失败。

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第20张图片

5.4.2 代码实现

锁接口

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

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

锁实现:

public class SimpleRedisLock implements ILock {

    private StringRedisTemplate stringRedisTemplate;
    //锁名称(业务名)
    private String name;
    //锁前缀
    private static final String KEY_PREFIX = "locks:";

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

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标识
        long threadId = Thread.currentThread().getId();

        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        //自动拆箱会有空指针风险
        return Boolean.TRUE.equals(success);
    }

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

在抢券业务中使用锁实现一人一单:
VoucherOrderServiceImpl类的seckillVoucher方法修改:

@Override
    public Result seckillVoucher(Long voucherId) {
        //1 查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2 判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            //未开始
            return Result.fail("秒杀尚未开始!");
        }
        //3 判断秒杀是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            //已结束
            return Result.fail("秒杀已结束");
        }
        //4 判断库存是否充足
        if(voucher.getStock()<1){
            //库存不足
            return Result.fail("库存不足!");
        }
        //synchronized悲观锁,给用户加锁
        //要在事务外层加锁,因为要在事务提交之后释放锁,才能确保线程安全
        Long userId = UserHolder.getUser().getId();
//        synchronized(userId.toString().intern()){
        //创建所对象
        SimpleRedisLock lock = new SimpleRedisLock(stringRedisTemplate, "order:" + userId);
        //获取锁
        boolean isLock = lock.tryLock(5);
        //判断是否获取锁成功
        if(!isLock){
            //失败,返回错误信息
            return Result.fail("不允许重复下单");
        }
        try {
            //使用代理对象的createVouterOrder方法才能开启事务
            //获取代理对象
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVouterOrder(voucherId);
        } finally {
            //释放锁
            lock.unLock();
        }
//        }
    }

5.4.3中 可能存在的问题

1、误删问题

把别的线程的锁删了。
黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第21张图片
解决方案: 获取锁时生成线程标识,在释放锁时判断线程标识是否是自己的,不一致可能是别人的锁,不释放。
黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第22张图片
修正:
黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第23张图片

public class SimpleRedisLock implements ILock {

    private StringRedisTemplate stringRedisTemplate;
    //锁名称(业务名)
    private String name;
    //锁前缀
    private static final String KEY_PREFIX = "locks:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

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

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();

        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        //自动拆箱会有空指针风险
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unLock() {
        //获取线程标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        //获取锁中的标识
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        //判断标识是否一致
        if(threadId.equals(id)){
            //释放锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }
}

2、原子性问题

判断锁和释放锁之间产生阻塞导致问题,因此这两个操作要变成原子性操作。
黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第24张图片
Redis事务是批处理,不能实现先判断锁一致再删除。并且Redis事务只能保证原子性,不能保证一致性。因此,使用Redis的Lua脚本来实现判断锁和释放锁操作的原子性。
黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第25张图片
修正:
利用lua的原子性特征,将判断和删除锁绑定为原子性操作。
黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第26张图片
Lua脚本:(放在resources目录下)

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

SimpleRedisLock类中unlock方法调用lua脚本

public class SimpleRedisLock implements ILock {

    private StringRedisTemplate stringRedisTemplate;
    //锁名称(业务名)
    private String name;
    //锁前缀
    private static final String KEY_PREFIX = "locks:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        //提前加载lua脚本
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

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

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();

        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        //自动拆箱会有空指针风险
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unLock() {
        //调用lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().getId());
    }
}

基于Redis的分布式锁(5.4, 5.5)总结

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第27张图片

5.5 基于Redis的分布式锁优化(可重入)

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第28张图片黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第29张图片

5.5.1 Redisson入门

1、配置POM依赖

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

2、配置Redisson

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient(){// RedissonClient是工厂类
        //配置
        Config config = new Config();
        //添加redis地址,这里添加的单点地址,集群要使用config.useClusterServers()添加地址
        config.useSingleServer().setAddress("redis://【虚拟机ip】:6379").setPassword("【redis密码】");
        //创建Redisson对象
        return Redisson.create(config);
    }
}

3、使用Redisson分布式锁

// VoucherOrderServiceImpl类中注入RedissonClient对象
    @Resource
    private RedissonClient redissonClient;
// VoucherOrderServiceImpl类seckillVoucher方法中【创建锁,获取和释放锁语句修改】
//        创建锁对象
//        SimpleRedisLock lock = new SimpleRedisLock(stringRedisTemplate, "order:" + userId);
//        获取锁
//        boolean isLock = lock.tryLock(1200);
//        释放锁
//        lock.unlock();
        //创建锁对象(可重入)
        RLock lock = redissonClient.getLock("lock:order:" + userId);
        //尝试获取锁,参数是:获取锁的最大等待时间(默认不等待),锁自动释放时间,时间单位
        boolean isLock = lock.tryLock();
        //释放锁
        lock.unlock();

5.5.2 Redisson分布式锁原理

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第30张图片

1 Redisson可重入原理

利用 hash 结构,记录线程标示和重入次数。
原理
黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第31张图片

2 Redisson可重试原理

【利用信号量控制锁重试等待】:消息订阅+信号量机制,不是无休止重试,是有人释放锁之后再重试。
前14分钟

3 Redisson防止业务未完成时锁的超时释放

watchDog(看门狗)锁续约时间实现
14:00之后

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第32张图片

5.6 Redisson解决主从一致原理

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第33张图片连锁策略:不再有主从节点,都获取成功才能获取锁成功,有一个节点获取锁不成功就获取锁失败。
黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第34张图片如果多个主节点保证锁的话,一个主节点宕机了,其它线程只能获得一个新主节点的锁,获取不到其它两个锁,还会获取失败
这里主要是防止主节点宕机后,其它线程获得新主节点的锁,引起线程安全问题。

(5.4-5.6总结)

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第35张图片


6、Redis优化秒杀(异步)

6.1 流程图

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第36张图片为避免所有操作都在数据库上执行,分离成两个线程:

  1. 一个线程判断用户的购买资格,发现用户有购买资格。(相当于收银台下单给小票)
  2. 再开启一个独立的线程来处理耗时较久的减库存、写订单的操作。(相当于后厨拿订单做菜)

可以将耗时较短的第一个线程操作放到 Redis 中,在 Redis 中处理对应的秒杀资格的判断。Redis 的性能是比 MySQL 要好的。此外,还需要引入【异步队列】记录相关的信息。

1、redis部分处理逻辑, Lua脚本封装操作保证原子性, redis这里选择的存储类型为set,因为key不能重复,而set恰好是无序不重复的
黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第37张图片

6.2 代码实现(基于阻塞队列的异步下单)

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第38张图片
1.新增优惠券的业务时,把秒杀优惠券的库存信息保存到redis

// VoucherServiceImpl类中
		//保存秒杀库存到redis
        stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());

2、编写lua脚本,按照下面的业务流程逻辑,在脚本中完成业务实现
黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第39张图片

-- 1.参数列表
-- 1.1 优惠券id
local voucherId = ARGV[1]
-- 1.2 用户id
local userId = ARGV[2]

-- 2.数据key
-- 2.1 库存key   key 是优惠的业务名称加优惠券id  value 是优惠券的库存数
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2 订单key   key 也是拼接的业务名称加优惠权id  而value是用户id, 这是一个set集合,凡购买该优惠券的用户都会将其id存入集合中
local orderKey = 'seckill:order:' .. voucherId

-- 3.脚本业务
-- 3.1 判断库存是否充足 get stockKey
if (tonumber(redis.call('get', stockKey)) <= 0)  then  --将get的value先转为数字类型才能判断比较
    -- 3.2 库存不足,返回1
    return 1
end
-- 3.3 判断用户是否下单 sismember orderKey userId命令,判断当前key集合中,是否存在该value;返回1存在,0不存在
if (redis.call('sismember', orderKey, userId) == 1) then
    --3.4 存在说明是重复下单,返回2
    return 2
end
-- 3.5 扣库存
redis.call('incrby', stockKey, -1)
-- 3.6 下单(保存用户)
redis.call('sadd', orderKey, userId)
return 0

3、VoucherOrderServiceImpl类中执行lua脚本,并判断,抢购成功的生成订单并存入阻塞队列

//注入脚本
	private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
    static {
        //提前加载lua脚本
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        SECKILL_SCRIPT.setResultType(Long.class);
    }
//运行脚本,且判断不满足的请求直接返回提示信息
	@Override
    public Result seckillVoucher(Long voucherId) {
        //获取用户
        Long userId = UserHolder.getUser().getId();
        //1 执行lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString()
        );
        //2 判断结果是否为0
        if(result.intValue() != 0){
            //2.1 不为0,没有购买资格
            return Result.fail(result.intValue()==1?"库存不足":"不能重复下单");
        }
        //2.2 为0,有购买资格;把下单信息保存到阻塞队列
        long orderId = redisIdWorker.nextId("order:");
        //TODO: 保存阻塞队列
        //3 返回订单id
        return Result.ok(orderId);
    }

4、如果抢购成功,将优惠券 id 和用户 id 封装后存入阻塞队列
4.1、创建BlockingQueue阻塞队列
BlockingQueue阻塞队列特点:当一个线程尝试从队列获取元素的时候,如果没有元素该线程阻塞,直到队列中有元素才会被唤醒并获取元素。

	//阻塞队列
    private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);

4.2、将满足条件的请求,生成订单,并把订单对象add到阻塞队列中

public Result seckillVoucher(Long voucherId) {
        //获取用户
        Long userId = UserHolder.getUser().getId();
        //1 执行lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString()
        );
        //2 判断结果是否为0
        if (result.intValue() != 0) {
            //2.1 不为0,没有购买资格
            return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单");
        }
        //2.2 为0,有购买资格;把下单信息保存到阻塞队列
        //2.2.1 封装订单id,用户id,代金券id
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        //2.2.2 放入阻塞队列
        orderTasks.add(voucherOrder);
        // 提前获取代理对象
        proxy = (IVoucherOrderService) AopContext.currentProxy();
        //3 返回订单id给客户端
        return Result.ok(orderId);
    }

5、开启线程任务,实现异步下单功能
5.1、首先创建一个线程池,再定义一个线程任务
【注意】线程任务需要在用户秒杀订单之前开始,用户一但开始秒杀,队列就会有新的订单,线程任务就应该立即取出订单信息,这里利用spring提供的注解,在类初始化完毕后立即执行线程任务。

	//线程池
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

    @PostConstruct
    private void init(){
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

5.2、线程任务代码

	//线程任务,内部类方式
    private class VoucherOrderHandler implements Runnable{
        @Override
        public void run() {
            while(true){
                //1 获取队列中订单信息
                try {
                    VoucherOrder voucherOrder = orderTasks.take();
                    //2 创建订单
                    handleVoucherOrder(voucherOrder);
                } catch (Exception e) {
                    log.error("处理订单异常"+e);
                }
            }
        }
    }

    private void handleVoucherOrder(VoucherOrder voucherOrder) {
        //1 获取用户id
        Long userId = voucherOrder.getUserId();
        //2 创建锁对象
        RLock lock = redissonClient.getLock("lock:order:" + userId);
        //3 尝试获取锁,参数是:获取锁的最大等待时间(默认不等待),锁自动释放时间,时间单位
        boolean isLock = lock.tryLock();
        //判断是否获取锁成功
        if(!isLock){
            //失败,返回错误信息
            log.error("不允许重复下单");
            return;
        }
        try {
            proxy.createVoucherOrder(voucherOrder);
        } finally {
            //释放锁
            lock.unlock();
        }
    }

    @Transactional
    public void createVoucherOrder(VoucherOrder voucherOrder) {
        //A 新增一人一单业务
        Long userId = voucherOrder.getUserId();

        //A1 查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
        //A2 判断是否存在当前id订单
        if(count>0){
            //用户已购买过
            log.error("用户已经购买一次!");
            return;
        }
        //5 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") //set stock=stock-1
                .eq("voucher_id", voucherOrder.getVoucherId()).gt("stock",0) //where id=? and stock=?
                .update();
        if(!success){
            //扣减失败
            log.error("库存不足!");
            return;
        }
        //创建订单
        save(voucherOrder);
    }

6.3优化思路总结

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第40张图片黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第41张图片


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

由于前面的阻塞队列是基于JVM的内存实现,那么不可避免的两个大问题
①高并发海量访问,创建订单,队列很快就超出上限,造成内存溢出;
②JVM内存没有持久化机制,若服务出现重启或宕机,阻塞队列中的所有任务都会丢失。
黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第42张图片

MQ(消息队列)优点: MQ是JVM以外的服务,不受JVM内存限制,且MQ中的所有消息会做持久化,这样即使重启或宕机,数据不会丢失。消息投递给消费者后需要消费者确认,未确认消息会一直存在下一次继续投递,确保消息至少被消费一次。

P69使用测试类创建Jmeter压力测试用的tokens.txt文件,并将所有用户存到redis。

package com.hmdp;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.bean.copier.CopyOptions;
import cn.hutool.core.lang.UUID;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import com.hmdp.service.IUserService;
import lombok.SneakyThrows;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY;

/**
 * @author xsy
 * @version 1.0
 */
// P69创建 tokens.txt文件
@SpringBootTest
@AutoConfigureMockMvc
public class VoucherOrderControllerTest {
    @Autowired
    private IUserService userService;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @SneakyThrows
    @Test
    void getTokens(){
        FileOutputStream outputStream = new FileOutputStream(new File("D:\\JavaCode\\redis\\hm-dianping\\tokens.txt"));//tokens.txt文件存储路径
        List<User> userList = userService.query().list();
        userList.forEach(user -> {
            String token = UUID.randomUUID().toString(true);
            UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
            Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                    new CopyOptions().
                            ignoreNullValue().
                            setFieldValueEditor((keyType, valueType) -> valueType.toString()));
            String key = LOGIN_USER_KEY + token;
            stringRedisTemplate.opsForHash().putAll(key, userMap);
            try {
                outputStream.write(token.getBytes(StandardCharsets.UTF_8));
                outputStream.write("\n".getBytes(StandardCharsets.UTF_8));
                outputStream.flush();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        });
    }
}

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

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第43张图片黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第44张图片

7.2 PubSub(发布订阅)基本的点对点消息模型

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第45张图片黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第46张图片

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

7.3.1 单消费者模式

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第47张图片黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第48张图片黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第49张图片黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第50张图片

7.3.2 消费者组模式

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第51张图片黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第52张图片黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第53张图片黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第54张图片

7.4三种Redis消息队列总结

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第55张图片

7.5 代码实现基于stream消息队列的异步秒杀

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀_第56张图片

7.5.1、redis客户端创建消息队列

XGROUP CREATE stream.orders g1 0 MKSTREAM

7.5.2、修改Lua脚本

-- 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   key 是优惠的业务名称加优惠券id  value 是优惠券的库存数
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2 订单key   key 也是拼接的业务名称加优惠权id  而value是用户id, 这是一个set集合,凡购买该优惠券的用户都会将其id存入集合中
local orderKey = 'seckill:order:' .. voucherId

-- 3.脚本业务
-- 3.1 判断库存是否充足 get stockKey
if (tonumber(redis.call('get', stockKey)) <= 0)  then  --将get的value先转为数字类型才能判断比较
    -- 3.2 库存不足,返回1
    return 1
end
-- 3.3 判断用户是否下单 sismember orderKey userId命令,判断当前key集合中,是否存在该value;返回1存在,0不存在
if (redis.call('sismember', orderKey, userId) == 1) then
    --3.4 存在说明是重复下单,返回2
    return 2
end
-- 3.5 扣库存
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)

return 0

7.5.3、修改对应java脚本

	public Result seckillVoucher(Long voucherId) {
        //获取用户
        Long userId = UserHolder.getUser().getId();
        //获取订单ID
        long orderId = redisIdWorker.nextId("order");
        //1 执行lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString(),String.valueOf(orderId)
        );
        //2 判断结果是否为0
        if (result.intValue() != 0) {
            //2.1 不为0,没有购买资格
            return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单");
        }
        // 提前获取代理对象
        proxy = (IVoucherOrderService) AopContext.currentProxy();
        //3 返回订单id给客户端
        return Result.ok(orderId);
    }

7.5.4、修改线程对象

	private class VoucherOrderHandler implements Runnable{
        String queueName = "stream.orders";
        @Override
        public void run() {
            while(true){
                try {
                    //1 获取消息队列中订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 streams.orders >
                    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()){
                        //2.1 如果获取失败,说明没有消息,继续下一次循环
                        continue;
                    }
                    // 解析消息中的订单信息
                    MapRecord<String, Object, Object> record = list.get(0);
                    Map<Object, Object> value = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                    //2.2 如果获取成功,可以下单创建订单
                    System.out.println("2-handleVoucherOrder(voucherOrder)");
                    handleVoucherOrder(voucherOrder);
                    //3 ACK确认 SACK stream.orders g1 id
                    stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
                } catch (Exception e) {
                    handlePendingList();
                    log.error("处理订单异常"+e);
                }
            }
        }

        private void handlePendingList(){
            while(true){
                try {
                    //1 获取消息队列中订单信息 XREADGROUP GROUP g1 c1 COUNT 1 streams.orders 0
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1),
                            StreamOffset.create(queueName, ReadOffset.from("0"))
                    );
                    //2 判断消息是否获取成功
                    if(list == null || list.isEmpty()){
                        //2.1 如果获取失败,说明pending-list没有消息,结束循环
                        break;
                    }
                    // 解析消息中的订单信息
                    MapRecord<String, Object, Object> record = list.get(0);
                    Map<Object, Object> value = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                    //2.2 如果获取成功,可以下单创建订单
                    System.out.println("1-handleVoucherOrder(voucherOrder)");
                    handleVoucherOrder(voucherOrder);
                    //3 ACK确认 SACK stream.orders g1 id
                    stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
                } catch (Exception e) {
                    log.error("处理pending-list订单异常"+e);
                    try {
                        Thread.sleep(20);
                    } catch (InterruptedException ex) {
                        throw new RuntimeException(ex);
                    }
                }
            }
        }
    }

8、【总结】Redis在秒杀业务中的应用

  1. 缓存
  2. 分布式锁
  3. 超卖问题
  4. Lua脚本
  5. Redis消息队列

你可能感兴趣的:(redis,redis,笔记,java)