关于java:分布式锁的演化电商超卖场景实战

7次阅读

共计 6915 个字符,预计需要花费 18 分钟才能阅读完成。

前言

从本篇开始,老猫会通过电商中的业务场景和大家分享锁在理论利用场景下的演化过程。从 Java 单体锁到分布式环境下锁的实际。

超卖的第一种景象案例

其实在电商业务场景中,会有一个这样让人禁忌的景象,那就是“超卖”,那么什么是超卖呢?举个例子,某商品的库存数量只有 10 件,最终却卖出了 15 件,简而言之就是商品卖出的数量超过了商品自身的库存数目。“超卖”会导致商家没有商品发货,发货的工夫缩短,从引起交易单方的纠纷。

咱们来一起剖析一下该景象产生的起因:如果商品只有最初一件,A 用户和 B 用户同时看到了商品,并且同时退出了购物车提交了订单,此时两个用户同时读取库存中的商品数量为一件,各自进行内存扣减之后,进行更新数据库。因而产生超卖,咱们具体看一下流程示意图:

解决方案

遇到上述问题,在单台服务器的时候咱们如何解决呢?咱们来看一下具体的计划。之前形容中提到,咱们在扣减库存的时候是在内存中进行。接下来咱们将其进行下沉到数据库中进行库存的更新操作,咱们能够向数据库传递库存增量,扣减一个库存,增量为 -1,在数据库进行 update 语句计算库存的时候,咱们通过 update 行锁解决并发问题。(数据库行锁:在数据库进行更新的时候,以后行被锁定,即为行锁,此处老猫形容比较简单,有趣味的小伙伴能够自发钻研一下数据库的锁)。咱们来看一下具体的代码例子。

业务逻辑代码如下:

@Service
@Slf4j
public class OrderService {
    @Resource
    private KdOrderMapper orderMapper;
    @Resource
    private KdOrderItemMapper orderItemMapper;
    @Resource
    private KdProductMapper productMapper;
    // 购买商品 id
    private int purchaseProductId = 100100;
    // 购买商品数量
    private int purchaseProductNum = 1;

    @Transactional(rollbackFor = Exception.class)
    public Integer createOrder() throws Exception{KdProduct product = productMapper.selectByPrimaryKey(purchaseProductId);
        if (product==null){throw new Exception("购买商品:"+purchaseProductId+"不存在");
        }

        // 商品以后库存
        Integer currentCount = product.getCount();
        // 校验库存
        if (purchaseProductNum > currentCount){throw new Exception("商品"+purchaseProductId+"仅剩"+currentCount+"件,无奈购买");
        }
        // 计算残余库存
        Integer leftCount = currentCount -purchaseProductNum;
        product.setCount(leftCount);
        product.setTimeModified(new Date());
        product.setUpdateUser("kdaddy");
        productMapper.updateByPrimaryKeySelective(product);
        // 生成订单
        KdOrder order = new KdOrder();
        order.setOrderAmount(product.getPrice().multiply(new BigDecimal(purchaseProductNum)));
        order.setOrderStatus(1);// 待处理
        order.setReceiverName("kdaddy");
        order.setReceiverMobile("13311112222");
        order.setTimeCreated(new Date());
        order.setTimeModified(new Date());
        order.setCreateUser("kdaddy");
        order.setUpdateUser("kdaddy");
        orderMapper.insertSelective(order);

        KdOrderItem orderItem = new KdOrderItem();
        orderItem.setOrderId(order.getId());
        orderItem.setProductId(product.getId());
        orderItem.setPurchasePrice(product.getPrice());
        orderItem.setPurchaseNum(purchaseProductNum);
        orderItem.setCreateUser("kdaddy");
        orderItem.setTimeCreated(new Date());
        orderItem.setTimeModified(new Date());
        orderItem.setUpdateUser("kdaddy");
        orderItemMapper.insertSelective(orderItem);
        return order.getId();}
}

通过以上代码咱们能够看到的是库存的扣减在内存中实现。那么咱们再看一下具体的单元测试代码:

