Redis实战—优惠卷秒杀(锁/事务/代理对象的应用)

本博客为个人学习笔记,学习网站与详细见:黑马程序员Redis入门到实战 P50 - P54 

目录

优惠卷秒杀下单功能实现

超卖问题

悲观锁与乐观锁

实现CAS法乐观锁

一人一单功能实现

代码优化 

代码细节分析 


优惠卷秒杀下单功能实现

Controller层代码实现

@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {

    @Resource
    private IVoucherOrderService voucherOrderService;

    @PostMapping("seckill/{id}")
    public Result seckillVoucher(@PathVariable("id") Long voucherId) {
        return voucherOrderService.seckillVoucher(voucherId);
    }
}

Service层代码实现

//接口类
public interface IVoucherOrderService extends IService<VoucherOrder> {
    Result seckillVoucher(Long voucherId);
}


//实现类
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdWorker redisIdWorker;

    @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.扣减库存
        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");
        voucherOrder.setId(orderId);
        // 6.2用户id
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        // 6.3代金卷id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        // 7.返回订单id
        return Result.ok(orderId);
    }
}

超卖问题

假设秒杀优惠卷库存为1,正常情况抢票逻辑如下图所示。 

假设秒杀优惠卷库存为1,高并发情况抢票逻辑如下图所示。 


悲观锁与乐观锁

由于悲观锁采用串行执行线程的方式,因此性能劣于乐观锁。

乐观锁的关键是判断之前查询到的数据是否被修改过,常见的方式有以下两种:

1.版本号法
为每种秒杀优惠卷添加版本号属性,每当该优惠卷的库存被更改时,令版本号也发生变化。因此只需要在查询库存的同时查询并记录版本号,在修改库存时,通过查询当前版本号并判断其是否与先前记录的版本号一致来判断查询到的库存是否已经被其他线程修改过。

2.CAS法(即版本号法的简化,利用库存代替版本号)


实现CAS法乐观锁

首先查询秒杀优惠卷信息并保存到变量voucher,当判断该优惠卷可以被抢购后,在修改该优惠卷库存时,需要满足当前优惠卷库存与voucher.getStock()相等的条件。

尽管添加了乐观锁,但在测试时发现用户抢购优惠卷的成功率过低。原因在于,在高并发的情况下,假设10个用户同时抢购一张优惠卷,那么只有一个用户能够抢购成功,其他用户均抢购失败。而在抢票的业务场景下,当用户同时进行抢购时,只要优惠卷库存大于0即可进行抢购,因此只需修改判断条件,在修改库存时,判断当前库存是否大于0即可。


一人一单功能实现

修改Service层实现类中优惠卷秒杀下单seckillVoucher方法,代码(未优化版本)如下:

@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.一人一单
    Long userId = UserHolder.getUser().getId();
    //5.1查询订单
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    //5.2判断是否存在
    if(count>1){
        //用户已经购买过了
        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);
    //8.返回订单ID
    return Result.ok(orderId);
}

代码优化 

存在的问题:尽管代码经过修改,但在高并发的情况下仍然存在问题,即单个用户在同一刻多次下单,多个线程均查询到count=0(该用户未购买过当前优惠券),此时多个线程同时创建订单,从而导致一人多单问题。

实现:为实现一人一单功能,我们需要加锁,相比超卖问题中每个线程只是对数据进行修改,所以可以使用乐观锁,通过判断数据值是否被修改来判断其他线程是否已经对当前数据进行操作。但一人多单的问题中,每个线程需要进行添加数据而非修改数据的操作,即创建新的订单,所以这种情况下,我们需要使用悲观锁。

优化后的代码如下所示:

@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);
        // 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();
        synchronized (userId.toString().intern()) {
            // 获取当前代理对象
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
    }

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

        // 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")  //使用MP,设置sql语句
                .eq("voucher_id", voucherId)
                .gt("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);
        // 8.返回订单id
        return Result.ok(orderId);
    }
}

代码细节分析 

有以下几个细节我们需要特别注意

1. 首先,我们将查询、判断、创建订单等过程封装成createVoucherOrder方法,但是我们并不能直接对这个方法加锁,如果对这个方法加锁的话将导致创建订单的业务是串行执行的,在高并发多用户的情况下,性能会很差。

2. 因为是一人一单功能,我们需要做的是对同一个用户创建订单的流程进行加锁,所以我们根据用户ID来加锁。我们为相同的用户ID配置一把锁,从而将锁的范围缩小,因此我们将createVoucherOrder方法进行修改,如下所示。

3. 然而,我们需要确保synchronized()传入的参数对象是相同的,synchronized锁才能起作用。因此我们不传入查询得到的userId对象,因为我们每次查询后都会赋值给新的userId对象,这样会导致传入synchronized()的参数始终不是同一个对象,因此我们采用的是synchronized(userId.toString())。

4.但是,如果我们直接使用userId.toString() 方法,拿到的对象实际上是不同的,toString() 的底层逻辑是new出一个新对象,因此我们需要使用intern()方法,该方法是从常量池中拿到数据,能确保我们每次取得的对象都是相同的。得到的代码如下所示。

@Transactional
//将创建订单封装成一个方法
public  Result createVoucherOrder(Long voucherId) {
    //获取用户Id
    Long userId = UserHolder.getUser().getId();

    synchronized(userId.toString().intern()) {
        查询、判断、创建订单等流程...
    }
}

//错误情况:synchronized(userId.toString())

5.由于我们是在方法里面加锁,因此代码的逻辑是先释放锁,再提交事务,因此在高并发的环境下可能会出现安全问题(即释放锁后,提交事务还未完成,其他线程又进来了)。所以我们应该先提交事务,再释放锁。因此,我们应该在方法外加锁,代码如下。

@Override
public Result seckillVoucher(Long voucherId) {

    ...

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

6.在以上代码中,我们调用createVoucherOrder方法,其实是通过this.的方式调用的,而this代表的是当前VoucherOrderServiceImpl对象,并不是createVoucherOrder的代理对象,因此事务是不生效的。要想让事务生效,我们需要利用代理对象调用createVoucherOrder方法,才能使事务生效,代码如下。

@Override
public Result seckillVoucher(Long voucherId) {

    ...

    Long userId = UserHolder.getUser().getId();
    synchronized (userId.toString().intern()) {
        // 获取当前代理对象
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.createVoucherOrder(voucherId);
    }
}

此外,我们还需做以下三步。
1.添加动态代理依赖

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

2.在接口中添加createVoucherOrder(Long voucherId); 

public interface IVoucherOrderService extends IService<VoucherOrder> {
    Result seckillVoucher(Long voucherId);
    
    Result createVoucherOrder(Long voucherId);
}

3.在启动类中添加注解,暴露该代理对象

@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {

    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }

}

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值