【Redis】优惠券秒杀 —— 一人一单

一、业务分析

需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单。

为什么要有这样的需求呢?思考一下,像这种秒杀券(特有券),它的优惠力度非常大,商家可能会赔本,那这种券的目的是什么?它的目的只有一个,就是利用这样的券吸引更多的用户来我们店中体验,体验了后如果口碑好,那就可以传播出去,吸引更多的用户来。

现在的问题在于:

优惠卷是为了引流,但是目前的情况是,一个人可以无限制的抢这个优惠卷,所以我们应当增加一层逻辑,让一个用户只能下一个单,而不是让一个用户下多个单

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

image-20240527201135771

VoucherOrderServiceImpl

初步代码:增加一人一单逻辑

@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.一人一单逻辑
    // 5.1.用户id
    Long userId = UserHolder.getUser().getId();
    // 这里不需要查具体数据了,只需要查count值
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    // 5.2.判断是否存在
    if (count > 0) {
        // 用户已经购买过了
        return Result.fail("用户已经购买过一次!");
    }

    //6,扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update();
    if (!success) {
        //扣减库存
        return Result.fail("库存不足!");
    }
    //7.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 7.1.订单id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);

    voucherOrder.setUserId(userId);
    // 7.3.代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);

    return Result.ok(orderId);
}

**存在问题:**现在的问题还是和之前一样,并发过来,查询数据库,都不存在订单,所以我们还是需要加锁,否则一个用户还是有可能会下多个单。但是乐观锁比较适合更新数据,因为可以判断某个字段是否修改。而现在是插入数据,没法判断某个字段是否修改,所以我们需要使用悲观锁操作

其中我们应该给 判断订单新增订单 这整段加上悲观锁


二、代码实现

1)优化一

因此可以 ctrl + alt + M 将其封装成一个函数

image-20240527202821782

由于我们是要在 createVoucherOrder 整个方法上都需要加上锁,直接使用同步方法就行了,此时的同步锁就是 this,即当前对象,因此肯定是线程安全的,因为当前对象肯定是唯一的。

另外我们事务的范围其实是更新数据库的服务,也就是做减库的操作和创建订单的操作,而不是整个方法,因为前面是查询,不用加事务,所以 seckillVoucher方法 上的事物也去掉,而是给下面的方法加上事务

image-20240527203718743

2)优化二

但是需要注意的是,不建议将 synchronized 直接加在方法上,因为你加在方法上锁的范围就变成了整个方法,而且锁的对象是this,也就意味着不管是任何一个用户来了,都要加这个锁,而且大家是同一把锁,也就意味着整个方法就是串行执行了,性能就很差了。

但是大家思考下:所谓的一人一单,我们是同一个用户来了,我们才去判断他的并发安全问题。但如果不是同一个用户,一个张三、一个李四,就不需要加同一把锁了,各做各的就行了。

因此我认为这个地方加的锁不应该是这个 service,即 this,而是当前用户。

我们可以以用户 id 加锁,这样的话我们就可以将锁的范围缩小了,也就是说同一个用户加一把锁,不同用户加不同锁。

专业一点说:但是这样添加锁,锁的粒度太粗了,在使用锁过程中,控制 锁粒度 是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会锁住,所以我们需要去控制锁的粒度,以下这段代码需要修改为:
intern() 这个方法是从常量池中拿到数据,如果我们直接使用userId.toString() 他拿到的对象实际上是不同的对象,new出来的对象,我们使用锁必须保证锁必须是同一把,所以我们需要使用intern()方法

@Transactional
public  Result createVoucherOrder(Long voucherId) {
	Long userId = UserHolder.getUser().getId();
	synchronized(userId.toString().intern()){
         // 5.1.查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 5.2.判断是否存在
        if (count > 0) {
            // 用户已经购买过了
            return Result.fail("用户已经购买过一次!");
        }

        // 6.扣减库存
        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();
        if (!success) {
            // 扣减失败
            return Result.fail("库存不足!");
        }

        // 7.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 7.1.订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 7.2.用户id
        voucherOrder.setUserId(userId);
        // 7.3.代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

        // 7.返回订单id
        return Result.ok(orderId);
    }
}

但是这里还要跟大家强调一点:userId.toString() 我们期望的是id值一样的作为一把锁,但你要知道,每一个请求来这个id对象都是一个全新的id对象,因此这个对象变了锁就变了,但是我们要求的是值一样,所以在这里用了 toString(),但是 toString() 就能保证它是按照值来加锁的吗?

