2020-12-29

博客园Logo
首页
新闻
博问
专区
闪存
班级

代码改变世界
搜索
注册
登录
返回主页
程序员老猫
老猫,一个坚持原创输出的男人。 在技术的路上期待与你的共同前行。 个人公众号“程序员老猫”。 个人博客地址:https://blog.ktdaddy.com/
博客园
首页
新随笔
联系
订阅
管理
【分布式锁的演化】电商“超卖”场景实战

前言

从本篇开始,老猫会通过电商中的业务场景和大家分享锁在实际应用场景下的演化过程。从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的时候就失效了,所以后面的文章中会和大家分享分布式锁的实现。当然也是通过电商中超卖的例子和大家分享。敬请期待。

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

热爱技术,热爱产品,热爱生活,一个懂技术,懂产品,懂生活的程序员~ 更多精彩内容,可以关注公众号“程序员老猫”。 一起讨论技术,探讨一下点子,研究研究赚钱!
分类: Java锁
标签: 锁的演化, 分布式锁, Java锁
好文要顶 关注我 收藏该文
程序员老猫
关注 - 0
粉丝 - 1
+加关注
0 0
« 上一篇: 【分布式锁的演化】常用锁的种类以及解决方案
posted @ 2020-12-29 12:56 程序员老猫 阅读(0) 评论(0) 编辑 收藏
刷新评论刷新页面返回顶部
登录后才能发表评论,立即 登录 或 注册, 访问 网站首页
写给园友们的一封求助信
【推荐】News: 大型组态、工控、仿真、CADGIS 50万行VC++源码免费下载
【推荐】有你助力,更好为你——博客园用户消费观调查,附带小惊喜!
【推荐】博客园x丝芙兰-圣诞特别活动:圣诞选礼,美力送递
【推荐】了不起的开发者,挡不住的华为,园子里的品牌专区
【福利】AWS携手博客园为开发者送免费套餐+50元京东E卡
【推荐】未知数的距离,毫秒间的传递,声网与你实时互动
【推荐】新一代 NoSQL 数据库,Aerospike专区新鲜入驻

相关博文:
· Java锁?分布式锁?乐观锁?行锁?
· Java锁–悲观锁、乐观锁
· MySQL中的锁(表锁、行锁)
· Mysql锁机制–乐观锁&悲观锁
· 锁
» 更多推荐…

最新 IT 新闻:
· 「逃离硅谷」愈演愈烈,旧金山正在消亡?
· 虽品质过硬 但《使命召唤手游》成不了《和平精英》
· 小米11上手体验:下了狠手,也留了一手
· 连摘两项大奖 腾讯云IPv6产品实力和领先部署能力再获权威机构认可
· 郭德帆:2021年对科技公司的一些预判
» 更多新闻…
公告

关注公众号
技术干货推送
免费资料领取
定时福利发放
昵称: 程序员老猫
园龄: 1个月
粉丝: 1
关注: 0
+加关注
< 2020年12月 >
日 一 二 三 四 五 六
29 30 1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31 1 2
3 4 5 6 7 8 9
搜索

找找看

谷歌搜索
我的标签
Java锁(4)
分布式锁(4)
锁的演化(4)
数据库(2)
数据库分库分表(2)
数据切分(1)
海量数据拆分(1)
海量数据处理(1)
随笔分类
Java锁(3)
数据库(1)
随笔档案
2020年12月(4)
文章分类
数据库(1)
最新评论

  1. Re:【分布式锁的演化】什么是锁?
    好的
    –小鸟酱
  2. Re:【分布式锁的演化】什么是锁?
    @小鸟酱 我这边写的是需要的 你也可以将其去除之后 看看效果哦…
    –程序员老猫
  3. Re:【分布式锁的演化】什么是锁?
    @程序员老猫 就是 改成syn块之后 set方法还用syn修饰吗…
    –小鸟酱
  4. Re:【分布式锁的演化】什么是锁?
    @小鸟酱 可以照着代码敲一下,应该会理解稍微透彻一些,后续老猫也会就锁这块展开演化。希望得到您的持续关注哈…
    –程序员老猫
  5. Re:【分布式锁的演化】什么是锁?
    后面有点模糊了
    –小鸟酱
    阅读排行榜
  6. 【分布式锁的演化】常用锁的种类以及解决方案(279)
  7. 【分布式锁的演化】什么是锁?(212)
  8. 【数据库】海量数据切分方案(19)
    评论排行榜
  9. 【分布式锁的演化】什么是锁?(5)
    推荐排行榜
  10. 【分布式锁的演化】常用锁的种类以及解决方案(1)
  11. 【分布式锁的演化】什么是锁?(1)
    Copyright © 2020 程序员老猫
    Powered by .NET 5.0 on Kubernetes
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值