方案一:Redis的过期监听🤤
在redis里面中,key可以设过期时间,这就给我们提供了一个思路:我们可以给预扣减的库存键设一个过期时间,使用redis的发布订阅机制来监听键过期事件,从而自动触发回增库存的逻辑
功能目标:
- 监听 Redis 中所有以
product:开头的商品 key 的过期事件。 - 一旦某个商品 key 过期(比如用户超时未支付),自动执行库存回补(
INCR)操作。
js
public class RedissonStockExpiredListener {
public static void main(String[] args) {
// 模拟预扣减库存
String productKey = "product:1001:stock";
RBatch batch = redisson.getBatch();
batch.get(productKey).setAsync(100L); // 初始化库存
batch.get(productKey).decrementAsync(); // 预扣减
batch.get(productKey).expireAsync(5, TimeUnit.SECONDS); // 设置5秒过期
batch.execute();
System.out.println("已预扣减库存,并设置5秒后过期");
// 订阅 Redis 的 key 过期事件
RTopic topic = redisson.getTopic("__keyevent@*:expired");
topic.addListener(String.class, new MessageListener<String>() {
@Override
public void onMessage(CharSequence channel, String key) {
System.out.println("监听到 key 过期:" + key);
if (key != null && key.startsWith("product:")) {
System.out.println("处理商品 key 过期,准备补偿库存...");
// 补偿库存:INCR
RAtomicLong stock = redisson.getAtomicLong(key);
long newStock = stock.incrementAndGet(); // INCR
System.out.println("库存已补偿,当前库存:" + newStock);
} else {
System.out.println("忽略非商品 key:" + key);
}
}
});
System.out.println("正在监听 Redis key 过期事件...");
}
}
优点是实时性强,对要求实时性高的业务比较友好,如抢票等。缺点是过于依靠Redis,风险不能分摊。且容易造成大量过期Key问题,影响性能。
方案二:Redis的延时队列😉
我们可以基于Redis的Sorted Set数据类型来做一个延时队列,通过score来进行排序,我们服务端下单时在当前时间加上超时时间,将订单ID放入进去,通过定时任务来定量拿取超时订单进行取消订单+补偿库存。
添加订单到延时队列
- 当用户下单但未支付时,计算出订单的超时时间(例如:当前时间 + 30 分钟),然后将订单 ID 以这个时间为 score 添加到 Redis Sorted Set 中。
js
RScoredSortedSet<String> delayedQueue = redisson.getScoredSortedSet("delayed:order:queue");
long timeoutTime = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(30); // 30分钟后超时
delayedQueue.add(timeoutTime, orderId);
定时任务检查并处理超时订单
- 定期运行一个后台任务,使用
ZRANGEBYSCORE获取所有已经到期的订单(即 score 小于等于当前时间戳的所有订单)。 - 对于每个找到的订单,执行取消订单和补偿库存的操作,并从 Sorted Set 中移除这些订单。
js
long currentTime = System.currentTimeMillis();
Collection<String> expiredOrders = delayedQueue.valueRange(0, true, currentTime, false);
for (String orderId : expiredOrders) {
try {
// 执行取消订单逻辑
cancelOrder(orderId);
// 补偿库存逻辑
restoreStock(orderId);
// 从延迟队列中删除该订单
delayedQueue.remove(orderId);
} catch (Exception e) {
// 异常处理
logger.error("处理超时订单失败: {}", orderId, e);
}
}
方案优点和方案一差不多,实时性好延迟小,但是就是过于依靠Redis,有数据丢失的风险。而且Redis是基于内存的,如果超时订单过多对Redis压力过大,容易挂机。
方案三:MQ的延时消息🤩
用户下单之后,生产者将设置好时间的消息发送给broker,等到规定的消息之后,该消息才对消费者可见,broker发送相应消息给消费者,消费者执行订单取消+库存补偿逻辑即可(消息重试机制保证消费者一定可以消费)

