前言

作为一名后端开发,最近在练习电商平台的秒杀模块开发。说实话,秒杀系统看起来简单,但真正做起来坑特别多。这不,刚上线没多久就遇到了经典的超卖问题,库存直接变成负数。今天就来分享一下这次踩坑的经历和最终的解决方案。


业务流程

  1. 用户进入秒杀页面,展示商品信息和倒计时
  2. 活动开始后,用户点击抢购按钮
  3. 系统校验库存,扣减库存,创建订单
  4. 返回抢购结果

问题复现

测试场景

设置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("抢购成功!");
    }
}

问题分析

看着这段代码,问题其实很明显:典型的线程安全问题

想象一下这个场景:

  1. 用户A查询库存,发现还有1件商品
  2. 用户B也查询库存,也发现还有1件商品
  3. 用户C、D、E…同时查询,都发现有库存
  4. 然后大家一起扣减库存,结果库存就变成负数了

解决方案演进

方案一: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();
    }
}

问题分析:
这个方案已经很不错了,解决了超卖问题。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