Redis实战(黑马点评--优惠券秒杀)

发布时间 2023-07-01 00:15:55作者: 夏雪冬蝉

Redis实现全局唯一ID

  • 在各类购物App中,都会遇到商家发放的优惠券
  • 当用户抢购商品时,生成的订单会保存到tb_voucher_order表中,而订单表如果使用数据库自增ID就会存在一些问题
    1. id规律性太明显
    2. 受单表数据量的限制
  • 如果我们的订单id有太明显的规律,那么对于用户或者竞争对手,就很容易猜测出我们的一些敏感信息,例如商城一天之内能卖出多少单,这明显不合适
  • 随着我们商城的规模越来越大,MySQL的单表容量不宜超过500W,数据量过大之后,我们就要进行拆库拆表,拆分表了之后,他们从逻辑上讲,是同一张表,所以他们的id不能重复,于是乎我们就要保证id的唯一性
  • 那么这就引出我们的全局ID生成器
    • 全局ID生成器是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足一下特性
      • 唯一性
      • 高可用
      • 高性能
      • 递增性
      • 安全性
  • 为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其他信息
  • ID组成部分
    • 符号位:1bit,永远为0
    • 时间戳:31bit,以秒为单位,可以使用69年(2^31秒约等于69年)
    • 序列号:32bit,秒内的计数器,支持每秒传输2^32个不同ID
@Component
public class RedisIdWorker {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    //设置起始时间,我这里设定的是2022.01.01 00:00:00
    public static final Long BEGIN_TIMESTAMP = 1640995200L;
    //序列号长度
    public static final Long COUNT_BIT = 32L;

    public long nextId(String keyPrefix){
        //1. 生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long currentSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timeStamp = currentSecond - BEGIN_TIMESTAMP;
        //2. 生成序列号
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        long count = stringRedisTemplate.opsForValue().increment("inc:"+keyPrefix+":"+date);
        //3. 拼接并返回,简单位运算
        return timeStamp << COUNT_BIT | count;
    }

    public static void main(String[] args) {
        //设置一下起始时间,时间戳就是起始时间与当前时间的秒数差
        LocalDateTime tmp = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
        System.out.println(tmp.toEpochSecond(ZoneOffset.UTC));
        //结果为1640995200L
    }
}

 

添加优惠券

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

tb_voucher:优惠券的基本信息,优惠金额、使用规则等

FieldTypeCollationNullKeyDefaultExtraComment
id bigint unsigned (NULL) NO PRI (NULL) auto_increment 主键
shop_id bigint unsigned (NULL) YES   (NULL)   商铺id
title varchar(255) utf8mb4_general_ci NO   (NULL)   代金券标题
sub_title varchar(255) utf8mb4_general_ci YES   (NULL)   副标题
rules varchar(1024) utf8mb4_general_ci YES   (NULL)   使用规则
pay_value bigint unsigned (NULL) NO   (NULL)   支付金额,单位是分。例如200代表2元
actual_value bigint (NULL) NO   (NULL)   抵扣金额,单位是分。例如200代表2元
type tinyint unsigned (NULL) NO   0   0,普通券;1,秒杀券
status tinyint unsigned (NULL) NO   1   1,上架; 2,下架; 3,过期
create_time timestamp (NULL) NO   CURRENT_TIMESTAMP DEFAULT_GENERATED 创建时间
update_time timestamp (NULL) NO   CURRENT_TIMESTAMP DEFAULT_GENERATED on update CURRENT_TIMESTAMP 更新时间

tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息

