Redis的优惠券秒杀问题(六)超卖问题、一人一单问题

Redis的优惠券秒杀问题(六)超卖问题、一人一单问题 

超卖问题

问题描述

使用Jmeter进行压测 

发生超卖问题原因分析 

解决方案 

悲观锁与乐观锁

1. 版本号 

2. CAS法 

CAS三大问题(题外话!)

CAS三大问题的解决方案

代码实现 

一人一单问题

问题描述 

流程设计

解决方案 

代码实现 

代码中技术点分析 

1. intern()

2. 事务失效问题 currentProxy()


Redis的优惠券秒杀问题(六)超卖问题、一人一单问题 

超卖问题

问题描述

接上一篇文章。在上一篇文章中,我们实现了秒杀下单的业务,有请求过来,只要库存充足,就进行减库存,生成订单的操作。

但是这样子在多线程高并发的场景下,一定会出现问题。

使用Jmeter进行压测 

我们可以用 jmeter工具 复现一下场景,具体配置如下 

配置authorization 

运行程序,登入用户后,打开F12,获取authorization 的值

启动 Jmeter,进行压测,我们这里开了200个线程

我们这里设定有100个库存,所以正常的情况应该会有一半的线程(100个)的HTTP请求出现异常,但是这里显然不是! 

查看数据库 tb_sckill_voucher表

stock为负数超卖问题发生! 

订单表 tb_voucher_order表 也是如此 

发生超卖问题原因分析 

解决方案 

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

所以我们现在要研究是就是要加什么类型的锁?要怎么加锁?在哪里加锁?

悲观锁与乐观锁

悲观锁添加同步锁,让线程串行执行
  优点简单粗暴
  缺点性能一般
乐观锁不加锁,在更新时判断是否有其它线程在修改
  优点性能好
  缺点存在成功率低的问题

先提一下,并不是说有一种锁叫乐观锁、叫悲观锁,“悲观”、“乐观”只是用来形容一种思想,一种方式!!!

相较于悲观锁而言, 乐观锁机制采取了更加宽松的加锁机制。自然性能方面会优于悲观锁!

在这个问题中我们用乐观锁来解决!最常见的方式有两个“版本号”、“CAS”

1. 版本号 

但是,显然,在之前数据库设计的时候,没有version这个字段

我们如果想要用这个方案也可以,但是比较麻烦!

2. CAS法 

CAS是乐观锁的一种实现,CAS全称是比较和替换,CAS的操作主要由以下几个步骤组成

  1. 先查询原始值
  2. 操作时比较原始值是否修改
  3. 如果修改,则操作失败,禁止更新操作,如果没有发生修改,则更新为新值

伪代码 

do{
    备份旧数据;
    基于旧数据构造新数据;
}while(!CAS( 内存地址,备份的旧数据,新数据 ))

CAS三大问题(题外话!)

扯点别的,CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题:

  1. ABA问题
  2. 循环时间长开销大
  3. 只能保证一个共享变量的原子操作

CAS三大问题的解决方案

CAS的三个问题及解决方案_渣一个的博客-CSDN博客_业务层cas 解决死循环icon-default.png?t=M85Bhttps://blog.csdn.net/weichi7549/article/details/107734843

代码实现 

@Resource
private ISeckillVoucherService seckillVoucherService;

@Resource
private RedisIdWorker redisIdWorker;

@Override
public Result seckillVoucher(Long voucherId) {
    // 1. 查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    LocalDateTime nowTime = LocalDateTime.now();

    // 2. 判断秒杀是否开始
    if (nowTime.isBefore(voucher.getBeginTime())) {
        return Result.fail("活动未开始!");
    }

    // 3. 判断秒杀是否结束
    if (nowTime.isAfter(voucher.getEndTime())) {
        return Result.fail("活动已结束!");
    }

    // 4. 判断库存
    if (voucher.getStock() < 1) {
        return Result.fail("已买完!");
    }

    // 5. 减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId).gt("stock", 0)  // CAS方案(乐观锁)!
            .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
    Long userId = UserHolder.getUser().getId();
    voucherOrder.setUserId(userId);
    // 6.3代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);
    return Result.ok(orderId);
}

核心是我们在减库存的时候,判断一下stock是否还大于0。

