一、问题分析
出现超卖问题一般都是线程安全引起的问题。正常情况下,是先查询库存,如果库存小于等于0就购买失败,接着第二个线程查询库存,如果库存小于等于0就购买失败。时序图如下所示
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("库存不足!");
}
但正常的业务单个,当然不是线程在竞争资源的,而线程的执行顺序是由CPU调度的,我们无法干预。如双十一这种情况,有可能上百,甚至上千个用户购买同一个商品,这时就会发生线程安全问题。假设有两个用户购买最后1件商品,分别用线程1和线程2表示。线程1查询库存量还剩1时,准备扣减库存时,线程2过来查询库存,因为线程1还没对数据进行修改,所以线程2查询的库存量也是1,就在这时,线程1对库存进行扣减完毕,库存量已经是0了。由于线程2开始查询的库存量是1,接着线程2也开始对库存进行扣减,就出现了超卖。
二、解决办法
针对线程安全问题,常见的解决方案就是加锁。注意,这里的悲观锁和乐观锁并不是真正的锁,而是一种设计理念。
悲观锁:总是悲观的认为线程安全一定会发生,因此每次操作数据之前都会先获取锁,确保线程穿行执行。因为是串行执行,所以执行效率并没有那么好。
乐观锁:乐观的认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时判断有没有线程对数据进行修改。如果没有修改认为数据是安全的,它才会更新数据。如果数据被修改了,则说明有安全问题,可以重试或异常。
那么如何知道数据有没有被修改呢?每条数据都有一个版本号。每次对数据修改前会查询出版本号,修改后版本号都会+1,如果修改前后的版本号不一致,说明数据被修改了,此时修改数据的操作就会失败。如果修改前后的版本号一致,说明数据没有被修改,那么就可以修改数据。
方式一:版本号法
如下时序图,线程1和线程2查询库存和版本号,由于此时都没有修改数据,所以两个线程查询的版本号都是1,假设线程1先执行库存扣减,那么它是一定会成功的,然后版本号就加1变成2了。接着线程2开始扣减库存,修改数据时发现版本号不是1,那么线程2这个执行语句就不会生效。这就防止了线程安全问题。
方式二:CAS
CAS只是在方式一的基础上简化了操作,版本号法需要额外记录的每条数据的版本,那我们可以寻找数据中和版本号相似的库存字段,每卖一件商品就会减少库存,在修改之前查询库存量作为条件,如果修改时库存量和查询时的不一致,就修改失败了。
小结:
悲观锁:
优点:简单方便
缺点:串行执行,性能一般
乐观锁:
优点:性能好
缺点:成功率不高
三、代码实践
将库存扣减的代码稍作调整,这个逻辑是,扣减库存时的库存量和查询的库存量一致的情况才认为线程安全,才会做修改操作。但测试发现,即便没有超卖,也会有很多失败的情况。这就是乐观锁的缺点有关了。如果有100个线程同时竞争这个资源,这100个线程最初查询到的库存量都是100,当第一个线程扣减库存量后变为99,而剩下的线程拿100和99进行比对,显然是不同的,也就是说剩下的99个线程都会修改失败。
//5,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1") // 等价于 set stock = stock - 1
.eq("voucher_id", voucherId) // 等价于 where id = ? and stock = ?
.eq("stock", voucher.getStock)
.update();
我们只需要将库存条件设置成大于0即可,因为我只要保证这100个线程只能竞争100个库存即可
//5,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1") // 等价于 set stock = stock - 1
.eq("voucher_id", voucherId) // 等价于 where id = ? and stock > 0
.gt("stock", 0)
.update();