黑马点评day03优惠券秒杀(超卖问题)

总览:

1、全局ID生成器

观察到未使用AUTO_INCRE

当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:

* id的规律性太明显
* 受单表数据量的限制

场景分析:如果我们的id具有太明显的规则,用户或者说商业对手很容易猜测出来我们的一些敏感信息,比如商城在一天时间内,卖出了多少单,这明显不合适。

场景分析二:随着我们商城规模越来越大,mysql的单表的容量不宜超过500W,数据量过大之后,我们要进行拆库拆表,但拆分表了之后,他们从逻辑上讲他们是同一张表,所以他们的id是不能一样的, 于是乎我们需要保证id的唯一性。

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:
ID:long型,8字节,64bit
ID的组成部分:符号位:1bit,永远为0,正数 
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

RedisWorker.java:

/**     
* 由正常年月日(20220101)转换为时间戳     
*/    
public Long makeTime(){
        LocalDateTime now = LocalDateTime.of(2022, 1, 1, 0, 0, 0);        
        long second=now.toEpochSecond(ZoneOffset.UTC);        
        return second;    
        }
/**     
* 开始时间戳     
*/    
    private static final long BEGIN_TIMESTAMP = 1640995200L;    
/**     
* 序列号的位数     
*/    
    private static final int COUNT_BITS = 32;    
    @Autowired    
    private RedisUtil redisUtil;    
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.redis中自增长记录总数量(条)        
    long count=redisUtil.incr("icr:" + keyPrefix + ":" + date,1);        
// 3.拼接并返回生成的ID        
    return timestamp << COUNT_BITS | count;    
}
}

2、实现优惠券秒杀下单

2.1代码实现

流程:

voucherOrderServiceImpl.java:

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

    @Autowired    
    private ISeckillVoucherService seckillVoucherService;    
    @Autowired    private RedisIdWorker redisIdWorker;    
    @Transactional    
    @Override    
    public Result seckillVoucher(Long voucherId) {
//        1.查询优惠券        
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
//        2.判断秒杀是否开始        
    if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            // 尚未开始            
            return Result.fail("秒杀尚未开始");        
            }
//        3.判断秒杀是否已经结束        
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            // 已经结束            
            return Result.fail("秒杀已经结束");        }
//       4.判断库存是否充足        
if (seckillVoucher.getStock() < 1) {
            // 库存不足            
            return Result.fail("库存不足");        }
        //5,扣减库存        
        boolean success=this.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);        
        this.save(voucherOrder);        
        return Result.ok(orderId);    
        }
}

超卖问题分析:

问题:

高并发情况下出现超卖

假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。

示例:

JMeter设置:

200个线程同时访问:

我们这里使用一个用户token来模拟高并发

这里由于代码设置了refreshInterceptor检查,所以需要携带token:

设置JSON断言,表示如果success==true则为请求成功

结果:

观察到200个线程中存在失败

查看数据库:

共有108条订单,且stock值 == -8,表示超卖了 8 个

悲观锁&&乐观锁:

悲观锁:插入数据时

乐观锁:更新数据时

选用乐观锁实现超卖解决:

选用乐观锁原因:因为此处主要是对stock数据做判断操作,为更新数据,则使用乐观锁

乐观锁两种方式:

方式一:version法

查询库存的同时查询版本号,关键在于减库操作时同时判断version与先前查找的是否一致,如果一致,表明中间过程没有其他线程修改version

方式二:cas法

(改进版本号法)CAS法:compare and switch

因为方式一库存与版本均被查找和跟新 因此 可使用库存代替版本

修改扣减库存方法:

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

JMeter测试,观察到异常达到90%,仅生成21条订单,stock远>0

以上逻辑的核心含义是:只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败
个人理解:若200个线程同时拿到stock的值,则任意线程stock值均一致,此时后续只有同一时间点开始的一批线程成功比较前后stock值一致,后续均不可满足条件,故仅卖出21条
乐观锁弊端:成功率可能会低
之前的方式要修改前后都保持一致,但是这样我们分析过,成功的概率太低,所以我们的乐观锁cas法需要变一下,改成stock大于0 即可
boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0

JMeter测试,观察到异常为50%,仅生成100条订单,stock=0

实现200个线程只有100(stock=100)个可以实现,并保证了stock=0,不会出现超卖

总结:

3、保证一人一单问题

流程:

具体操作逻辑如下:比如时间是否充足,如果时间充足,则进一步判断库存是否足够,然后再根据优惠卷id和用户id查询是否已经下过这个订单,如果下过这个订单,则不再下单,否则进行下单

直接修改(不加锁):

直接修改seckillvoucher方法:加入如下代码,但这样仍然无法确保一人一单,原因是若一个用户同时发起100条购买请求,这样查找出来的count都等于0,那这样100条线程都可以买了,仍无法实现一人一单