@SpringBootTest
class DistributeApplicationTests {
    @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();}
}

代码执结束之后咱们看一下后果:

订单 id:1
订单 id:2
订单 id:3
订单 id:4
订单 id:5

很显然,数据库中尽管只有一个库存,然而产生了五个下单记录,如下图:


这也就产生了超卖的景象,那么如何能力解决这个问题呢?

单体架构中,利用数据库行锁解决电商超卖问题。

那么如果是这种解决方案的话,咱们就要将咱们扣减库存的动作下沉到咱们的数据库中,利用数据库的行锁解决并发状况下同时操作的问题,咱们来看一下代码的革新点。

@Service
@Slf4j
public class OrderServiceOptimizeOne {
    ..... 篇幅限度,此处省略,具体可参考 github 源码
    @Transactional(rollbackFor = Exception.class)
    public Integer createOrder() throws Exception{KdProduct product = productMapper.selectByPrimaryKey(purchaseProductId);
        if (product==null){throw new Exception("购买商品:"+purchaseProductId+"不存在");
        }

        // 商品以后库存
        Integer currentCount = product.getCount();
        // 校验库存
        if (purchaseProductNum > currentCount){throw new Exception("商品"+purchaseProductId+"仅剩"+currentCount+"件,无奈购买");
        }

        // 在数据库中实现减量操作
        productMapper.updateProductCount(purchaseProductNum,"kd",new Date(),product.getId());
        // 生成订单
        ..... 篇幅限度,此处省略,具体可参考 github 源码
        return order.getId();}
}

咱们再来看一下执行的后果

从上述后果中,咱们发现咱们的订单数量仍旧是 5 个订单,然而库存数量此时不再是 0,而是由 1 变成了 -4,这样的后果显然仍旧不是咱们想要的,那么此时其实又是超卖的另外一种景象。咱们来看一下超卖景象二所产生的起因。

超卖的第二种景象案例

上述其实是第二种景象,那么产生的起因是什么呢?其实是在校验库存的时候呈现了问题,在校验库存的时候是并发进行对库存的校验,五个线程同时拿到了库存,并且发现库存数量都为 1,造成了库存短缺的假象。此时因为写操作的时候具备 update 的行锁,所以会顺次扣减执行,扣减操作的时候并无校验逻辑。因而就产生了这种超卖浮现。简略的如下图所示:

解决方案一:

单体架构中,利用数据库行锁解决电商超卖问题。就针对以后该案例,其实咱们的解决形式也比较简单,就是更新结束之后,咱们立刻查问一下库存的数量是否大于等于 0 即可。如果为正数的时候,咱们间接抛出异样即可。(当然因为此种操作并未波及到锁的常识,所以此计划仅做提出,不做理论代码实际)

解决方案二:

校验库存和扣减库存的时候对立加锁,让其成为原子性的操作,并发的时候只有获取锁的时候才会去读库库存并且扣减库存操作。当扣减完结之后,开释锁,确保库存不会扣成正数。那此时咱们就须要用到后面博文提到的 java 中的两个锁的关键字 synchronized 关键字 和 ReentrantLock

对于 synchronized 关键字的用法在之前的博文中也提到过,有办法锁和代码块锁两种形式,咱们一次来通过实际看一下代码,首先是通过办法锁的形式,具体的代码如下:

//`synchronized` 办法块锁
@Service
@Slf4j
public class OrderServiceSync01 {
    ..... 篇幅限度,此处省略,具体可参考 github 源码
    @Transactional(rollbackFor = Exception.class)
    public synchronized Integer createOrder() throws Exception{KdProduct product = productMapper.selectByPrimaryKey(purchaseProductId);
        if (product==null){throw new Exception("购买商品:"+purchaseProductId+"不存在");
        }

        // 商品以后库存
        Integer currentCount = product.getCount();
        // 校验库存
        if (purchaseProductNum > currentCount){throw new Exception("商品"+purchaseProductId+"仅剩"+currentCount+"件,无奈购买");
        }

        // 在数据库中实现减量操作
        productMapper.updateProductCount(purchaseProductNum,"kd",new Date(),product.getId());
        // 生成订单
        ..... 篇幅限度,此处省略,具体可参考 github 源码
        return order.getId();}
}