.gt("stock", 0) 

我们这边是只要库存是大于0的就都可以购买,所以对于这个点,乐观锁的判断可以适当放宽,只对库存为0时的“减库存”操作加锁,对应的SQL:

update tb_seckill_voucher 
set stock = stock - 1 
where voucher_id = 18 and stock > 0

一人一单问题

问题描述 

什么是一人一单问题?简单的来说就是模拟为了防止黄牛”屯“货而设计的,每一个用户ID,只能下一单!如下图,同一个用户下了很多单!!!

所以我们要修改秒杀业务,要求同一个优惠券,一个用户只能下一单 

流程设计

解决方案 

我们先获取一下用户的id,如果是相同的用户id“同时”执行到这里,只能允许一个进入该逻辑,执行减库存,生成订单的逻辑!其它的必须在此阻塞

代码实现 

@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);
        LocalDateTime nowTime = LocalDateTime.now();

        // 2. 判断秒杀是否开始
        if (nowTime.isBefore(voucher.getBeginTime())) {
            return Result.fail("活动未开始!");
        }

        // 3. 判断秒杀是否结束
        if (nowTime.isAfter(voucher.getEndTime())) {
            return Result.fail("活动已结束!");
        }

        // 4. 判断库存
        if (voucher.getStock() < 1) {
            return Result.fail("已买完!");
        }

        Long userId = UserHolder.getUser().getId();
        // 如果字符常量池中已经包含一个等于此String对象的字符串,则返回常量池中字符串的引用
        // 对于任意两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true
        synchronized (userId.toString().intern()) {
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
    }

    @Transactional
    public Result createVoucherOrder(Long voucherId) {
        // 一人一单
        Long userId = UserHolder.getUser().getId();

        Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0){
            return Result.fail("用户已经购买过一次了!");
        }

        // 5. 减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId).gt("stock", 0)  // CAS方案(乐观锁)!
                .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);
        return Result.ok(orderId);
    }
}

代码中技术点分析 

1. intern()

Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
    ...      
}

intern() 方法返回字符串对象的规范化表示形式

对于任意两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true

String.intern()是一个Native方法,它的作用是:如果字符常量池中已经包含一个等于此String对象的字符串,则返回常量池中字符串的引用(不会新new一个),否则,将新的字符串放入常量池,并返回新字符串的引用。

在该业务场景中,会有多个线程会同时执行该逻辑。会生成多个userId,这里是要给相同的userId加锁!但是toString()方法会生成一个新的字符串对象

如果不使用 intern() ,尽管这些字符串的值相同,它们的内存地址也会不同!!!所以并不会把它们认定为相同的字符串!

2. 事务失效问题 currentProxy()

如果我们这里不生成其代理对象,则会导致事务失效

synchronized (userId.toString().intern()) {
    return createVoucherOrder(voucherId);
}

在Spring中,事务的实现方式,是对当前类(VoucherOrderServiceImpl)做了动态代理用其代理对象去做事务处理!

但是这里如果是上述代码, 实际上是this.createVoucherOrder(voucherId),这个this指的是VoucherOrderServiceImpl,是非代理对象,是没有事务功能!!!所以如果代码这样子写,@Transactional标注的事务会失效!!!

解决办法 

synchronized (userId.toString().intern()) {
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createVoucherOrder(voucherId);
}

获取到当前对象的代理对象 

AopContext.currentProxy() 

然后再用该代理对象来调用方法

proxy.createVoucherOrder(voucherId);  

这么改完之后还要在启动类上面加上注解,用来暴露代理对象

@EnableAspectJAutoProxy(exposeProxy = true)    // 默认是关闭的

@EnableAspectJAutoProxy(exposeProxy = true) // 暴露代理对象!
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {
    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }
}
posted @   金鳞踏雨  阅读(320)  评论(0编辑  收藏  举报  
相关博文:
阅读排行:
· 清华大学推出第四讲使用 DeepSeek + DeepResearch 让科研像聊天一样简单!
· 实操Deepseek接入个人知识库
· 易语言 —— 开山篇
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 【全网最全教程】使用最强DeepSeekR1+联网的火山引擎,没有生成长度限制,DeepSeek本体
点击右上角即可分享
微信分享提示