Redis学习之秒杀业务

秒杀业务的核心流程是:判断日期和库存、扣减库存、创建订单

如下图:

image-20231008144119363

注意,秒杀库存信息和商品信息最好是独立的两张表,不要放在一起影响性能。

 @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("库存不足!");
        }
        //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);
​
        return Result.ok(orderId);
​
    }

订单超卖问题

出现的原因:有多个人同时下单,在库存扣减前大家查到的库存都大于0,所以都 触发了扣库存操作。

image-20231008144420766

解决方法

image-20231008144454250

1)悲观锁:假定每次并发都会冲突,所以干脆给操作整体加锁,将并发改为同步执行。 可以通过 synchronized 关键字实现。 优点是实现简单,缺点是严重影响性能(大家可以同时抢购)。

2)乐观锁:假定并发不一定会冲突,所以不加锁,而是通过判断数据是否在查出来之后被其他线程修改过,来决定是否允许操作。

乐观锁主要有版本号法和 CAS 两种实现方式。

版本号法:

给数据增加一个版本号字段,每次修改操作版本号 + 1,就可以通过版本号来判断数据是否有被修改。

image-20231008144613223

CAS 是对乐观锁的简化,即直接用一个每次都会查询和更新的字段来代替版本号,比如库存 stock 字段:

image-20231008144717818

优点:性能好

缺点:存在成功率低的问题(很多人查到的版本号是一样的,结果只能有一个人操作成功),可以使用分段锁来改进。比如将 100 个库存分为 10 份,大家分别抢这 10 份。

 

对于以上这种场景,其实不用判断 stock 是否变化,可以直接判断 stock > 0,从而保障成功率。

boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0

一人一单

优惠券或者秒杀活动的目的是为了吸引新用户,因此不能让一个用户把所有东西都抢走了。所以需要额外判断用户当前下单数是否 > 0。

这一步操作在多线程场景下依然会出现问题:新用户第一次进来同时抢 10 次,结果判断下单数都是 0,然后就都抢成功了。

所以还是需要加锁,因为订单是新创建的数据,所以无法使用乐观锁,使用悲观锁实现。

单机实现

单机部署后端服务器时,可以使用 Java 自带的 Synchronized 关键字作为悲观锁。 要注意以下几个细节问题:

1)synchronized 锁的范围不能太大,不能锁住整个对象,会严重影响性能。因为是一人一单,所以可以每个用户一把独立的锁。 注意,锁住对象时要用 toString.intern,保证同 id 的用户始终是同一个对象:

synchonized(userId.toString().intern()) { 
    xxx 
}

2)synchonized 必须在使用 @Transactional 注解的方法外层使用,因为 @Transactional 是使用动态代理,在方法执行结束后才提交事务。如果把 synchronized 写在事务方法内,提交事务前锁已经释放,但此时数据还未更新,其他线程依然能获取锁并顺利执行。 3)调用事务方法时不能用 this 对象,因为 @Transactional 注解实际上是调用 Spring 生成的代理对象的方法,如果调用 this 对象的方法会无法使用事务功能,所以要获取代理对象并调用。

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

@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.一人一单逻辑
    // 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("用户已经购买过一次!");
    }
​
    //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);
​
}

存在问题:现在的问题还是和之前一样,并发过来,查询数据库,都不存在订单,所以我们还是需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作

注意:在这里提到了非常多的问题,我们需要慢慢的来思考,首先我们的初始方案是封装了一个createVoucherOrder方法,同时为了确保他线程安全,在方法上添加了一把synchronized 锁

@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
​
    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") // 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);
}

,但是这样添加锁,锁的粒度太粗了,在使用锁过程中,控制锁粒度 是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会锁住,所以我们需要去控制锁的粒度,以下这段代码需要修改为: 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);
    }
}

但是以上代码还是存在问题,问题的原因在于当前方法被spring的事务控制,如果你在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题:如下:

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

image-20231008150649381

但是以上做法依然有问题,因为你调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务

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

分布式实现

Synchronized 关键字只对单个 JVM 有效,多机部署时还是可能会同时有多个不同 JVM 的线程访问已加锁的方法。

如下图:

image-20231008150926964

 

因此,我们不能把锁存储到单个服务器上,而是应该使用一个集中的存储来管理锁,所有进程都能读到它。 这就需要分布式锁。

posted @ 2023-10-08 15:10  万事胜意k  阅读(46)  评论(0编辑  收藏  举报