此时咱们看一下运行的后果。

[pool-1-thread-2] c.k.d.service.OrderServiceSync01         : pool-1-thread- 2 库存数 1
[pool-1-thread-1] c.k.d.service.OrderServiceSync01         : pool-1-thread- 1 库存数 1
订单 id:12
[pool-1-thread-5] c.k.d.service.OrderServiceSync01         : pool-1-thread- 5 库存数 -1
订单 id:13
[pool-1-thread-3] c.k.d.service.OrderServiceSync01         : pool-1-thread- 3 库存数 -1


此时咱们很显著地发现数据还是存在问题,那么这个是什么起因呢?

其实聪慧的小伙伴其实曾经发现了,咱们第二个线程读取到的数据仍旧是 1,那么为什么呢?其实很简略,第二个线程在读取商品库存的时候是 1 的起因是因为上一个线程的事务并没有提交,咱们也能比拟清晰地看到目前咱们办法上的事务是在锁的里面的。所以就产生了该问题,那么针对这个问题,咱们其实能够将事务的提交进行手动提交,而后放到锁的代码块中。具体革新如下。

 public synchronized Integer createOrder() throws Exception{
     // 手动获取以后事务   
     TransactionStatus transaction = platformTransactionManager.getTransaction(transactionDefinition);
        KdProduct product = productMapper.selectByPrimaryKey(purchaseProductId);
        if (product==null){platformTransactionManager.rollback(transaction);
            throw new Exception("购买商品:"+purchaseProductId+"不存在");
        }

        // 商品以后库存
        Integer currentCount = product.getCount();
        log.info(Thread.currentThread().getName()+"库存数"+currentCount);
        // 校验库存
        if (purchaseProductNum > currentCount){platformTransactionManager.rollback(transaction);
            throw new Exception("商品"+purchaseProductId+"仅剩"+currentCount+"件,无奈购买");
        }

        // 在数据库中实现减量操作
        productMapper.updateProductCount(purchaseProductNum,"kd",new Date(),product.getId());
        // 生成订单并实现订单的保留操作
         ..... 篇幅限度,此处省略,具体可参考 github 源码
        platformTransactionManager.commit(transaction);
        return order.getId();}

此时咱们再看一下运行的后果:

 [pool-1-thread-3] c.k.d.service.OrderServiceSync01         : pool-1-thread- 3 库存数 1
 [pool-1-thread-5] c.k.d.service.OrderServiceSync01         : pool-1-thread- 5 库存数 0
订单 id:16
 [pool-1-thread-4] c.k.d.service.OrderServiceSync01         : pool-1-thread- 4 库存数 0
 [pool-1-thread-1] c.k.d.service.OrderServiceSync01         : pool-1-thread- 1 库存数 0 

依据下面的后果咱们能够很分明的看到只有第一个线程读取到了库存是 1,前面所有的线程获取到的都是 0 库存。咱们再来看一下具体的数据库。

很显著,咱们到此数据库的库存和订单数量也都正确了。

前面 synchronized 代码块锁以及 ReentrantLock 交给小伙伴们本人去尝试着实现,当然老猫也曾经把相干的代码写好了。具体的源码地址为:https://github.com/maoba/kd-d…

写在最初

本文通过电商中两种超卖景象和小伙伴们分享了一下单体锁解决问题过程。当然这种锁的应用是无奈逾越 jvm 的,当遇到多个 jvm 的时候就生效了,所以前面的文章中会和大家分享分布式锁的实现。当然也是通过电商中超卖的例子和大家分享。敬请期待。

当然更多干货也欢送大家搜寻关注公众号“程序员老猫”。老猫,一个专一原创干货的男人

正文完
 0