悲观锁 乐观锁
在没有加锁的秒杀场景下 每秒打进来的请求是巨大的 高并发场景下 我们发现不仅异常率高的可怕 库存竟然还变成了负数 这产生的结果肯定是很大损失的 那为什么会出现超卖问题呢
我们假设有下面两个线程
线程1查询库存,发现库存充足,创建订单,然后准备对库存进行扣减,但此时线程2和线程3也进行查询,同样发现库存充足,然后线程1执行完扣减操作后,库存变为了0,线程2和线程3同样完成了库存扣减操作,最终导致库存变成了负数!这就是超卖问题的完整流程
因此超卖产生了 那我们应该如何解决呢 很简单 直接加锁不就好了 加个互斥锁 一个一个来 那么会出现所有人都在堵塞 秒杀变成小时杀了 肯定不行 所以我先介绍两种锁的机制
- 悲观锁,认为线程安全问题一定会发生,因此操作数据库之前都需要先获取锁,确保线程串行执行。常见的悲观锁有:synchronized、lock
- 乐观锁,认为线程安全问题不一定发生,因此不加锁,只会在更新数据库的时候去判断有没有其它线程对数据进行修改,如果没有修改则认为是安全的,直接更新数据库中的数据即可,如果修改了则说明不安全,直接抛异常或者等待重试。常见的实现方式有:版本号法、CAS操作、乐观锁算法
接下来我们详细分析乐观锁的实现方式:
版本号机制
版本号机制是乐观锁最常见的实现方式。每条数据都有一个版本号,每次更新数据时版本号加1。当线程A要更新数据时,先检查当前版本号是否与自己获取时的版本号一致,如果一致则更新,否则说明数据已被其他线程修改,更新失败。
例如我们可以在商品表中增加一个version字段:
UPDATE product SET stock = stock - 1, version = version + 1
WHERE id = #{id} AND version = #{version}
CAS (Compare And Swap)
CAS是乐观锁的另一种实现方式,它包含三个操作数:内存位置、预期原值和新值。执行CAS操作时,将内存位置的值与预期原值比较,如果相匹配,则将内存位置的值更新为新值。否则,不做任何操作。
public boolean decreaseStock(Long productId, Integer version) {// 查询商品当前库存和版本号Product product = productMapper.selectById(productId);if (product.getStock() <= 0) {return false; // 库存不足}// 使用CAS更新库存和版本号int result = productMapper.decreaseStockWithVersion(productId, product.getVersion(), product.getVersion() + 1);return result > 0;
}
两种锁的适用场景
悲观锁适用于:
- 并发写入多、临界资源争抢激烈的场景
- 读少写多的场景
- 要求数据强一致性的场景
乐观锁适用于:
- 并发写入少、冲突较少的场景
- 读多写少的场景
- 允许短时间数据不一致的场景
乐观锁解决超卖问题
首先我们要为 tb_seckill_voucher 表新增一个版本号字段 version ,线程1查询完库存,在进行库存扣减操作的同时将版本号+1,线程2在查询库存时,同时查询出当前的版本号,发现库存充足,也准备执行库存扣减操作,但是需要判断当前的版本号是否是之前查询时的版本号,结果发现版本号发生了改变,这就说明数据库中的数据已经发生了修改,需要进行重试(或者直接抛异常中断)
**boolean flag = seckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>().eq(SeckillVoucher::getVoucherId, voucherId).gt(SeckillVoucher::getStock, 0).setSql("stock = stock -1"));**
注意到这里**.gt(SeckillVoucher::getStock, 0)而不是eq(SeckillVoucher::getStock,voucher.getStock())**
其实是因为乐观锁的弊端 可能锁住正常订单 例如大家一起获取库存100 一个线程执行成功 其他线程扣减时 发现库存为99 直接不干了 多个线程直接断了 因此我们直接写成第一种即可
一人一单超卖
很容易发现 在判断订单前加上逻辑即可
int count = this.count(new LambdaQueryWrapper<VoucherOrder>().eq(VoucherOrder::getUserId, ThreadLocalUtls.getUser().getId()));if (count >= 1) {// 当前用户不是第一单return Result.fail("用户已购买");}
通过测试,发现并没有达到我们想象中的目标,一个人只能购买一次,但是发现一个用户居然能够购买8次。这说明还是存在超卖问题
问题原因:出现这个问题的原因和前面库存为负数数的情况是一样的,线程1查询当前用户是否有订单,当前用户没有订单准备下单,此时线程2也查询当前用户是否有订单,由于线程1还没有完成下单操作,线程2同样发现当前用户未下单,也准备下单,这样明明一个用户只能下一单,结果下了两单,也就出现了超卖问题
解决方案:一般这种超卖问题可以使用下面两种常见的解决方案
- 悲观锁
- 乐观锁
悲观锁解决超卖问题
/*** 抢购秒杀券** @param voucherId* @return*/@Transactional@Overridepublic Result seckillVoucher(Long voucherId) {// 1、查询秒杀券SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 2、判断秒杀券是否合法if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {// 秒杀券的开始时间在当前时间之后return Result.fail("秒杀尚未开始");}if (voucher.getEndTime().isBefore(LocalDateTime.now())) {// 秒杀券的结束时间在当前时间之前return Result.fail("秒杀已结束");}if (voucher.getStock() < 1) {return Result.fail("秒杀券已抢空");}// 3、创建订单Long userId = ThreadLocalUtls.getUser().getId();synchronized (userId.toString().intern()) {// 创建代理对象,使用代理对象调用第三方事务方法, 防止事务失效IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(userId, voucherId);}}/*** 创建订单** @param userId* @param voucherId* @return*/@Transactionalpublic Result createVoucherOrder(Long userId, Long voucherId) {
// synchronized (userId.toString().intern()) {// 1、判断当前用户是否是第一单int count = this.count(new LambdaQueryWrapper<VoucherOrder>().eq(VoucherOrder::getUserId, userId));if (count >= 1) {// 当前用户不是第一单return Result.fail("用户已购买");}// 2、用户是第一单,可以下单,秒杀券库存数量减一boolean flag = seckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>().eq(SeckillVoucher::getVoucherId, voucherId).gt(SeckillVoucher::getStock, 0).setSql("stock = stock -1"));if (!flag) {throw new RuntimeException("秒杀券扣减失败");}// 3、创建对应的订单,并保存到数据库VoucherOrder voucherOrder = new VoucherOrder();long orderId = redisIdWorker.nextId(SECKILL_VOUCHER_ORDER);voucherOrder.setId(orderId);voucherOrder.setUserId(ThreadLocalUtls.getUser().getId());voucherOrder.setVoucherId(voucherOrder.getId());flag = this.save(voucherOrder);if (!flag) {throw new RuntimeException("创建秒杀券订单失败");}// 4、返回订单idreturn Result.ok(orderId);}}
这里有很多值得注意的小问题
- 锁的范围尽量小。synchronized尽量锁代码块,而不是方法,锁的范围越大性能越低
- 锁的对象一定要是一个不变的值。我们不能直接锁 Long 类型的 userId,每请求一次都会创建一个新的 userId 对象,synchronized 要锁不变的值,所以我们要将 Long 类型的 userId 通过 toString()方法转成 String 类型的 userId,toString()方法底层(可以点击去看源码)是直接 new 一个新的String对象,显然还是在变,所以我们要使用 intern() 方法从常量池中寻找与当前 字符串值一致的字符串对象,这就能够保障一个用户 发送多次请求,每次请求的 userId 都是不变的,从而能够完成锁的效果(并行变串行)
- 我们要锁住整个事务,而不是锁住事务内部的代码。如果我们锁住事务内部的代码会导致其它线程能够进入事务,当我们事务还未提交,锁一旦释放,仍然会存在超卖问题
- Spring的@Transactional注解要想事务生效,必须使用动态代理。Service中一个方法中调用另一个方法,另一个方法使用了事务,此时会导致@Transactional失效,所以我们需要创建一个代理对象,使用代理对象来调用方法。
让代理对象生效的步骤:
①引入AOP依赖,动态代理是AOP的常见实现之一
<dependency><groupId>org.aspectj</groupId><artifactId>aspectjweaver</artifactId></dependency>
②暴露动态代理对象,默认是关闭的
@EnableAspectJAutoProxy(exposeProxy = true)
乐观锁解决
ALTER TABLE tb_voucher_order
ADD CONSTRAINT UNIQUE (user_id)
乐观锁解决方案其实更加优雅。我们可以通过在数据库表中添加唯一约束来防止一人多单的问题。通过在user_id字段上添加唯一约束,当多个线程尝试为同一用户创建订单时,数据库会自动拒绝重复记录,只有第一个提交的事务能够成功。这种方式比使用悲观锁性能更好,因为它不需要额外的加锁操作,而是利用了数据库自身的特性来保证数据一致性。
要记得捕获异常返回前端
try {save(order); // 可能会抛出 DuplicateKeyException} catch (DuplicateKeyException e) {return Result.fail("你已经抢过了");}
这样即可