一、Bug 场景
在一个电商系统中,订单创建后若 30 分钟未支付则自动取消。系统采用两种方式处理超时订单:① 定时任务(每 5 分钟扫描一次未支付订单,执行取消逻辑);② 基于 Redis 过期键通知(订单创建时在 Redis 中设置 30 分钟过期键,过期时触发取消回调)。但实际运行中发现,部分订单被重复取消,导致库存回滚多次,出现库存异常。
二、代码示例
定时任务取消订单
java
@Component
@EnableScheduling
public class OrderTimeoutSchedule {
@Autowired
private OrderService orderService;
// 每5分钟执行一次
@Scheduled(cron = "0 0/5 * * * ?")
public void cancelTimeoutOrders() {
// 查询30分钟前未支付的订单
List<Order> timeoutOrders = orderService.queryUnpaidOrdersBefore(LocalDateTime.now().minusMinutes(30));
for (Order order : timeoutOrders) {
// 执行取消订单逻辑(扣减的库存回滚)
orderService.cancelOrder(order.getId());
}
}
}
Redis 过期通知取消订单
java
@Component
public class RedisKeyExpireListener {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private OrderService orderService;
@PostConstruct
public void init() {
// 订阅Redis过期键通知
redisTemplate.getConnectionFactory().getConnection().subscribe(
message -> {
// 消息内容为过期的键名,格式:order:timeout:{orderId}
String expiredKey = new String(message.getBody());
if (expiredKey.startsWith("order:timeout:")) {
String orderId = expiredKey.split(":")[2];
// 执行取消订单逻辑
orderService.cancelOrder(Long.parseLong(orderId));
}
},
"__keyevent@0__:expired".getBytes()
);
}
}
订单服务(取消订单逻辑)
java
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private ProductMapper productMapper;
/**
* 取消订单(回滚库存)
*/
@Transactional
public void cancelOrder(Long orderId) {
Order order = orderMapper.selectById(orderId);
if (order == null || order.getStatus() != 0) { // 0-未支付
return;
}
// 1. 更新订单状态为取消
order.setStatus(2); // 2-已取消
orderMapper.updateById(order);
// 2. 回滚商品库存
productMapper.increaseStock(order.getProductId(), order.getQuantity());
}
// 其他方法:queryUnpaidOrdersBefore等
}
三、问题描述
-
预期行为:每个超时未支付的订单仅被取消一次,库存正确回滚一次。
-
实际行为:部分订单被重复取消,导致库存多次回滚(例如,实际库存 10 件,订单购买 2 件,重复取消 3 次后库存变为 16 件)。原因如下:
- 定时任务与 Redis 通知冲突:同一订单既被定时任务扫描到,又触发了 Redis 过期通知,两种机制同时执行取消逻辑。
- 定时任务内部并发:定时任务执行时,若处理时间过长,下一次任务提前启动(如前一次未执行完,新任务开始),导致同一批订单被重复处理。
- 订单状态判断非原子 :
cancelOrder方法中,"查询订单状态" 与 "更新状态" 非原子操作,高并发下可能多个线程同时通过状态检查,执行后续逻辑。
四、解决方案
-
统一超时取消入口:保留一种主要机制(如 Redis 过期通知),定时任务作为兜底,且兜底任务增加幂等性校验。
java// 定时任务优化:仅处理未被Redis通知处理的订单 @Scheduled(cron = "0 0/5 * * * ?") public void cancelTimeoutOrders() { List<Order> timeoutOrders = orderService.queryUnpaidOrdersBefore(LocalDateTime.now().minusMinutes(35)); // 比Redis过期时间晚5分钟,确保Redis已处理 for (Order order : timeoutOrders) { orderService.cancelOrder(order.getId()); } } -
定时任务加分布式锁:防止任务并发执行,确保同一时间只有一个实例的定时任务在运行。
java@Scheduled(cron = "0 0/5 * * * ?") public void cancelTimeoutOrders() { String lockKey = "lock:order:cancel:schedule"; Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 1, TimeUnit.MINUTES); if (Boolean.TRUE.equals(locked)) { try { // 执行取消逻辑 List<Order> timeoutOrders = orderService.queryUnpaidOrdersBefore(LocalDateTime.now().minusMinutes(35)); for (Order order : timeoutOrders) { orderService.cancelOrder(order.getId()); } } finally { redisTemplate.delete(lockKey); } } } -
订单状态更新原子化:通过 SQL 条件更新确保状态判断与更新的原子性,替代先查后更。
java@Transactional public void cancelOrder(Long orderId) { // 1. 原子更新订单状态(仅当状态为0时更新为2) int updateRows = orderMapper.updateStatusToCanceled(orderId); if (updateRows == 0) { return; // 状态已变更,无需处理 } // 2. 回滚库存(仅当状态更新成功时执行) Order order = orderMapper.selectById(orderId); productMapper.increaseStock(order.getProductId(), order.getQuantity()); }对应的 SQL(OrderMapper.xml):
xml<update id="updateStatusToCanceled"> UPDATE `order` SET status = 2, cancel_time = NOW() WHERE id = #{orderId} AND status = 0 </update>