引言
在电商业务中,库存超卖问题就如同一颗定时炸弹,随时可能在高并发的环境下引爆。对于后端工程师来说,就需要为这颗炸弹加上防止爆炸的保险,从而避免因为超卖导致的资损问题。本系列文章就将从这个场景入手,一步步地为各位读者引入分布式锁的各种实现,从而让大家可以掌握分布式锁在常见场景的使用。
需求背景
背景非常简单,就是在电商项目中,用户购买商品和数量后后,系统会对商品的库存进行相应数量的扣减。因此,我们模拟这个场景就需要商品表和库存表两张表,但业务并不是这里的重点,需要简化一下,一张简单的商品库存表足以,如下:
CREATE TABLE `tb_goods_stock` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键id',
`goods_id` bigint(20) NOT NULL COMMENT '商品id',
`stock` int NOT NULL COMMENT '库存数',
PRIMARY KEY (`id`)
) COMMENT = '商品库存表';
接着,我们创建一个SpringBoot
的项目,在接口中实现简单的扣减库存的逻辑,示例如下:
public String reductStock(Long goodsId,Integer count){
//1.查询商品库存的库存数量
Integer stock = stockDao.selectStockByGoodsId(goodsId);
//2.判断商品的库存数量是否足够
if (stock < count) return "库存不足";
//3.如果足够,扣减库存数量
stockDao.updateStockByGoodsId(goodsId,stock-count);
//4.返回扣减成功
return "库存扣减成功!";
}
创建成功后,先往数据库里插入一条商品id为1、库存为1的数据,便于我们测试接口的逻辑。分别执行两次调用,分别得到库存不足和库存扣减成功的提示,验证逻辑没有问题,如下:
发现问题
上面的例子如果是通过单次访问,那么它的执行结果也是符合我们预期的。但在高并发场景下,多个线程同时访问同一个数据就可能出现超卖问题。因此,我们用JMeter
来模拟大量并发数据来进行线上抢购场景复现,如下:
添加一个线程组,设定50个线程和100次循环次数,如下:
这时再将数据库里的商品id为1的数据的库存修改为5000
,如下:
接着执行HTTP请求,如下:
通过聚合报告可以看出5000次请求都执行成功,这个时候按照正常逻辑,库存应该扣完了,回到数据库查询,如下:
通过查询发现还有4000多个库存,带换到线上场景,这个时候后续还有用户继续请求购买,最终实际卖出的肯定会远远超过库存,这就是经典的超卖问题。
JVM锁初显神通
并发问题去找锁
这个几乎是大家的共识,那么这里的超卖问题也不例外。因此,最直接的办法就是直接在涉及扣减库存的逻辑或操作上进行加锁
处理。首先,最先想到的就是JVM锁,只需要一个synchronized
关键字就可以实现,代码修改如下:
public synchronized String reductStock(Long goodsId,Integer count){
//1.查询商品库存的库存数量
Integer stock = stockDao.selectStockByGoodsId(goodsId);
//2.判断商品的库存数量是否足够
if (stock < count) return "库存不足";
//3.如果足够,扣减库存数量
stockDao.updateStockByGoodsId(goodsId,stock-count);
//4.返回扣减成功
return "库存扣减成功!";
}
我们这时候去把数据库的库存还原下,然后重新用JMeter
进行请求(Ps:原参数不变),执行后我们先看数据库结果,如下:
可以看到这次的库存就被扣减完了,但我们查看聚合报告会发现对比前面的请求,有一项指标下降了很多-吞吐量,从三千多到现在的一千多,所以加锁肯定对性能是会产生影响的,如下:
当然除了synchronized
关键字,还有更为灵活的方式,毕竟它是作用在方法上的,而我们使用reentrantLock
则可以实现对代码块进行加锁,如下:
ReentrantLock reentrantLock = new ReentrantLock();
public String reductStock(Long goodsId,Integer count){
//1.加锁
reentrantLock.lock();
try {
//2.查询商品库存的库存数量
Integer stock = stockDao.selectStockByGoodsId(goodsId);
//3.判断商品的库存数量是否足够
if (stock < count) return "库存不足";
//4.如果足够,扣减库存数量
stockDao.updateStockByGoodsId(goodsId,stock-count);
} finally {
//5.解锁
reentrantLock.unlock();
}
//6.返回扣减成功
return "库存扣减成功!";
}
JVM锁是万能的吗?
经过了上面的简单改造就让我们的扣减库存不失效了,那么是否这样就可以真正地解决线上的超卖问题呢?当然不是的,JVM锁并不是万能的,它在部分场景下是会失效的,如下:
1.多例模式
首先,我们都知道Spring默认是单例的,即每个对象都会被注册成为一个bean交给IOC容器进行管理。但是它是可以设置成多例的,只需要一个简单的注解,如下:
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
@Service
public class StockService {
@Autowired
private StockDao stockDao;
public synchronized String reductStock(Long goodsId,Integer count){
//1.查询商品库存的库存数量
Integer stock = stockDao.selectStockByGoodsId(goodsId);
//2.判断商品的库存数量是否足够
if (stock < count) return "库存不足";
//3.如果足够,扣减库存数量
stockDao.updateStockByGoodsId(goodsId,stock-count);
//4.返回扣减成功
return "库存扣减成功!";
}
}
这个时候我们再次进行调用测试,结果如下:
可以看到超卖问题又重出江湖了。那么这是为什么呢?其实很好理解,多例模式下这个类对应的bean
也可以有多个,也就是我们每次执行到这个方法都是一个新的bean
,自然就根本没有锁住。
2.事务模式
事务模式就是在方法上加上事务注解(Ps:这里测试记得把上面的多例注解注释掉),代码如下:
@Transactional
public synchronized String reductStock(Long goodsId,Integer count){
//1.查询商品库存的库存数量
Integer stock = stockDao.selectStockByGoodsId(goodsId);
//2.判断商品的库存数量是否足够
if (stock < count) return "库存不足";
//3.如果足够,扣减库存数量
stockDao.updateStockByGoodsId(goodsId,stock-count);
//4.返回扣减成功
return "库存扣减成功!";
}
再次进行调用测试,结果如下:
可以看到依然会有剩余库存,那么为什么加上事务就破坏了JVM锁呢?其实也很好理解:我们看代码,在扣减库存的方法上我们加了事务,方法内部加了锁,可以理解成事务包着锁。那么当请求A执行到扣减库存的方法后,会先进入事务,然后加锁->执行业务逻辑->解锁。这里需要注意的是,一旦解锁之后,请求B就会马上抢夺锁,所以这个时候就出现了旧请求还没提交事务,新请求就拿到锁开始执行了。在读已提交这个默认的隔离级别下,就可能出现新旧请求扣减了同一份库存,自然超卖问题就又出现了。那么是否有解决办法呢?答案是肯定的。这里我们分析了失效的原因,那么其实只要把锁加到事务外,确保事务提交了才释放锁就行。比如按照我们现有的例子,把synchronized
关键字加到controller
层就行了,这里很简单就不演示了,感兴趣的读者可以自行测试。
3.集群模式
集群模式则是最常见的情况,毕竟应该不会有生产级别的服务只部署一个实例,几乎都是部署多实例的。那么这个时候JVM锁自然就失效了,如下:
在这个例子中,外部的请求进入到nginx,通过负载均衡策略转发到库存服务,JVM锁只在所在的JVM内部失效,所以这里加的JVM锁其实是3个服务各加了一把锁,那各自锁各自的等于没锁,超卖问题自然就又出现了。
解决JVM锁失效后的并发问题
上文中提到了3种JVM锁失效的场景,那么就需要想出新的策略来应对并发问题,那么让我们把目光投向MysQL
,它天然就带有表锁、行锁、间隙锁等,那么我们可以利用这些性质来实现我们业务上的加解锁。这种利用数据库锁机制并且假设数据会冲突在操作前加锁的思想,我们称为悲观锁。它的实现方式主要有以下两种:
悲观锁 - 单条update语句实现
首先,让我们回到扣减库存的业务逻辑,如下:
public String reductStock(Long goodsId,Integer count){
//1.查询商品库存的库存数量
Integer stock = stockDao.selectStockByGoodsId(goodsId);
//2.判断商品的库存数量是否足够
if (stock < count) return "库存不足";
//3.如果足够,扣减库存数量
stockDao.updateStockByGoodsId(goodsId,stock-count);
//4.返回扣减成功
return "库存扣减成功!";
}
先查询现在的库存数量,然后判断库存是否足够,如果足够再扣减。那么这三步操作我们其实可以合成一步SQL来执行,这是原本的扣减库存的SQL语句,如下:
@Update("update tb_goods_stock set stock= #{count} where goods_id= #{goodsId}")
Integer updateStockByGoodsId(@Param("goodsId") Long goodsId, @Param("count") Integer count);
让我们进行一个迭代,直接在SQL进行扣减和判断操作,如下:
@Update("update tb_goods_stock set stock= stock - #{count} where goods_id= #{goodsId} and stock >= #{count}")
Integer updateStockByGoodsId(@Param("goodsId") Long goodsId, @Param("count") Integer count);
然后回到service那里同步修改,如下:
public String reductStock(Long goodsId,Integer count){
//1.扣减库存数量
Integer result = stockDao.updateStockByGoodsId(goodsId, count);
//2.如果数量大于0,则扣减成功
if (result > 0){
return "库存扣减成功!";
}
//3.返回扣减失败
return "库存扣减失败!";
}
接着我们用JMeter
再次进行测试,最终库存按照预期归零了,如下:
那么这种悲观锁-单条update语句的方式是否就很完美了呢?当然不是,它其实也存在一些问题:
1.易造成锁范围过大
范围过大怎么理解呢,我们在MySQL客户端里进行测试,首先插入id = 1和2的两条商品库存数据,如下:
然后我们写下update
语句,如下:
BEGIN;
UPDATE tb_goods_stock SET stock = stock - 1 WHERE id = 1;
SELECT * FROM tb_goods_stock;
COMMIT;
然后逐行执行,但执行到查询后先不提交,这个时候执行这条sql查询数据库的加锁情况,如下:
select * from performance_schema.data_locks;
然后得到如下结果:
于是我们可以分析出来,当前的这条update
语句会把每条tb_goods_stock
表上每条数据都锁起来,虽然锁类型都是行锁,但实际上每行都锁其实已经是表锁了。在我们这个例子中,就是用户购买id = 1的商品,但所有商品库存都被锁住了,一个用户买东西,所有用户都得排队等,这个性能只能说相当感人了。那么这个问题有解决办法吗?当然是有的,我们观察下index_name
字段,发现它的值都是主键id,因为我们的商品id并没有建立索引,所以这里锁的时候就会根据主键将全表锁住了。既然知道问题出在哪里了,那么解决办法也很简单,给商品id加个索引就行,加好索引之后我们重新开启事务执行update
语句,再来查锁信息,如下:
这个时候看到id=2的lock_mode
发生了变化,多了一个GAP,它表示间隙锁(Ps:它的意思是你在1和2之间插入一条大于1小于2的数据是插入不进去的)。
2.无法在程序中获取扣减库存之前的值
这个就很好理解了,原本在代码中拆了三段逻辑执行,在扣减前会先获取,自然就有记录。现在全部一条SQL执行了,在应用层面是没有旧库存了。
3.很多场景下无法满足业务诉求
我们这里的案例业务逻辑十分简单,一条SQL就搞定了,那么在实际场景中,还可能涉及到拆单、合单等之类的操作,那么这个时候是需要我们在代码中处理业务逻辑的,显然单靠一条update
语句就无法满足需求了。
悲观锁 - for update语句实现
那么为了解决上述的后两个问题,我们可以使用悲观锁的另一种方式。只需要在查询语句后加个for update
,如下:
@Select("select stock from tb_goods_stock where goods_id= #{goodsId} for update")
Integer selectStockByGoodsIdForUpdate(@Param("goodsId") Long goodsId);
它的作用是在查询的时候加锁,和前面的update
语句一样会加行锁,当然,如果你没有建索引,它会建表锁。注意的是:这里的锁是依靠mysql
的锁机制实现的,所以当你的事务没提交的时候,当前的连接就会一直持有锁,所以需要我们在方法上加上事务注解,保证逻辑执行完成后自动提交事务,如下:
@Transactional(rollbackFor = Exception.class)
public String reductStock(Long goodsId,Integer count){
//1.查询商品库存数量
Integer stock = stockDao.selectStockByGoodsIdForUpdate(goodsId);
//2.判断库存数量是否足够
if (stock < count){
return "库存不足!";
}
//3.如果库存足够,扣减库存
stockDao.updateStockByGoodsId(goodsId, count);
//3.返回扣减成功
return "库存扣减成功!";
}
它的优势也很明显,解决了上述的两个问题:无法在程序中获取扣减库存之前的值和很多场景下无法满足业务诉求。那么,它的问题是什么呢?
1.易造成锁范围过大
这个很好理解,毕竟它本身的实现和单条update
语句一样,所以自然也会存在相同的这个问题。
2.性能较差
长时间锁定以及频繁的加锁和解锁操作都会成为性能的瓶颈点。
3.死锁问题
其实这个问题,单条update语句也可能出现,主要和加锁顺序有关。比如现在两个客户端A和B同时请求,客户端A里我们先给商品id=1的加锁,客户端B则先给商品id=2的加锁,接着A再给商品=2的加锁,B则给商品id=1的加锁,这个时候就形成了死锁。
4.select for update和普通select语句读取内容不一致
在默认的隔离级别(即读已提交)下,假如客户端A开启了事务,并做了扣减库存,这个时候还未提交事务,客户端B这个时候使用select语句读取到的值就是扣减前的,但是如果客户端B使用的是select for update来读取,读到的就是扣减后的值,因为它是当前读
,即数据的真实值而不受事务影响。那么如果在业务中,有的地方使用select for update
,有的地方使用select
,而且需要对读取到的值做业务处理,这样处处不一致就可能导致数据问题。
乐观锁-版本号
有悲观锁自然也有乐观锁,和悲观锁相反,它是假设每次去拿数据别人都不会修改,所以不会上锁,只在更新的时候判断一下别人有没有更新这个数据。虽然叫乐观锁,但它其实更像是一种设计思想,先来介绍一下它的一种实现-版本号:
1.给指定表增加一个字段version
ALTER TABLE `tb_goods_stock`
ADD COLUMN `version` int NULL DEFAULT 0 COMMENT '版本号' AFTER `stock`;
2.读取数据的时候将version字段一起读出
@Select("select id,stock,version from tb_goods_stock where goods_id= #{goodsId}")
List<GoodsStockEntity> selectStockAndVersionByGoodsId(@Param("goodsId") Long goodsId);
3.数据每更新一次,version字段加1
@Select("update tb_goods_stock set stock= #{count}, version=#{version} + 1 where goods_id= #{goodsId} and version = #{version}")
Integer updateStockAndVersionByGoodsIdAndVersion(@Param("goodsId") Long goodsId, @Param("count") Integer count,@Param("version") Integer version);
4.提交更新的时候,判断库中的version字段和前面读出来的进行比较
//1.查询商品库存数量 + version
List<GoodsStockEntity> goodsStockEntities = stockDao.selectStockAndVersionByGoodsId(goodsId);
//2.判空
if (goodsStockEntities.isEmpty()) {
return "商品不存在!";
}
//3.存在则取出
GoodsStockEntity goodsStockEntity = goodsStockEntities.get(0);
//4.判断库存数量是否足够
if (goodsStockEntity.getStock() < count) {
return "库存不足!";
}
//5.如果库存足够,扣减库存
result = stockDao.updateStockAndVersionByGoodsIdAndVersion(goodsId,
goodsStockEntity.getStock() - count, goodsStockEntity.getVersion());
5.相同更新,不相同重试
public String reductStock(Long goodsId,Integer count) {
//1.声明修改标志变量
Integer result = 0;
while (result == 0) {
//1.查询商品库存数量 + version
List<GoodsStockEntity> goodsStockEntities = stockDao.selectStockAndVersionByGoodsId(goodsId);
//2.判空
if (goodsStockEntities.isEmpty()) {
return "商品不存在!";
}
//3.存在则取出
GoodsStockEntity goodsStockEntity = goodsStockEntities.get(0);
//4.判断库存数量是否足够
if (goodsStockEntity.getStock() < count) {
return "库存不足!";
}
//5.如果库存足够,扣减库存
result = stockDao.updateStockAndVersionByGoodsIdAndVersion(goodsId,
goodsStockEntity.getStock() - count, goodsStockEntity.getVersion());
}
//6.返回扣减成功
return "库存扣减成功!";
}
修改完成后,我们再次进行测试,可以看到库存扣减为0,版本号也加到了5000,如下:
乐观锁-时间戳
通过版本号机制,我们成功解决了扣减库存的问题,接下来看下乐观锁的另一种实现-时间戳。它的实现方式和版本号类似,这里我们就不演示了,给大家说明下步骤:
- 1.给表增加
timestamp
字段; - 2.读取数据的时候,将
timestamp
字段一起读出; - 3.数据每更新一次,
timestamp
取当前时间戳; - 4.提交更新时,判断库中的
timestamp
字段值和之前取出来的timestamp
进行比较; - 5.相同更新,不相同重试。
乐观锁问题
看了乐观锁的实现,在前文中我们分析了悲观锁存在的问题,那么同样地,接下来我们聊聊乐观锁存在的问题。
1.高并发写操作性能低
因为我们存在重试机制,那么在高并发场景下,存在多个请求不断重试,每个请求的读也都需要和数据库进行IO,所以它更适合读多写少的场景。
2.存在ABA问题
这是一个乐观锁的常见问题,虽然在我们上面的例子中并不会发生,因为它只涉及到减库存,可能有的同学会困惑为什么呢?我来举个例子:假设目前业务还存在取消订单,需要对版本号做减一。那么假设此时有三个线程同时进入,线程A减库存,版本号加一;线程B加库存,版本号减一;线程C也是减库存,但是线程B把版本号减一,把A加的又减回去了,就导致C拿到的和读到的一样了,于是C也减库存了。这个时候A和C扣了一次,但实际消费了两次,超卖问题就又出现了。
小结
本篇文章通过超卖问题引入了JVM锁、MySQL悲观锁和乐观锁,并对每种锁的实现和局限都做了讲解,其实是想在开篇就告诉各位读者没有完美的方案,只有更好的方案。在我们后续的学习中你也会看到不断地肯定与否定,主要的目的是希望各位读者在学习后可以根据自己的业务场景选择合适的方案!