前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Spring Boot中对于超卖现象的问题分析和解决方案

Spring Boot中对于超卖现象的问题分析和解决方案

作者头像
botkenni
发布2022-05-06 15:54:22
1.1K0
发布2022-05-06 15:54:22
举报
文章被收录于专栏:IT码农

本文只针对单体应用的高并发导致超卖的处理方案。

超卖是指商品本来只有固定的数量比如10个,但是在某一时刻有大量的并发请求涌入,导致商品卖出去了100个,这就是超卖现象。

本文以7种方案来实现减库存操作,然后分析每个方案有什么问题,哪个方案可以解决超卖。

场景设计

创建数据库:

代码语言:javascript
复制
create database mytest charset=utf8;

创建一个商品表:

代码语言:javascript
复制
USE mytest;
 DROP TABLE IF EXISTS `tb_product`;
 CREATE TABLE `tb_product`  (
   `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键id',
   `name` varchar(64) NOT NULL COMMENT '用户名,唯一',
   `price` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '价格',
   `stock` int(10) NOT NULL DEFAULT 0 COMMENT '库存',
   PRIMARY KEY (`id`) USING BTREE,
 ) ENGINE = InnoDB CHARACTER SET = utf8;

然后插入一条数据:

代码语言:javascript
复制
INSERT INTO `mytest`.`tb_product`(`id`, `name`, `price`, `stock`) VALUES (1, 'iPhone6S', 5000.00, 1);
 

现在,我们有了一个商品,且它的库存stock是1,即只有一个。

JMeter模拟高并发

JMeter可以模拟高并发场景,具体的使用请看我的这篇文章:JMeter的下载和使用

模拟一下子进来500个请求。

方案一(事务)

先来看看一个商品减库存函数,分析在高并发下会出现的问题:

代码语言:javascript
复制
/**
  * 简单的减库存操作,不支持高并发
  * @author cc
  * @date 2021-12-30 15:04
 */
 @Transactional(rollbackFor = Exception.class)
 public void sampleSale(Long productId) {
     TbProduct product = productDao.selectByPrimaryKey(productId);
     if (product == null) {
         throw new RuntimeException("没有找到该商品");
     }
     int stock = product.getStock() - 1;
     if (stock >= 0) {
         product.setStock(stock);
         int r = productDao.updateByPrimaryKeySelective(product);
         if (r <= 0) {
             throw new RuntimeException("商品减库存失败");
         }
     } else {
         throw new RuntimeException("库存不足");
     }
 }
 

在上面的函数中,先获取该商品的信息,拿到库存数,当库存数足够,就进行减库存操作。

但是问题是,在高并发下,会有多个线程同时读到商品的库存为1,然后就都进行了减库存操作。假如同一时刻有10个线程,那么减库存操作就会执行10次,商品库存数由1变成了-9。

所以该方案是不行的。

方案二(事务 + 方法锁)
代码语言:javascript
复制
/**
  * 事务 + synchronized,也不能解决高并发
  * 所以这种方式仍然不能解决超卖问题
  * @author cc
  * @date 2021-12-30 15:05
 */
 @Transactional(rollbackFor = Exception.class)
 public synchronized void syncSale(Long productId) {
     TbProduct product = productDao.selectByPrimaryKey(productId);
     if (product == null) {
         throw new RuntimeException("没有找到该商品");
     }
     int stock = product.getStock() - 1;
     if (stock >= 0) {
         product.setStock(stock);
         int r = productDao.updateByPrimaryKeySelective(product);
         if (r <= 0) {
             throw new RuntimeException("商品减库存失败");
         }
     } else {
         throw new RuntimeException("库存不足");
     }
 }
 

和方案一类似,但是在方法前面加了synchronized,经过测试方案二比方案一要好得多,但是多测几遍,会发现超卖问题依然存在,只是概率低了一些。

这是因为锁释放了但是事务没有提交,所以导致多个线程读到了相同的值。

所以这种方式仍然不能解决超卖问题。

方案三(事务 + 代码块锁)
代码语言:javascript
复制
/**
  * 解决上面多个线程同时开启了事务的问题,将synchronized放到函数块里面
  * 可以解决超卖,但是性能比较影响,并且多个请求要排队等待,不建议使用
  * @author cc
  * @date 2021-12-30 15:10
 */
 public void manualSale(Long productId) {
     synchronized (this) {
         sampleSale(productId);
     }
 }
 

这种是方案二的优化版,将锁放到代码块,解决了方案二的问题。

缺点是整个代码块都加锁,如果减库存之后还有其他的耗时操作,其他的请求就需要排很久的队。

方案四(手撸SQL)

通过这样的SQL也可以解决超卖问题:

代码语言:javascript
复制
update `tb_product` set stock = stock - #{amount} WHERE id = #{productId} AND stock >= #{amount}
 /**
  * 手撸sql的方式解决超卖问题
  * InnoDB会自动给UPDATE、DELETE、DELETE语句添加排他锁
  * @author cc
  * @date 2021-12-30 15:03
 */
 public void sqlSale(Long productId) {
     int amount = 1; // 要扣减的数量
     int r = productDao.updateStockById(productId, amount);
     if (r <= 0) {
         throw new RuntimeException("商品减库存失败");
     }
 }
 

这是因为InnoDB引擎会自动给UPDATE、INSERT、DELETE语句添加排他锁,所以通过这样的语句可以防止超卖。

优点很明显,简单方便。

缺点仍然很明显,每一次都要操作数据库,对系统会造成很大的压力。

所以在高并发这种场景下这个方案不适用。

方案五(Redis缓存)

方案四的缺点在硬盘IO上,Redis也是io,使用redis来代替数据库,一个目的为了利用redis的高性能和减少数据库的压力瓶颈

关于Redis可以看我的这篇文章:Spring Boot中Redis的基本使用和优雅的接口数据缓存

使用Redis,我们要提前将商品数据缓存起来:

代码语言:javascript
复制
redisTemplate.opsForHash().increment("stock", "product_1", 1);

缓存的方式有很多种,不一定用hash的incr,这里只是做一个示例。

现在我们在Redis中有一个库存为1的商品,来看看代码示例:

代码语言:javascript
复制
/**
  * 普通的redis策略,将库存放到缓存中,不做其他处理
  * 缺点:不支持高并发,会出现超卖
  * @author cc
  * @date 2021-12-30 14:55
 */
 public void redisNormal(Long productId) {
     String productKey = "product_" + productId;
     // 获取缓存中商品的库存量
     int stock = Integer.parseInt(redisTemplate.opsForHash().get("stock", productKey).toString());
     // 扣减库存
     if (stock > 0) {
         redisTemplate.opsForHash().increment("stock", productKey, -1);
     } else {
         throw new RuntimeException("库存不足");
     }
    // 模拟商品下单的耗时操作
     try {
         Thread.sleep(2000);
     } catch (Exception e) {
         // 商品下单失败
         System.out.println("商品下单失败");
     }
 }
 我们将商品库存的查询放到了内存中,速度更快,但是上面的代码在高并发下会出现超卖现象,所以我们要对查询操作进行加锁。
方案六(Redis + synchronized)
代码语言:javascript
复制
/**
  * redis策略升级版,用synchronized给库存操作上锁
  * 优点:支持高并发
  * @author cc
  * @date 2021-12-30 14:57
 */
 public void redisBySync(Long productId) {
     synchronized (this) {
         String productKey = "product_" + productId;
         // 获取缓存中商品的库存量
         int stock = Integer.parseInt(redisTemplate.opsForHash().get("stock", productKey).toString());
         // 扣减库存
         if (stock > 0) {
             redisTemplate.opsForHash().increment("stock", productKey, -1);
         } else {
             throw new RuntimeException("库存不足");
         }
     }
    // 模拟商品下单的耗时操作
     try {
         Thread.sleep(2000);
     } catch (Exception e) {
         System.out.println("商品下单失败");
     }
 }
方案七(Redis + Lock)
代码语言:javascript
复制
private Lock lock = new ReentrantLock();
/**
  * redis策略升级版,用lock给库存操作上锁
  * 优点:支持高并发,使用起来比synchronized更灵活
  *
  * @author cc
  * @date 2021-12-30 14:59
 */
 public void redisByLock(Long productId) {
     String result = null;
     lock.lock();
     try {
         String productKey = "product_" + productId;
         // 获取缓存中商品的库存量
         int stock = Integer.parseInt(redisTemplate.opsForHash().get("stock", productKey).toString());
         System.out.println("stock: " + stock);
        // 扣减库存
         if (stock > 0) {
             redisTemplate.opsForHash().increment("stock", productKey, -1);
         } else {
             result = "库存不足";
         }
     } catch (RuntimeException e) {
         e.printStackTrace();
     } finally {
         lock.unlock();
     }
     if (result != null) {
         throw new RuntimeException(result);
     }
    // 模拟商品下单的耗时操作
     try {
         Thread.sleep(2000);
     } catch (Exception e) {
         System.out.println("商品下单失败");
     }
 }
 

方案六和方案七只是加锁的方式不一样,Lock比起synchronized,在使用上更加灵活,所以在使用上可以看场景来决定。

两个方案都可以解决高并发下导致的超卖问题,并且是将锁加到库存查询操作中,不影响商品下单的操作,而且使用的是内存,所以速度更快。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2022-05-05,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 场景设计
  • JMeter模拟高并发
  • 方案一(事务)
  • 方案二(事务 + 方法锁)
  • 方案三(事务 + 代码块锁)
  • 方案四(手撸SQL)
  • 方案五(Redis缓存)
  • 方案六(Redis + synchronized)
  • 方案七(Redis + Lock)
相关产品与服务
数据库
云数据库为企业提供了完善的关系型数据库、非关系型数据库、分析型数据库和数据库生态工具。您可以通过产品选择和组合搭建,轻松实现高可靠、高可用性、高性能等数据库需求。云数据库服务也可大幅减少您的运维工作量,更专注于业务发展,让企业一站式享受数据上云及分布式架构的技术红利!
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档