// 5.一人一单逻辑
    // 5.1.用户id
    Long userId = UserHolder.getUser().getId();
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    // 5.2.判断是否存在
    if (count > 0) {
        // 用户已经购买过了
        return Result.fail("用户已经购买过一次!");
    }

**存在问题:**现在的问题还是和之前一样,并发过来,查询数据库,都不存在订单,所以我们还是需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作
**注意:**在这里提到了非常多的问题,我们需要慢慢的来思考,首先我们的初始方案是封装了一个createVoucherOrder方法,同时为了确保他线程安全,在方法上添加了一把synchronized 锁

改进版1:

修改voucherOrderServiceImpl:

seckillVoucher():

public Result seckillVoucher(Long voucherId) {
//        1.查询优惠券        
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
//        2.判断秒杀是否开始        
    if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            // 尚未开始            
            return Result.fail("秒杀尚未开始");        
            }
//        3.判断秒杀是否已经结束        
    if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            // 已经结束            
            return Result.fail("秒杀已经结束");        
            }
//       4.判断库存是否充足        
    if (seckillVoucher.getStock() < 1) {
            // 库存不足            
            return Result.fail("库存不足");        
          }
        return this.createVoucherOrder(voucherId);
    }    

封装判断一人一单、修改库存和创建订单 为 createVoucherOrder():

@Transactional
public Result createVoucherOrder(Long voucherId) {
    //        5.0一人一单    
    // 5.1.用户id    
    Long userId = UserHolder.getUser().getId();    
    /*这里锁不放在方法上,这样做是因为若放在方法上,表明每当有用户来就会加锁,不管是不是同一个用户     
    *而是放在userId上,表明只对同一用户(查找UserId值一样的)加锁,避免资源过度消耗,锁粒度更细     
    */    
    /*     
    *这里使用userId转为string,目的是因为字符串常量池的对象是唯一的,及时代码上new一个对象,但只要值相同,不会再重复     
    * userId(Long)转string 源码上是new了一个string对象,故要使用String.intern()表明先去常量池找到若equals相等(值一样),则返回其对象     
    * 保证了一个userId对应一个String对象     
    */    
    /*     
    *这里有问题:     
    * 锁释放在synchorionzed{}执行完毕,而方法事务提交在@Transactional注解的方法执行完     
    * 如果在方法内部加锁,会导致锁释放了,而事务仍然没有提交,一但锁释放就会有线程进入,会导致问题发生,所以我们需要选择将当前方法整体包裹起来,确保事务不会出现问题,由此使用改进版2     
    */    
    synchronized (userId.toString().intern()) {
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();        
        // 5.2.判断是否存在        
        if (count > 0) {
            // 用户已经购买过了            
            return Result.fail("用户已经购买过一次!");        
            }
        //5,扣减库存        
        boolean success = this.seckillVoucherService.update()
                .setSql("stock=stock-1")
                .eq("voucher_id", voucherId)
                .gt("stock", 0)
                .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);        
        this.save(voucherOrder);        
        return Result.ok(orderId);    
        }
}

改进版2:

基于改进版1做出修改:

修改seckillVoucher():

public Result seckillVoucher(Long voucherId) {
//        1.查询优惠券        
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
//        2.判断秒杀是否开始        
    if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            // 尚未开始            
            return Result.fail("秒杀尚未开始");        
            }
//        3.判断秒杀是否已经结束        
    if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            // 已经结束            
            return Result.fail("秒杀已经结束");        
            }
//       4.判断库存是否充足        
    if (seckillVoucher.getStock() < 1) {
            // 库存不足            
            return Result.fail("库存不足");       
          }
        Long userId = UserHolder.getUser().getId();          
       synchronized (userId.toString().intern()) {
    return this.createVoucherOrder(voucherId);}
/* 
*改进版2在此处加锁,表明只有在事务方法createVoucherOrder执行完提交后后,才释放锁 
* 保证了不会有锁释放前不会有其他线程 (锁的唯一性)
*/
/*异步,事务这些注解生效的原理,在于通过切面创建了代理类,通过操作代理类实现了异步,事务
 *在同一类中声明异步、事务时,则不会创建代理类,故事务不会生效
 *此处存在问题,我们只对createVoucherOrder加了事务,未对外部方法seckillVoucher添加事务
 *因此采用this直接调用同类方法导致事务无法生效
 */
  }    

由于this调用会导致事务失效,故采用注入自身方法

声明后注入类后,调用方法(需要在Service接口中定义方法)

结果测试:

JMeter测试:200个请求同时发起,只有一个成功

数据库只有一个订单,且stock=99,表明用户只可购买一次

集群部署下出现并发安全问题:

观察到:

使用postman携带同一用户token验证一人一单:

数据库生成两个订单,证明在集群部署情况下无法保证一人一单

多台jvm:

产生安全问题原因:集群部署下或在分布式系统中,有多个jvm的存在,每个jvm都有自己的锁,导致每个锁都有线程获取。

需要实现多个jvm使用同一把(套)锁:跨jvm锁、跨进程锁

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值