文章目录
- 使用数据库解决超卖问题(非分布式)
- 分为三步:原始方法
- 合并二、三步:使用update行锁使操作下沉到数据库
- 合并一、二、三步:使用方法锁
- 优化:使用块锁
- 使用数据库解决分布式超卖问题
- 主要原理
- 解决方案
解决库存超卖问题,可以另扣库存不在程序中运行,而是通过数据库。向数据库传递库存增量,扣件N个库存,增量为-N。也就是在数据库update语句计算库存,通过update行锁解决并发。
在高并发的情况下对数据库压力较大,所以很少使用。
使用数据库解决超卖问题(非分布式)
这里我们从原始方法开始,一步一步修改代码来达到防止超卖的目的。
分为三步:原始方法
我们一步一步的分析,先来看下面的更新库存代码(创建订单的代码不做展示),我们实现了下面的三个步骤:
- 获取当前库存量
selectByPrimaryKey()
。 - 在程序中计算还剩下多少库存
leftCount
。 - 将leftCount更新至数据库
updateByPrimaryKeySelective()
。
我们使用一个test多线程来查看这个程序的结果:
@RunWith(SpringRunner.class)
@SpringBootTest
public class DistributeDemoApplicationTests {
@Autowired
private OrderService orderService;
@Test
public void concurrentOrder() throws InterruptedException {
CountDownLatch cdl = new CountDownLatch(5);
CyclicBarrier cyclicBarrier = new CyclicBarrier(5);
ExecutorService es = Executors.newFixedThreadPool(5);
for (int i =0;i<5;i++){
es.execute(()->{
try {
cyclicBarrier.await();
Integer orderId = orderService.createOrder();
System.out.println("订单id:"+orderId);
} catch (Exception e) {
e.printStackTrace();
}finally {
cdl.countDown();
}
});
}
cdl.await();
es.shutdown();
}
}
该程序运行的结果如下:
在数据库中,product商品数据库的数目count从1减少到了0。
而订单表多了5个订单:
很明显库存只剩下1个,但是重复下单了5次,出现了超卖。
合并二、三步:使用update行锁使操作下沉到数据库
所以,我们想使用update行锁来把代码执行的逻辑下降到数据库中去执行。
在Mapper中:
int updateProductCount(@Param("purchaseProductNum") int purchaseProductNum,
@Param("updateUser") String xxx, @Param("updateTime") Date date,
@Param("id") Integer id);
<update id="updateProductCount">
update product
set count = count - #{purchaseProductNum,jdbcType=INTEGER},
update_user = #{updateUser,jdbcType=VARCHAR},
update_time = #{updateTime,jdbcType=TIME}
where id = #{id,jdbcType=INTEGER}
</update>
在原来的代码中,将计算剩余库存和更新数据库的操作合并为一步:
因为update操作有行锁,所以5个线程同时执行一条语句的话,只会有一个线程执行成功,另外四个线程回去等它运行完成后再争抢这个锁。
这样运行的结果如下:
在数据库中,product商品数据库的数目count从1减少到了-4。
同样也是产生了5个订单:
很明显结果不正确,所以代码还需要改进。
合并一、二、三步:使用方法锁
我们可以让校验库存和减扣库存成为原子性的操作。也就是把1,2,3步合并成为一步。
这样,并发的时候,只有获得锁的线程才能进行校验和扣减。
我们运行的结果如下:
直接看结果,发现这个test跑出错了,理想情况下只可能产生一个订单,而截图很明显产生了两次订单。
我们再看数据库:
怎么回事?我们只能通过打印log来看看到底出了什么问题。
再次运行:
我们发现线程3和4读到的库存都是1。为什么会有两个线程读到的库存都是1呢?这是因为我们使用了事务:
前一个线程还没有提交事务,后一个线程就开始执行了。
所以我们需要把事务一块锁起来,这需要我们手动的去控制事务了。
首先需要手动的注入这些类。
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
@Autowired
private PlatformTransactionManager platformTransactionManager;
@Autowired
private TransactionDefinition transactionDefinition;
在方法开始我们创建事务,方法的结束提交事务:
// @Transactional(rollbackFor = Exception.class)
public Integer createOrder() throws Exception{
TransactionStatus transaction1 = platformTransactionManager.getTransaction(transactionDefinition);
...
校验库存
减扣库存
新建订单
...
platformTransactionManager.commit(transaction1);
return order.getId();
}
其中校验库存、减扣库存、新建订单的三个操作中,如果出现了异常,需要执行事务回滚的相关操作:platformTransactionManager.rollback(transaction1);
这次运行的结果:
数据库扣库存成功,订单也只创建了一个。
优化:使用块锁
当然可以使用synchronized(this){}
的块锁。
也可以使用ReentrantLock
并发包提供的可重入锁。
这里给出后者的完整代码:
private Lock lock = new ReentrantLock();
// @Transactional(rollbackFor = Exception.class)
public Integer createOrder() throws Exception{
Product product = null;
lock.lock();
try {
TransactionStatus transaction1 = platformTransactionManager.getTransaction(transactionDefinition);
product = productMapper.selectByPrimaryKey(purchaseProductId);
if (product==null){
platformTransactionManager.rollback(transaction1);
throw new Exception("购买商品:"+purchaseProductId+"不存在");
}
//商品当前库存
Integer currentCount = product.getCount();
System.out.println(Thread.currentThread().getName()+"库存数:"+currentCount);
//校验库存
if (purchaseProductNum > currentCount){
platformTransactionManager.rollback(transaction1);
throw
new Exception("商品"+purchaseProductId+"仅剩"+currentCount+"件,无法购买");
}
productMapper.updateProductCount(purchaseProductNum,"xxx",new Date(),product.getId());
platformTransactionManager.commit(transaction1);
}finally {
lock.unlock();
}
TransactionStatus transaction = platformTransactionManager.getTransaction(transactionDefinition);
Order order = new Order();
...
orderMapper.insertSelective(order);
OrderItem orderItem = new OrderItem();
...
orderItemMapper.insertSelective(orderItem);
platformTransactionManager.commit(transaction);
return order.getId();
}
使用数据库解决分布式超卖问题
我们知道,单体锁是不会跨JVM的,所以需要第三方来解决分布式的问题。
主要原理
这里我们使用数据库来解决,主要是通过select ... for update
来解决,这句话的意思是for update
的数据,我们就给他加上了一把锁,其他的人是不能修改这个数据的,也不能给这条数据再加锁。
我们首先新增一个表distribute_lock
:
- id,自增id。
- bussiness_code,业务代码用来区分不同业务使用的锁,比如订单使用一把锁,商品的其他某些步骤的并发又使用其他的锁。
- bussiness_name,业务名称用来标注code是什么意思,做一个注释的作用。
我们简单的放入数据:
我们查看自动提交是否置为0:
没有,则置为0:
现在我们运行:
现在这条语句已经检索出来了,并且加上了锁(由于我们把autocommit设置为了不自动提交,所以这条语句现在还没有提交)。我们再创建一个窗口,仍然是同样的select ... for update
语句查询:
可以看到直接运行是检索不出来的。我们在前一个查询上加上commit操作,直接运行commit。
这样第二个窗口的查询也在之后立即执行完毕,然后我们也要再执行commit。
解决方案
首先生成Springboot程序。
这里的controller,我们使用一个可重用锁来锁住,我们运行一下,并且使用postman来验证锁。
请求1:
请求2(与请求1相同):
我们看下命令行输出:
可以发现,单JVM的情况下,同时请求,只有一个获取到了锁。
这里我们再试试多JVM,也就是新增一个port。
一个设置8080端口,另一个设置8081端口。
启动:
我们使用postman,一个请求8080一个请求8081:
可以看出,锁只在同一个JVM里才有效。
现在我们使用第三方数据库来实现分布式锁,首先用mybatis-generator插件生成:
<plugin>
<groupId>org.mybatis.generator</groupId>
<artifactId>mybatis-generator-maven-plugin</artifactId>
<version>1.3.7</version>
<dependencies>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.17</version>
</dependency>
</dependencies>
</plugin>
我们在生成的map里新加入一个方法:
DistributeLock selectDistributeLock(@Param("bussinessCode")String bussinessCode);
实现如下:
修改controller:
按照之前的postman的方法同样测试两个不同的端口:
为什么会出现这种情况呢?因为我们没有加入事务,sql语句已经自动提交了。
加上事务即可。
运行结果:
运行成功。