在电商、秒杀等高并发场景中,超卖问题(库存扣减时出现负数) 是一个经典的技术挑战。以下是基于 Spring Boot 项目的 超卖问题解决方案,结合多种技术手段实现高并发下的库存安全。
1. 超卖问题原因分析
- 根本原因:多个并发请求同时读取库存(如剩余 1 件),都执行扣减操作,导致实际库存扣减多次(如变为 -1)。
- 技术原因:数据库或缓存的 非原子操作 和 并发控制缺失。
2. 解决方案总览
方案 | 原理说明 | 适用场景 | 优缺点 |
---|---|---|---|
数据库乐观锁 | 通过版本号或时间戳控制并发更新 | 低并发,数据一致性要求高 | 简单,但重试率高 |
数据库悲观锁 | 使用 SELECT ... FOR UPDATE 锁定记录 | 极低并发,强一致性 | 性能差,易死锁 |
Redis 原子操作 | 利用 Redis 的 DECR /INCR 原子性扣减库存 | 高并发,允许短暂不一致 | 高性能,需处理数据同步 |
分布式锁 | 使用 Redisson 等分布式锁控制同一商品的库存操作 | 分布式系统,高并发 | 性能中等,实现复杂 |
消息队列 | 将请求放入队列串行处理,异步扣减库存 | 超高并发,最终一致性 | 高吞吐,延迟较高 |
3. 具体实现方案
方案一:数据库乐观锁(版本号法)
原理:通过版本号或时间戳校验,确保库存扣减的原子性。需要有一个数据可以标识在查询到的数据和在进行数据库更新的时候的数据是相同的。(改进可以用库存来标识)
步骤:
-
表结构设计:
CREATE TABLE product ( id BIGINT PRIMARY KEY, stock INT NOT NULL, version INT DEFAULT 0 -- 乐观锁版本号 );
-
更新逻辑:
@Transactional public boolean deductStock(Long productId, int quantity) { // 1. 查询商品信息(带版本号 版本号就是一个标识) Product product = productDao.findById(productId); // 2.如果查询到的库存数量小于用户购买的数量直接返回 库存不足 if (product.getStock() < quantity) { throw new RuntimeException("库存不足"); } // 3. 执行更新(版本号校验)在更新数据库的时候通过查看标识是否发生变化来确保是否可以去修改数据库 int rows = productDao.updateStock( productId, quantity, product.getVersion() ); // 3. 更新成功判定 return rows > 0; }
-
SQL 语句:
UPDATE product SET stock = stock - #{quantity}, version = version + 1 WHERE id = #{productId} AND version = #{oldVersion} AND stock >= #{quantity};
优点:简单易实现,无需额外组件。
缺点:高并发下失败率高,需前端配合重试。
如果是通过库存来去标识的那么在数据库更新的时候不一定是让这个库存和前面查到的完全一样,可以让他大于0都可以进行更新。
方案二:Redis 原子操作 + 数据库最终一致(推荐高性能方案)
原理:使用 Redis 预扣库存(原子操作),异步同步到数据库。
步骤:
-
初始化库存到 Redis:
// 商品ID:1001 初始库存100 redisTemplate.opsForValue().set("stock:1001", 100);
-
扣减库存(原子操作):
public boolean deductStock(Long productId, int quantity) { String key = "stock:" + productId; // 使用 Lua 脚本保证原子性 String script = "if (redis.call('exists', KEYS[1]) == 1) then " + " local stock = tonumber(redis.call('get', KEYS[1])); " + " if (stock >= tonumber(ARGV[1])) then " + " return redis.call('decrby', KEYS[1], ARGV[1]); " + " end; " + " return -1; " + "end; " + "return -2;"; Long result = redisTemplate.execute( new DefaultRedisScript<>(script, Long.class), Collections.singletonList(key), String.valueOf(quantity) ); if (result == null) return false; return result >= 0; }
-
异步同步到数据库:
@Async public void asyncUpdateDBStock(Long productId, int quantity) { productDao.deductStock(productId, quantity); // 数据库扣减(需处理幂等) }
-
补偿机制(防止 Redis 扣减成功但数据库失败):
- 定期对比 Redis 和数据库库存差异,修复数据。
- 订单超时未支付时回滚库存。
优点:高性能,适合秒杀场景。
缺点:需维护 Redis 和数据库的数据一致性。
方案三:Redisson 分布式锁(推荐分布式系统)
原理:通过分布式锁确保同一商品的库存扣减串行化。
步骤:
-
添加 Redisson 依赖:
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.23.2</version> </dependency>
-
配置 Redisson:
spring: redis: host: localhost port: 6379
-
扣减库存逻辑:
@Autowired private RedissonClient redissonClient; public boolean deductStock(Long productId, int quantity) { String lockKey = "product_lock:" + productId; RLock lock = redissonClient.getLock(lockKey); try { // 尝试加锁(等待10秒,锁自动释放时间30秒) if (lock.tryLock(10, 30, TimeUnit.SECONDS)) { Product product = productDao.findById(productId); if (product.getStock() >= quantity) { productDao.deductStock(productId, quantity); return true; } return false; } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } return false; }
优点:强一致性,适合分布式环境。
缺点:性能受锁竞争影响,需合理设计锁粒度。
方案四:消息队列削峰填谷(推荐超高并发)
原理:通过消息队列缓冲请求,异步处理订单。
步骤:
-
发送扣减请求到队列:
@Autowired private RabbitTemplate rabbitTemplate; public void sendDeductMessage(Long productId, int quantity) { Map<String, Object> message = new HashMap<>(); message.put("productId", productId); message.put("quantity", quantity); rabbitTemplate.convertAndSend("stock.deduct.queue", message); }
-
消费者处理库存扣减:
@RabbitListener(queues = "stock.deduct.queue") public void handleDeductMessage(Map<String, Object> message) { Long productId = (Long) message.get("productId"); int quantity = (int) message.get("quantity"); productDao.deductStock(productId, quantity); // 需保证幂等性 }
优点:吞吐量极高,系统解耦。
缺点:数据最终一致,不适合实时响应。
4. 综合方案建议
-
秒杀场景:
Redis 预扣库存 + 消息队列异步下单 + 数据库最终扣减
- 用户请求扣减 Redis 库存(原子操作)。
- 库存足够时,发送消息到队列异步创建订单。
- 消费者从队列读取消息,扣减数据库库存。
-
普通高并发:
Redisson 分布式锁 + 数据库乐观锁
- 使用分布式锁控制商品粒度的并发。
- 数据库乐观锁保证最终一致性。
5. 注意事项
- 幂等性:所有操作需保证幂等(如订单ID去重),防止重复扣减。
- 库存回滚:订单取消或支付超时后,需回滚 Redis 和数据库库存。
- 监控告警:监控库存异常、Redis 与数据库一致性差异。
- 压测验证:使用 JMeter 模拟高并发,验证库存准确性。
通过合理选择方案,结合 Spring Boot 的事务管理、Redis 原子操作和分布式锁,可有效解决超卖问题。