FieldTypeCollationNullKeyDefaultExtraComment
voucher_id bigint unsigned (NULL) NO PRI (NULL)   关联的优惠券的id
stock int (NULL) NO   (NULL)   库存
create_time timestamp (NULL) NO   CURRENT_TIMESTAMP DEFAULT_GENERATED 创建时间
begin_time timestamp (NULL) NO   CURRENT_TIMESTAMP DEFAULT_GENERATED 生效时间
end_time timestamp (NULL) NO   CURRENT_TIMESTAMP DEFAULT_GENERATED 失效时间
update_time timestamp (NULL) NO   CURRENT_TIMESTAMP DEFAULT_GENERATED on update CURRENT_TIMESTAMP 更新时间
  • 平价券由于优惠力度并不是很大,所以是可以任意领取
  • 而代金券由于优惠力度大,所以像第二种券,就得限制数量,从表结构上也能看出,特价券除了具有优惠券的基本信息以外,还具有库存,抢购时间,结束时间等等字段

新增普通券

/**
 * 新增普通券
 * @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());
}

新增秒杀券业务逻辑

@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
    // 保存优惠券
    save(voucher);
    // 保存秒杀信息
    SeckillVoucher seckillVoucher = new SeckillVoucher();
    // 关联普通券id
    seckillVoucher.setVoucherId(voucher.getId());
    // 设置库存
    seckillVoucher.setStock(voucher.getStock());
    // 设置开始时间
    seckillVoucher.setBeginTime(voucher.getBeginTime());
    // 设置结束时间
    seckillVoucher.setEndTime(voucher.getEndTime());
    // 保存信息到秒杀券表中
    seckillVoucherService.save(seckillVoucher);
}

实现秒杀下单

  • 那我们现在来分析一下怎么抢优惠券
    • 首先提交优惠券id,然后查询优惠券信息
    • 之后判断秒杀时间是否开始
      • 开始了,则判断是否有剩余库存
        • 有库存,那么删减一个库存
          • 然后创建订单
        • 无库存,则返回一个错误信息
      • 没开始,则返回一个错误信息
  • 对应的流程图如下

 

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

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Autowired
    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("库存不足!");
        }
        // 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");
        //6.2 设置用户id
        Long id = UserHolder.getUser().getId();
        //6.3 设置代金券id
        voucherOrder.setVoucherId(voucherId);
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(id);
        //7. 将订单数据保存到表中
        save(voucherOrder);
        //8. 返回订单id
        return Result.ok(orderId);
    }
}

 

库存超卖问题分析

//4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
    return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
}
//5. 扣减库存
boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
if (!success) {
    return Result.fail("库存不足");
}
  • 假设现在只剩下一张优惠券,线程1过来查询库存,判断库存数大于1,但还没来得及去扣减库存,此时库线程2也过来查询库存,发现库存数也大于1,那么这两个线程都会进行扣减库存操作,最终相当于是多个线程都进行了扣减库存,那么此时就会出现超卖问题
  • 超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案
    1. 悲观锁
      • 悲观锁认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行
      • 例如Synchronized、Lock等,都是悲观锁
    2. 乐观锁
      • 乐观锁认为线程安全问题不一定会发生,因此不加锁,只是在更新数据的时候再去判断有没有其他线程对数据进行了修改
        • 如果没有修改,则认为自己是安全的,自己才可以更新数据
        • 如果已经被其他线程修改,则说明发生了安全问题,此时可以重试或者异常
  • 悲观锁:悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等
  • 乐观锁:乐观锁会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如CAS
  • 乐观锁的典型代表:就是CAS(Compare-And-Swap),利用CAS进行无锁化机制加锁,var5 是操作前读取的内存值,while中的var1+var2 是预估值,如果预估值 == 内存值,则代表中间没有被人修改过,此时就将新值去替换 内存值

使用stock来充当版本号,在扣减库存时,比较查询到的优惠券库存和实际数据库中优惠券库存是否相同

// 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();
        if (!success) {
            return Result.fail("库存不足");
        }
  • 以上逻辑的核心含义是:只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败
  • 那么我们继续完善代码,修改我们的逻辑,在这种场景,我们可以只判断是否有剩余优惠券,即只要数据库中的库存大于0,都能顺利完成扣减库存操作
// 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 = ?
                .gt("stock", 0)
                .update();
        if (!success) {
            return Result.fail("库存不足");
        }