文章目录

  • 使用数据库解决超卖问题(非分布式)
  • 分为三步:原始方法
  • 合并二、三步:使用update行锁使操作下沉到数据库
  • 合并一、二、三步:使用方法锁
  • 优化:使用块锁
  • 使用数据库解决分布式超卖问题
  • 主要原理
  • 解决方案


解决库存超卖问题,可以另扣库存不在程序中运行,而是通过数据库。向数据库传递库存增量,扣件N个库存,增量为-N。也就是在数据库update语句计算库存,通过update行锁解决并发。
在高并发的情况下对数据库压力较大,所以很少使用。

使用数据库解决超卖问题(非分布式)

这里我们从原始方法开始,一步一步修改代码来达到防止超卖的目的。

分为三步:原始方法

我们一步一步的分析,先来看下面的更新库存代码(创建订单的代码不做展示),我们实现了下面的三个步骤:

  1. 获取当前库存量selectByPrimaryKey()
  2. 在程序中计算还剩下多少库存leftCount
  3. 将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。

java 补充库存 java商品库存怎么加锁_bc


而订单表多了5个订单:

java 补充库存 java商品库存怎么加锁_java 补充库存_02


很明显库存只剩下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>

在原来的代码中,将计算剩余库存和更新数据库的操作合并为一步:

java 补充库存 java商品库存怎么加锁_数据库_03

因为update操作有行锁,所以5个线程同时执行一条语句的话,只会有一个线程执行成功,另外四个线程回去等它运行完成后再争抢这个锁。

这样运行的结果如下:

在数据库中,product商品数据库的数目count从1减少到了-4。

java 补充库存 java商品库存怎么加锁_java 补充库存_04


同样也是产生了5个订单:

java 补充库存 java商品库存怎么加锁_JVM_05

很明显结果不正确,所以代码还需要改进。

合并一、二、三步:使用方法锁

我们可以让校验库存和减扣库存成为原子性的操作。也就是把1,2,3步合并成为一步。

这样,并发的时候,只有获得锁的线程才能进行校验和扣减。

java 补充库存 java商品库存怎么加锁_java 补充库存_06


我们运行的结果如下:

java 补充库存 java商品库存怎么加锁_JVM_07


java 补充库存 java商品库存怎么加锁_java 补充库存_08

直接看结果,发现这个test跑出错了,理想情况下只可能产生一个订单,而截图很明显产生了两次订单。

我们再看数据库:

java 补充库存 java商品库存怎么加锁_bc_09

怎么回事?我们只能通过打印log来看看到底出了什么问题。

java 补充库存 java商品库存怎么加锁_JVM_10

再次运行:

java 补充库存 java商品库存怎么加锁_JVM_11


我们发现线程3和4读到的库存都是1。为什么会有两个线程读到的库存都是1呢?这是因为我们使用了事务:

java 补充库存 java商品库存怎么加锁_java 补充库存_12


前一个线程还没有提交事务,后一个线程就开始执行了。

所以我们需要把事务一块锁起来,这需要我们手动的去控制事务了。

首先需要手动的注入这些类。

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

java 补充库存 java商品库存怎么加锁_java 补充库存_13

  • id,自增id。
  • bussiness_code,业务代码用来区分不同业务使用的锁,比如订单使用一把锁,商品的其他某些步骤的并发又使用其他的锁。
  • bussiness_name,业务名称用来标注code是什么意思,做一个注释的作用。

我们简单的放入数据:

java 补充库存 java商品库存怎么加锁_JVM_14


我们查看自动提交是否置为0:

java 补充库存 java商品库存怎么加锁_JVM_15


没有,则置为0:

java 补充库存 java商品库存怎么加锁_bc_16


现在我们运行:

java 补充库存 java商品库存怎么加锁_JVM_17


现在这条语句已经检索出来了,并且加上了锁(由于我们把autocommit设置为了不自动提交,所以这条语句现在还没有提交)。我们再创建一个窗口,仍然是同样的select ... for update语句查询:

java 补充库存 java商品库存怎么加锁_java 补充库存_18


可以看到直接运行是检索不出来的。我们在前一个查询上加上commit操作,直接运行commit。

java 补充库存 java商品库存怎么加锁_java 补充库存_19


这样第二个窗口的查询也在之后立即执行完毕,然后我们也要再执行commit。

解决方案

首先生成Springboot程序。

java 补充库存 java商品库存怎么加锁_bc_20


这里的controller,我们使用一个可重用锁来锁住,我们运行一下,并且使用postman来验证锁。

请求1:

java 补充库存 java商品库存怎么加锁_java 补充库存_21


请求2(与请求1相同):

java 补充库存 java商品库存怎么加锁_数据库_22

我们看下命令行输出:

java 补充库存 java商品库存怎么加锁_JVM_23


可以发现,单JVM的情况下,同时请求,只有一个获取到了锁。

这里我们再试试多JVM,也就是新增一个port。

一个设置8080端口,另一个设置8081端口。

java 补充库存 java商品库存怎么加锁_数据库_24


启动:

java 补充库存 java商品库存怎么加锁_bc_25


我们使用postman,一个请求8080一个请求8081:

java 补充库存 java商品库存怎么加锁_java 补充库存_26


java 补充库存 java商品库存怎么加锁_bc_27

可以看出,锁只在同一个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);

实现如下:

java 补充库存 java商品库存怎么加锁_数据库_28

修改controller:

java 补充库存 java商品库存怎么加锁_数据库_29


按照之前的postman的方法同样测试两个不同的端口:

java 补充库存 java商品库存怎么加锁_bc_30


java 补充库存 java商品库存怎么加锁_java 补充库存_31


为什么会出现这种情况呢?因为我们没有加入事务,sql语句已经自动提交了。

加上事务即可。

java 补充库存 java商品库存怎么加锁_JVM_32


运行结果:

java 补充库存 java商品库存怎么加锁_数据库_33


java 补充库存 java商品库存怎么加锁_bc_34


运行成功。