一、Bug 场景
在一个电商平台的秒杀活动中,某款限量商品(库存 100 件)开启秒杀。系统使用 Spring Boot + Redis + MySQL 实现,用户通过前端点击秒杀按钮提交请求,后端校验库存后创建订单并扣减库存。但活动结束后,发现实际下单数量远超 100 件,出现超卖;同时,部分用户反馈点击一次秒杀按钮,却收到了多个订单成功的通知,存在重复下单问题。
二、代码示例
秒杀服务实现(有缺陷)
java
@Service
public class SeckillService {
@Autowired
private ProductMapper productMapper;
@Autowired
private OrderMapper orderMapper;
@Autowired
private StringRedisTemplate redisTemplate;
// 商品库存缓存键
private static final String STOCK_KEY = "seckill:stock:%d";
/**
* 秒杀核心逻辑
*/
public boolean seckill(Long productId, Long userId) {
// 1. 检查 Redis 缓存中的库存
String stockKey = String.format(STOCK_KEY, productId);
Integer stock = Integer.parseInt(redisTemplate.opsForValue().get(stockKey));
if (stock == null || stock <= 0) {
return false; // 库存不足
}
// 2. 检查用户是否已下单(防重复下单)
String userOrderKey = "seckill:order:" + productId + ":" + userId;
if (Boolean.TRUE.equals(redisTemplate.hasKey(userOrderKey))) {
return false; // 已下单
}
// 3. 扣减 Redis 库存
redisTemplate.opsForValue().decrement(stockKey);
// 4. 创建订单并扣减数据库库存
try {
// 扣减数据库库存
int rows = productMapper.decreaseStock(productId, 1);
if (rows <= 0) {
// 数据库扣减失败,回滚 Redis 库存
redisTemplate.opsForValue().increment(stockKey);
return false;
}
// 创建订单
Order order = new Order();
order.setProductId(productId);
order.setUserId(userId);
order.setStatus(1); // 未支付
orderMapper.insert(order);
// 标记用户已下单
redisTemplate.opsForValue().set(userOrderKey, "1", 24, TimeUnit.HOURS);
return true;
} catch (Exception e) {
// 异常回滚 Redis 库存
redisTemplate.opsForValue().increment(stockKey);
return false;
}
}
}
控制器
java
@RestController
@RequestMapping("/seckill")
public class SeckillController {
@Autowired
private SeckillService seckillService;
@PostMapping("/{productId}")
public ResponseEntity<String> doSeckill(@PathVariable Long productId, @RequestParam Long userId) {
boolean success = seckillService.seckill(productId, userId);
return success ? ResponseEntity.ok("秒杀成功") : ResponseEntity.ok("秒杀失败");
}
}
三、问题描述
-
预期行为:秒杀商品库存严格控制在 100 件以内,每个用户只能成功下单一次。
-
实际行为:
- 超卖问题:高并发下,多个线程同时通过 Redis 库存检查(步骤 1),都认为库存充足,导致 Redis 库存扣减后实际库存为负,最终数据库扣减也超过实际库存。例如,库存剩 1 时,10 个线程同时通过检查,Redis 扣减后变为 -9,数据库最终库存被减为 -9。
- 重复下单问题 :用户快速点击秒杀按钮时,多个请求同时到达,都通过 "用户已下单" 检查(步骤 2),导致同一用户创建多个订单。原因是 Redis 的
hasKey检查与set标记非原子操作,存在间隙。
四、解决方案
-
解决超卖问题:
- 使用 Redis 原子操作校验并扣减库存,替代先查后减的非原子逻辑:
java// 替换步骤 1 和 3:原子性检查并扣减库存 Long remainStock = redisTemplate.opsForValue().decrement(stockKey); if (remainStock == null || remainStock < 0) { // 库存不足,回滚 redisTemplate.opsForValue().increment(stockKey); return false; }- 数据库层面加行锁,确保扣减库存的原子性:
sql-- ProductMapper 的 decreaseStock 方法对应的 SQL UPDATE product SET stock = stock - 1 WHERE id = #{productId} AND stock > 0; -
解决重复下单问题:
- 使用 Redis 的
setIfAbsent原子操作,检查并标记用户下单状态:
java// 替换步骤 2:原子性检查并标记已下单 String userOrderKey = "seckill:order:" + productId + ":" + userId; Boolean isFirstOrder = redisTemplate.opsForValue().setIfAbsent(userOrderKey, "1", 24, TimeUnit.HOURS); if (isFirstOrder == null || !isFirstOrder) { // 已下单或设置失败,回滚库存 redisTemplate.opsForValue().increment(stockKey); return false; } - 使用 Redis 的
-
最终优化后的秒杀逻辑:
java
public boolean seckill(Long productId, Long userId) {
String stockKey = String.format(STOCK_KEY, productId);
String userOrderKey = "seckill:order:" + productId + ":" + userId;
// 1. 原子扣减库存,判断是否成功
Long remainStock = redisTemplate.opsForValue().decrement(stockKey);
if (remainStock == null || remainStock < 0) {
redisTemplate.opsForValue().increment(stockKey); // 回滚
return false;
}
// 2. 原子检查并标记用户下单
Boolean isFirstOrder = redisTemplate.opsForValue().setIfAbsent(userOrderKey, "1", 24, TimeUnit.HOURS);
if (isFirstOrder == null || !isFirstOrder) {
redisTemplate.opsForValue().increment(stockKey); // 回滚库存
return false;
}
// 3. 数据库操作(扣减库存 + 创建订单)
try {
int rows = productMapper.decreaseStock(productId, 1);
if (rows <= 0) {
// 数据库扣减失败,双重回滚
redisTemplate.opsForValue().increment(stockKey);
redisTemplate.delete(userOrderKey);
return false;
}
Order order = new Order();
order.setProductId(productId);
order.setUserId(userId);
order.setStatus(1);
orderMapper.insert(order);
return true;
} catch (Exception e) {
// 异常回滚
redisTemplate.opsForValue().increment(stockKey);
redisTemplate.delete(userOrderKey);
return false;
}
}