本文只针对单体应用的高并发导致超卖的处理方案。
超卖是指商品本来只有固定的数量比如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,在使用上更加灵活,所以在使用上可以看场景来决定。
两个方案都可以解决高并发下导致的超卖问题,并且是将锁加到库存查询操作中,不影响商品下单的操作,而且使用的是内存,所以速度更快。