订单超时实践记录
订单到期自动关闭的需求可以通过多种方案实现,主要分为以下两类:1. 轮询扫库 2. 延迟队列。
一、轮询扫库
定时轮询存在以下问题:
- 时间不准,存在几秒钟误差,尤其是在订单量大、处理时间长的情况下,误差更容易累积。
- 对数据库有压力,特别是在订单量很大的情况下。
在单机环境下,可以使用 @Scheduled
作为定时任务方案;在分布式环境中,建议使用分布式任务调度平台,例如 XXL-JOB 来实现。
二、延迟队列
延迟队列方案有很多,基本可以分为基于内存和基于分布式的实现。
- 基于内存的方案,例如自定义实现——可以用一个线程扫描 Java 的无界阻塞队列
DelayQueue
,也可以用 Netty 的时间轮来实现。Netty 的时间轮实现简单,但只适用于单机场景,不适用于分布式场景。 - 基于分布式的方案,例如 Redis 过期 key 监听消息或 MQ 延迟消息。使用 Redis 过期 key 非常方便,因为大多数分布式项目中都会用到 Redis;不过需要注意,Redis 官方不保证 key 过期时一定会立即删除或发出消息。因此,许多人认为使用 RabbitMQ 或 RocketMQ 这些延迟消息方案较为理想。
然而在实际项目中,我没有选择使用 MQ 处理订单超时关闭。原因在于,从中小型公司的角度来看,这种方式不一定切合实际。
如果项目并发量不大且服务器资源充足,使用 MQ 来实现服务解耦、分布式事务和订单超时关闭问题是可行的。但在低并发量的场景下,采用简单的轮询任务扫描数据库也能承担,并且实现相对简单。
如果项目并发量大,订单数量多,那么更适合让 MQ 处理更重要的业务,而不是订单超时关闭。即便资源充足,接受这种“资源浪费”,很多订单积累在 MQ 中(如大部分订单已支付),都会成为无效信息。随着消息积累,投递延迟的问题也会影响精确性。
在实际工作中,我最终选用了定时轮询的方案。为减轻数据库压力,我选择轮询缓存数据库 Redis,将数据和超时时间存入 Redis 队列。通过 XXL-JOB 轮询,获取超时数据后关闭订单并删除消息。
为了更精确,可以缩短定时任务的轮询间隔,或直接用一个线程 while(true)
不断处理。
Redisson 实现示例:
将消息存入 Redis 队列,利用有序集合(ZSet)存储数据,并使用 score
存储延迟时间:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.0</version> <!-- 使用最新版本 -->
</dependency>
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RBlockingQueue;
import org.redisson.api.RDelayedQueue;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
@Slf4j
public class DelayedQueueExample {
private final RedissonClient redissonClient;
private final RBlockingQueue<String> blockingQueue;
private final RDelayedQueue<String> delayedQueue;
public DelayedQueueExample(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
// 创建一个阻塞队列
this.blockingQueue = redissonClient.getBlockingQueue("delayQueue");
// 基于阻塞队列创建延迟队列
this.delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
this.startConsumer();
}
// 添加消息到延迟队列
public void addMessageToDelayQueue(String message, long delay, TimeUnit timeUnit) {
delayedQueue.offer(message, delay, timeUnit);
log.info("消息已添加到延迟队列,消息内容: {}", message);
}
// 启动消费线程,处理延迟到期的消息
public void startConsumer() {
new Thread(() -> {
while (true) {
try {
String message = blockingQueue.take();
log.info("处理到期消息: {}", message);
// 执行业务逻辑
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("消费线程被中断");
}
}
}, "DelayedQueueConsumer").start();
}
}
调用 delayedQueue.offer(message, delay, timeUnit)
将消息添加到延迟队列,指定延迟时间。到期后,消息自动进入 blockingQueue
,由消费线程处理。
三、并发问题处理方案
订单到期关闭表面看似简单,但容易忽略一个隐藏风险,特别是缺乏完善数据模型时,风险更难被监控系统捕获。
此风险在于支付成功与订单超时关闭可能同时发生,有两种情况:
- 订单支付成功后执行关闭。此时数据库更新语句带有状态判断,因此关闭订单会失败,无需额外处理。
- 拉起支付后订单超时关闭,随后支付成功。此时可选择“原路退款”或“按成功处理”。
如果是支付中台,不需要关注此情况,支付成功时通知业务端,由业务方决定那种处理方案处理即可。业务方若选择“原路退款”,尽管要处理库存解锁、优惠券恢复等,且需通知用户,但可以简化业务逻辑。若选择“按成功处理”,则需重新扣除库存和优惠券,虽避免退款引起的用户困惑,但流程会更复杂。
推荐“原路退款”方案,因为它避免了业务侵入性,与用户申请退款类似,仅需字段区分退款类型。而按成功处理的方式会增加订单支付成功后的流程复杂度。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!