电商秒杀系统中的超卖问题
·
文章目录
前言
作为一名后端开发,最近在练习电商平台的秒杀模块开发。说实话,秒杀系统看起来简单,但真正做起来坑特别多。这不,刚上线没多久就遇到了经典的超卖问题,库存直接变成负数。今天就来分享一下这次踩坑的经历和最终的解决方案。
业务流程
- 用户进入秒杀页面,展示商品信息和倒计时
- 活动开始后,用户点击抢购按钮
- 系统校验库存,扣减库存,创建订单
- 返回抢购结果
问题复现
测试场景
设置100件商品库存,设置500个用户用JMeter模拟高并发请求。
问题现象
库存显示:-127(竟然是负数!)
成功订单:234个(远超库存数量)
失败订单:266个
原始代码
@Service
public class SeckillService {
@Autowired
private ProductService productService;
@Autowired
private OrderService orderService;
public Result<String> seckill(Long productId, Long userId) {
// 1. 查询商品信息
Product product = productService.getById(productId);
if (product == null) {
return Result.fail("商品不存在");
}
// 2. 检查活动时间
if (!checkSeckillTime(product)) {
return Result.fail("活动未开始或已结束");
}
// 3. 检查库存(问题出现在这里)
if (product.getStock() < 1) {
return Result.fail("库存不足!");
}
// 4. 扣减库存
product.setStock(product.getStock() - 1);
productService.updateById(product);
// 5. 创建订单
Order order = new Order();
order.setProductId(productId);
order.setUserId(userId);
order.setPrice(product.getPrice().multiply(new BigDecimal("0.8")));
orderService.save(order);
return Result.success("抢购成功!");
}
}
问题分析
看着这段代码,问题其实很明显:典型的线程安全问题。
想象一下这个场景:
- 用户A查询库存,发现还有1件商品
- 用户B也查询库存,也发现还有1件商品
- 用户C、D、E…同时查询,都发现有库存
- 然后大家一起扣减库存,结果库存就变成负数了
解决方案演进
方案一:synchronized 同步锁
最直接的想法就是加锁,保证同一时间只有一个线程能执行扣减操作。
@Service
public class SeckillService {
// 使用synchronized关键字
public synchronized Result<String> seckill(Long productId, Long userId) {
// 原有逻辑
Product product = productService.getById(productId);
if (product.getStock() < 1) {
return Result.fail("库存不足!");
}
product.setStock(product.getStock() - 1);
productService.updateById(product);
// 创建订单逻辑...
return Result.success("抢购成功!");
}
}
结果分析:
虽然解决了超卖问题,但是性能实在太差了。synchronized是JVM级别的锁,在分布式环境下也不适用。
方案二:数据库悲观锁
既然JVM锁不行,那我们试试数据库层面的锁。
@Service
public class SeckillService {
public Result<String> seckill(Long productId, Long userId) {
// 使用SELECT FOR UPDATE加锁
Product product = productService.getByIdWithLock(productId);
if (product.getStock() < 1) {
return Result.fail("库存不足!");
}
product.setStock(product.getStock() - 1);
productService.updateById(product);
// 创建订单逻辑...
return Result.success("抢购成功!");
}
}
对应的SQL:
SELECT * FROM product WHERE id = #{id} FOR UPDATE;
测试结果:
库存正常:0
订单数量:100(正确)
性能问题:TPS提升到400,但还是不够
结果分析:
虽然解决了超卖问题,但是悲观锁的问题是锁粒度太大,而且容易造成死锁。在秒杀这种高并发场景下,数据库压力会非常大。
方案三:乐观锁(version版本号)
乐观锁的思路是:不加锁,但在更新时检查数据是否被其他线程修改过。
@Service
public class SeckillService {
public Result<String> seckill(Long productId, Long userId) {
Product product = productService.getById(productId);
if (product.getStock() < 1) {
return Result.fail("库存不足!");
}
// 使用乐观锁更新
product.setStock(product.getStock() - 1);
boolean success = productService.updateWithVersion(product);
if (!success) {
return Result.fail("抢购失败,请重试!");
}
// 创建订单逻辑...
return Result.success("抢购成功!");
}
}
对应的SQL:
UPDATE product
SET stock = #{stock}, version = version + 1
WHERE id = #{id} AND version = #{version};
结果分析:
乐观锁虽然解决了性能问题,但是用户体验不好。大部分用户会看到"请重试"的提示,这在秒杀场景下是不可接受的。
方案四:CAS原子操作
既然传统的版本号乐观锁体验不好,那我们试试直接在SQL层面做原子操作。
@Service
public class SeckillService {
public Result<String> seckill(Long productId, Long userId) {
// 直接在数据库层面做原子扣减
boolean success = productService.decreaseStock(productId);
if (!success) {
return Result.fail("库存不足!");
}
// 创建订单逻辑...
return Result.success("抢购成功!");
}
}
关键SQL:
UPDATE product
SET stock = stock - 1
WHERE id = #{id} AND stock > 0;
MyBatis-Plus实现:
@Service
public class ProductServiceImpl extends ServiceImpl<ProductMapper, Product> implements ProductService {
public boolean decreaseStock(Long productId) {
return this.update()
.setSql("stock = stock - 1")
.eq("id", productId)
.gt("stock", 0)
.update();
}
}
问题分析:
这个方案已经很不错了,解决了超卖问题。
更多推荐




所有评论(0)