【Redis学习05】优惠券秒杀及其优化

文章目录

    • 1. 全局唯一ID
      • 1.1 全局唯一ID介绍及生成策略
      • 1.2 代码实现
      • 1.3 总结
    • 2. 优惠券秒杀下单
      • 2.1 添加优惠券
      • 2.2 优惠券秒杀功能
    • 3. 超卖问题
      • 3.1 问题分析
      • 3.2 乐观锁与悲观锁
      • 3.3 乐观锁的实现方式
      • 3.4 代码实现
    • 4. 一人一单
      • 4.1 需求分析
      • 4.2 代码实现
      • 4.3 问题分析

1. 全局唯一ID

1.1 全局唯一ID介绍及生成策略

【Redis学习05】优惠券秒杀及其优化_第1张图片
为了解决这个问题,我们就有必要自己设计一个全局的唯一ID。

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具

一般需要满足以下特性:

【Redis学习05】优惠券秒杀及其优化_第2张图片

为了增加ID的安全性,我们可以不直接使用redis自增数值,而是拼接一些其他信息:

【Redis学习05】优惠券秒杀及其优化_第3张图片

1.2 代码实现

首先,我们可以使用2022年1月1日零点的时间的秒数作为开始时间戳,用生成ID的本地时间减去开始时间戳作为最终时间戳。

通过下面代码我们可以获取2022年1月1日零点的时间的秒数,并将其定义为一个常量。
【Redis学习05】优惠券秒杀及其优化_第4张图片

接下来我们生成序列号,将时间戳和序列号进行拼接然后返回。

拼接时间戳和序列号的做法是首先将时间戳使用位运算向左移动三十二位,然后将右边空出来的三十二位与序列号做或运算,得到最终的id。

@Component
public class RedisIdWorker {

    public static final long BEGIN_TIMESTAMP = 1640995200;
    //序列号位数
    public static final int COUNT_BITS = 32;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    public Long nextId(String keyPrefix) {
        //1. 生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSeconds = now.toEpochSecond(ZoneOffset.UTC);
        long timeStamp = nowSeconds - BEGIN_TIMESTAMP;

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

        //3. 拼接并返回
        //时间戳左移32位,左移留下的32位0与count做或运算
        return timeStamp << COUNT_BITS | count;
    	}
    }

1.3 总结

【Redis学习05】优惠券秒杀及其优化_第5张图片

2. 优惠券秒杀下单

每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券就需要秒杀抢购

【Redis学习05】优惠券秒杀及其优化_第6张图片

2.1 添加优惠券

我们在优惠券的接口中添加了新增普通优惠券和秒杀券的方法,我们可以通过请求去添加需要的优惠券。

@RestController
@RequestMapping("/voucher")
public class VoucherController {

    @Resource
    private IVoucherService voucherService;

    /**
     * 新增普通券
     * @param voucher 优惠券信息
     * @return 优惠券id
     */
    @PostMapping
    public Result addVoucher(@RequestBody Voucher voucher) {
        voucherService.save(voucher);
        return Result.ok(voucher.getId());
    }

    /**
     * 新增秒杀券
     * @param voucher 优惠券信息,包含秒杀信息
     * @return 优惠券id
     */
    @PostMapping("seckill")
    public Result addSeckillVoucher(@RequestBody Voucher voucher) {
        voucherService.addSeckillVoucher(voucher);
        return Result.ok(voucher.getId());
    }

【Redis学习05】优惠券秒杀及其优化_第7张图片

2.2 优惠券秒杀功能

实现我们的优惠券秒杀功能首先需要满足以下两点:

  1. 秒杀是否开始或已经结束
  2. 库存是否充足

接下来我们梳理一下秒杀流程图,可以说业务流程并不复杂,我们使用代码实现一下。
【Redis学习05】优惠券秒杀及其优化_第8张图片

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

    @Autowired
    private ISeckillVoucherService seckillVoucherService;

    @Autowired
    private RedisIdWorker RedisIdWorker;
    /**
     * 优惠券秒杀
     * @param voucherId
     * @return
     */
    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        //1. 根据优惠券id查询优惠券信息
        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. 充足,扣减优惠券数量
        voucher.setStock(voucher.getStock()-1);
        boolean success = seckillVoucherService.updateById(voucher);

        if (!success) {
            return Result.fail("优惠券已被抢完");
        }

        //6. 创建订单
        //6.1 设置id
        VoucherOrder voucherOrder = new VoucherOrder();
        Long voucherOrderId = RedisIdWorker.nextId("voucherOrder");
        voucherOrder.setId(voucherOrderId);

        //6.2 设置user_id      
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);

        //6.3 设置优惠券id
        voucherOrder.setVoucherId(voucherId);
        this.save(voucherOrder);

        return Result.ok(voucherOrderId);
    }
}

【Redis学习05】优惠券秒杀及其优化_第9张图片

3. 超卖问题

3.1 问题分析

我们学习使用redis在做项目就需要考虑并发问题,我们来看一下如下几种情况,观察我们的程序会出现哪些问题。

正常(理想)情况下
【Redis学习05】优惠券秒杀及其优化_第10张图片

并发情况下:出现库存为-1 的情况,也就是出现了超卖问题
【Redis学习05】优惠券秒杀及其优化_第11张图片

3.2 乐观锁与悲观锁

超卖问题的典型就是多线程问题,针对这一问题的常见解决方案就是加锁

加锁有乐观锁和悲观锁两种,其中,如果有高并发的需求悲观锁因其串行执行保证线程安全而不能满足需要,因此,对于高并发解决问题一般是使用乐观锁

当然,使用乐观锁也不一定能完全解决高并发带来的线程安全问题,我们接下去还会继续学习其他的解决方案。

