【分布式锁的演化】电商“超卖”场景实战
前言
从本篇开始,老猫会通过电商中的业务场景和大家分享锁在实际应用场景下的演化过程。从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-distribute
写在最后
本文通过电商中两种超卖现象和小伙伴们分享了一下单体锁解决问题过程。当然这种锁的使用是无法跨越jvm的,当遇到多个jvm的时候就失效了,所以后面的文章中会和大家分享分布式锁的实现。当然也是通过电商中超卖的例子和大家分享。敬请期待。