我们看一下toString底层,可以发现在底层调用的是long的一个静态的特征函数,在它的内部其实是 new 了一个字符串。

image-20240527205530520

那也就是说,我们刚才的代码中,每调一次 toString() 也是一个全新的字符串对象,也就是说你这个锁的对象还是在变,哪怕你id是一样的,它每次也是new,因此还是一个全新的对象。

此时就需要调用字符串的 intern() 方法,这个方法的作用是返回字符串的规范表示,即去字符串常量池中寻找跟你值一样的那个字符串的地址返回给你,即你的值给你。

因此无论你这里new了多少个字符串,只要你的值是一样的,那最终的返回结果也就是一样的,这样就可以确保:当用户id一样时,锁就一样。

image-20240527205830754

而不同用户就不会被锁定,此时我们整体的锁定范围变小了,性能就会得到很大的提升了。


3)优化三

但是以上代码还是存在问题,问题的原因在于当前方法被spring的事务控制,如果你在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题,因此当方法结束后,锁其实就已经释放了,锁释放了就意味着其他线程可以进来了,而此时因为事务尚未提交,如果有其他线程进来去查询订单的话,那我们刚刚新增的这个订单还没有写入数据库,因为你还没提交事务,因此这个线程查询的时候依然不存在,就有可能出现并发安全问题。

因此我们这个锁锁定的范围有点小

所以我们选择将当前方法整体包裹起来,确保事务不会出现问题:如下:

在seckillVoucher 方法中,添加以下逻辑,这样就能保证事务的特性,同时也控制了锁的粒度

1653373434815

此时等 createVoucherOrder函数 执行完,就说明一定是写入数据库了,因为事务已经提交了,等我这块事务提交完,再来释放锁,也就是说锁所释放的这一刻,就可以确保数据库中是有订单了,此时再有其他线程来,你再去完成这个业务的时候,去查询订单的时候,订单肯定存在了,就不会重复下单了。


4)优化四

但是以上做法依然有问题,因为你调用的方法,其实是this.的方式调用的,this 拿到的事当前的 VoucherOrderServiceImpl对象,而不是它的代理对象。

image-20240527210819240

事务想要生效,其实是因为spring对当前这个类做了动态代理,拿到了它的代理对象,用它来做的事务处理。而这里的 this 其实是非代理对象,也就是目标对象,因此它是没有事务功能的,这就是spring事务失效的几种可能性之一,这就是一种可能性。

所以这个地方,我们需要获得原始的事务对象, 来操作事务,借助API:AopContext,它里面的 currentProxy()方法 就可以拿到当前对象的代理对象了

Object proxy = AopContext.currentProxy();

这个对象是 VoucherOrderService 的代理对象,因此使用 VoucherOrderService 来接收就行了,然后做个强转。

它返回的时候是Object,但我们知道肯定是 VoucherOrderService

IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();

最后上使用代理对象来调用 createVoucherOrder方法,而不是使用this,这样的话就会被spring进行管理了,因为这个代理对象是由spring创建的,它是带有事务的函数,这个不存在的原因是因为 VoucherOrderService 接口中是不存在这个函数的,因此我们也将这个函数在 VoucherOrderService 中创建一下。

image-20240527211420360

现在事务就能够生效了

1653383810643

当然你要这么去做还需要做两件事:

1、添加一个依赖,因为这么做后底层会使用aspectj的依赖,是一种动态代理的模式

<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>

2、在启动类上添加 @ 注解,去暴露这个代理对象

可以发现这个值默认是false,默认是不暴露的,这个值需要将它改为true就能暴露

image-20240527211725194

一旦暴露后,在 VoucherOrderServiceImpl 实现类中就可以拿到这个代理对象了

image-20240527211839937

此时我们就能确保这个事务生效了,并且是先去获取锁,最后才创建事务,事务提交后才会释放锁,这样才会避免我们刚才说的因为事务没提交就释放锁的这种安全问题。

此时重新使用JeMeter测试即可,然后查看数据库,发现库存确实是只减少了一个,而且一人一单的问题。

image-20240527212534783

这样我们就解决了一人一单的问题,解决方案同样是加锁,这次我们使用的是悲观锁。只不过我们这次采用了一种特殊的锁对象,即用户ID,减少了锁定资源的范围,从而一定程度上提高了它的性能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值