【Redis学习05】优惠券秒杀及其优化_第12张图片

3.3 乐观锁的实现方式

乐观锁的关键是判断之前查询得到的数据是否发生变化,常见的方法有如下两种

  • 版本号法
  • CAS(Compare And Set)法

【Redis学习05】优惠券秒杀及其优化_第13张图片
CAS法是根据版本号法进行改进的:既然库存和版本号是同时进行修改的,那我们何苦新增一个版本号字段来增加复杂度呢?我们直接判断扣减库存前的库存跟之前查询的库存是否一致不就解决了吗。

是的,如果扣减之前查询的库存没有发生变化,则进行扣减操作,如果扣减之前查询的库存发生变化,则说明有其他线程对库存进行了修改,这时候我们就不进行库存扣减操作,返回操作失败的提示信息。

【Redis学习05】优惠券秒杀及其优化_第14张图片

3.4 代码实现

	@Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        //1. 根据优惠券id查询优惠券信息
        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("优惠券已被抢完");
        }

        Long userId = UserHolder.getUser().getId();

        //5. 充足,扣减优惠券数量

        //当更新时查询的库存为未更新前的库存时进行库存减一
        boolean success = seckillVoucherService.update()
                .setSql("stock=stock-1")
                .eq("voucher_id", voucherId)
                .eq("stock", voucher.getStock()).update();

        if (!success) {
            return Result.fail("优惠券已被抢完");
        }

        //6. 创建订单
        //6.1 设置id
        VoucherOrder voucherOrder = new VoucherOrder();
        Long voucherOrderId = RedisIdWorker.nextId("voucherOrder");
        voucherOrder.setId(voucherOrderId);

        //6.2 设置user_id
        voucherOrder.setUserId(userId);

        //6.3 设置优惠券id
        voucherOrder.setVoucherId(voucherId);
        this.save(voucherOrder);

        return Result.ok(voucherOrderId);
    }
}

我们使用Jmeter进行高并发测试
【Redis学习05】优惠券秒杀及其优化_第15张图片
我们使用Jmeter进行一秒钟两百个线程进行优惠券秒杀,按我们猜想应该是优惠券不够卖,至少有一半的线程是不能抢到优惠券的,结果确实如此嘛?

我们打开数据库看一下库存和订单
【Redis学习05】优惠券秒杀及其优化_第16张图片
【Redis学习05】优惠券秒杀及其优化_第17张图片
可以看出,我们启动了两百个线程但只卖出了20几张优惠券,这是为什么呢?

因为我们设置的条件是当我们扣减库存时查询的库存必须和我们扣减之前保持一致,而在高并发下,我们很多线程被这一个条件卡在了“门外”,进而导致抢购失败的情况

如何优化呢?其实我们设置的条件可以不再是扣减库存时查询的库存必须和我们扣减之前保持一致,而是库存大于0,就是不一致只要库存大于0是不是也能满足需要。说干就干,我们修改一下代码

boolean success = seckillVoucherService.update()
                    .setSql("stock=stock-1")
                    .gt("stock", 0)
                    .eq("voucher_id", voucherId).update();

【Redis学习05】优惠券秒杀及其优化_第18张图片
【Redis学习05】优惠券秒杀及其优化_第19张图片
可以看到,这种办法确实有效。

4. 一人一单

4.1 需求分析

对于秒杀优惠券,我们要求是同一个用户只能购买一张优惠券,不能多买。

我们重新梳理一下流程
【Redis学习05】优惠券秒杀及其优化_第20张图片

4.2 代码实现

    @Transactional
    public Result seckillVoucher(Long voucherId) {
        //1. 根据优惠券id查询优惠券信息
        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("优惠券已被抢完");
        }

        Long userId = UserHolder.getUser().getId();
        //一人一单
        int count = this.query().eq("user_id", userId)
                .eq("voucher_id", voucherId).count();
        if(count>0){
            return Result.fail("一个用户只能购买一个优惠券");
        }
  
        //当更新时查询的库存大于0时进行库存减一
        boolean success = seckillVoucherService.update()
                .setSql("stock=stock-1")
                .gt("voucher_id", 0)
                .eq("stock", voucher.getStock()).update();

        if (!success) {
            return Result.fail("优惠券已被抢完");
        }

        //6. 创建订单
        //6.1 设置id
        VoucherOrder voucherOrder = new VoucherOrder();
        Long voucherOrderId = RedisIdWorker.nextId("voucherOrder");
        voucherOrder.setId(voucherOrderId);

        //6.2 设置user_id
        voucherOrder.setUserId(userId);

        //6.3 设置优惠券id
        voucherOrder.setVoucherId(voucherId);
        this.save(voucherOrder);

        return Result.ok(voucherOrderId);
    }

4.3 问题分析

其实,一人一单在高并发情况下也是会出现一人可以买多张优惠券的问题 ,这时我们就只加悲观锁进行限制。
【Redis学习05】优惠券秒杀及其优化_第21张图片

解决方法,加悲观锁
【Redis学习05】优惠券秒杀及其优化_第22张图片

但是,加悲观锁在单机情况下能解决安全问题,但是在集群模式下就不行了

为什么呢?

因为我们如果在集群模式下,有很多个JVM,每个JVM中的锁是不一样的。如下图所示,当线程1在JVM1中获取锁成功时,线程3也成功的获取了JVM2中的锁,因为两个锁是不是同一个,因此肯定能获取成功,这样我们就又无法保证在集群模式下实现一人一单的问题了。
【Redis学习05】优惠券秒杀及其优化_第23张图片

怎么解决呢?我也还没学

接下去我会继续学习分布式锁,等我学会了再来分享。

看到这里的朋友记得点赞关注一下,我会持续学习并且更新,加油!!!

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