引言
在后端开发和面试中,如果是做类似秒杀和商城等业务时,我们难免会遇到关于订单超时了,但是Redis里面的库存已经预扣减了,那么如何把预扣减的库存进行补偿呢?下面和大家讨论几种比较常见常用的方案。
为什么要补偿预扣减的库存?😵
古人常说,要做一件事之前,先要清楚为什么要做这件事,再去想解决这件事的方案。我认为这种说法也是对的,只有先清楚为什么要补偿预扣减的库存,才能获得解决方案的灵感。
在一个秒杀业务中,大家一起来抢购这种商品,但是商品数额有限。一位用户率先抢到这个商品,已经成功下单(即已经有了订单号),但是尚未结款,也就代表数据库还没有真正落库扣减这个商品,只是在Redis进行预扣减了这件库存。该用户在后来的一段时间因为某事忘记了付款,即订单超时,那么我们就要把这个Redis中预扣减的库存补偿回Redis中,否则就会造成后来的用户无法继续抢购,也就是少卖现象,会给商家利益造成损失。
方案一:Redis的过期监听🤤
在redis里面中,key可以设过期时间,这就给我们提供了一个思路:我们可以给预扣减的库存键设一个过期时间,使用redis的发布订阅机制来监听键过期事件,从而自动触发回增库存的逻辑
功能目标:
- 监听 Redis 中所有以
product:
开头的商品 key 的过期事件。 - 一旦某个商品 key 过期(比如用户超时未支付),自动执行库存回补(
INCR
)操作。
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 中。
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 中移除这些订单。
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的延时消息🤩
我们通过RocketMQ来讲这个例子,用户下单之后,生产者将设置好时间的消息发送给broker,等到规定的消息之后,该消息才对消费者可见,broker发送相应消息给消费者,消费者执行订单取消+库存补偿逻辑即可(消息重试机制保证消费者一定可以消费)
生产者:用户下单后发送延时消息
@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);
}
}
消费者:监听并处理超时订单
@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定期扫描过期的订单,筛选出超时未支付的订单,进行取消订单+补偿库存。
@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());
}
}
}
}
笔者更加推荐xxl-job来实现分布式定时任务,因为它自带可视化界面,兼顾性能与精准,更适合微服务架构下的分布式任务调度
这种方案对实时性支持不是很好,且框架学习成本高。
重复补偿库存问题😗
由于网络延迟或者网络分区,又或者是Redis服务器问题,还有并发问题等,有可能会导致重复补偿库存的情况,那么该怎么解决呢?
1.使用分布式锁
-
在执行库存补偿之前,尝试获取一个针对特定商品 ID 的分布式锁。只有成功获取锁的进程才能执行补偿操作。这样可以防止多个实例同时处理同一个过期事件。
-
示例代码片段:
RLock lock = redisson.getLock("lock:product:" + productId); if (lock.tryLock()) { try { // 执行库存补偿逻辑 } finally { lock.unlock(); } }
2.状态标记
-
在补偿库存后,给对应的 key 添加一个特殊的标识(如
compensated
标记)。下次再遇到该 key 过期时,先检查是否存在这个标记。如果存在,则跳过补偿步骤。 -
示例代码片段:
if (!redissonMap.containsKey(productId + ":compensated")) { // 执行库存补偿逻辑 redissonMap.put(productId + ":compensated", true); }
3.延迟队列结合定时任务
刚才方案二就可以解决这个问题,将需要补偿的订单信息放入延迟队列中,并设置适当的延迟时间。通过后台定时任务统一处理这些订单,确保每个订单只被处理一次
4.数据库唯一约束
通过建立补偿库存表,业务和订单id作为唯一索引,每次补偿之前尝试是否可以插入该表,可以则补偿,反之则跳过。
总结❤️
实际业务不一定要按照上面的方案执行,可以根据实际来组合使用(●’◡’●)。如果你看了这篇文章有收获可以点赞+关注+收藏🤩,这是对笔者更新的最大鼓励!如果你有更多方案或者文章中有错漏之处,请在评论区提出帮助笔者勘误,祝你拿到更好的offer!