生产者:用户下单后发送延时消息
js
@Service
public class OrderService {
private final RocketMQTemplate rocketMQTemplate;
public OrderService(RocketMQTemplate rocketMQTemplate) {
this.rocketMQTemplate = rocketMQTemplate;
}
public void createOrder(String orderId, String productKey) {
// 1. 创建订单(伪代码)
System.out.println("创建订单:" + orderId);
// 2. 预扣减库存(假设用 Redisson)
// RAtomicLong stock = redisson.getAtomicLong(productKey);
// stock.decrementAndGet();
// 3. 发送延迟消息,level=15 对应 30分钟
OrderTimeoutMessage message = new OrderTimeoutMessage(orderId, productKey);
rocketMQTemplate.convertAndSend("ORDER_TIMEOUT_TOPIC", message, null, 15); // level=15
System.out.println("已发送延迟消息,30分钟后处理订单:" + orderId);
}
}
消费者:监听并处理超时订单
js
@Service
@RocketMQMessageListener(topic = "ORDER_TIMEOUT_TOPIC", consumerGroup = "order-timeout-group")
public class OrderTimeoutConsumer implements RocketMQListener<OrderTimeoutMessage> {
@Override
public void onMessage(OrderTimeoutMessage message) {
String orderId = message.orderId;
String productKey = message.productKey;
long expectedExpireTime = message.timestamp + TimeUnit.MINUTES.toMillis(30);
// 1. 判断是否已经过期(防止重复消费或提前消费)
if (System.currentTimeMillis() < expectedExpireTime) {
System.out.println("消息尚未到期,跳过处理:" + orderId);
return;
}
// 2. 查询订单状态(伪代码)
boolean paid = checkIfOrderPaid(orderId);
if (paid) {
System.out.println("订单已支付,无需处理:" + orderId);
return;
}
// 3. 未支付 → 取消订单 + 补偿库存
cancelOrder(orderId);
restoreStock(productKey);
}
private boolean checkIfOrderPaid(String orderId) {
// 查询数据库判断是否已支付(伪逻辑)
return false; // 假设未支付
}
private void cancelOrder(String orderId) {
System.out.println("取消订单:" + orderId);
// 实际操作:更新订单状态为已取消
}
private void restoreStock(String productKey) {
System.out.println("补偿库存:" + productKey);
// 实际操作:INCR productKey
// RAtomicLong stock = redisson.getAtomicLong(productKey);
// stock.incrementAndGet();
}
}
RocketMQ是5.x的版本就可以支持任意时刻的延时消息了,否则就是对应级别的延时消息。
优点是高并发性能好,而且处理消息丢失等方案成熟。缺点就是系统架构更加复杂了,需要处理MQ的其他问题,维护成本变高了。
方案四:分布式定时任务调度框架😈
使用分布式定时任务调度框架,比如xxl-job 或 Quartz是企业里面非常常见的方案。用户下单之后,我们在数据库里面记录订单状态为"待支付",然后通过xxl-job定期扫描过期的订单,筛选出超时未支付的订单,进行取消订单+补偿库存。
js
@Component
public class OrderTimeoutJobHandler {
@XxlJob("orderTimeoutJob")
public void orderTimeoutJob() throws Exception {
XxlJobLogger.log("开始执行超时订单检测任务...");
// 1. 获取当前时间
LocalDateTime now = LocalDateTime.now();
// 2. 查询所有 timeout_time <= now 且 status = 0 的订单
List<Order> expiredOrders = orderMapper.findExpiredOrders(now);
for (Order order : expiredOrders) {
// 3. 加锁防止并发处理同一订单(可选 Redis 分布式锁)
boolean locked = redisTemplate.opsForValue().setIfAbsent(
"lock:order:" + order.getId(), "locked", 5, TimeUnit.MINUTES);
if (!locked) {
continue; // 已被其他节点处理
}
try {
// 4. 再次确认是否已支付(防止并发问题)
Order updatedOrder = orderMapper.selectById(order.getId());
if (updatedOrder.getStatus() != 0) {
continue;
}
// 5. 更新订单状态为"已取消"
orderMapper.updateStatus(order.getId(), 2);
// 6. 补偿库存(如使用 Redisson)
RAtomicLong stock = redisson.getAtomicLong(order.getProductKey());
stock.incrementAndGet();
XxlJobLogger.log("已取消订单:" + order.getId() + ",补偿库存:" + order.getProductKey());
} finally {
// 7. 释放锁
redisTemplate.delete("lock:order:" + order.getId());
}
}
}
}
重复补偿库存问题😗
由于网络延迟或者网络分区,又或者是Redis服务器问题,还有并发问题等,有可能会导致重复补偿库存的情况,那么该怎么解决呢?
1.使用分布式锁
- 在执行库存补偿之前,尝试获取一个针对特定商品 ID 的分布式锁。只有成功获取锁的进程才能执行补偿操作。这样可以防止多个实例同时处理同一个过期事件。
- 示例代码片段:
js
RLock lock = redisson.getLock("lock:product:" + productId);
if (lock.tryLock()) {
try {
// 执行库存补偿逻辑
} finally {
lock.unlock();
}
}
2.状态标记
- 在补偿库存后,给对应的 key 添加一个特殊的标识(如
compensated标记)。下次再遇到该 key 过期时,先检查是否存在这个标记。如果存在,则跳过补偿步骤。 - 示例代码片段:
js
if (!redissonMap.containsKey(productId + ":compensated")) {
// 执行库存补偿逻辑
redissonMap.put(productId + ":compensated", true);
}
3.延迟队列结合定时任务
刚才方案二就可以解决这个问题,将需要补偿的订单信息放入延迟队列中,并设置适当的延迟时间。通过后台定时任务统一处理这些订单,确保每个订单只被处理一次。
4.数据库唯一约束
通过建立补偿库存表,业务和订单id作为唯一索引,每次补偿之前尝试是否可以插入该表,可以则补偿,反之则跳过。