订单超时自动处理方案
通过这篇文章你将了解到订单超时自动处理的常见场景,如:超时未付款、未发货、未确认收货等;常见处理方式,如:定时任务扫描、Redis过期键监听、Redis有序集合、JDK延时队列、RabbitMQ延时队列、RocketMQ延时消息、时间轮算法等;并将了解这些实现方式的原理、优缺点、示例代码等。

目录
前言:
在电商场景下,由于交易双方没有面对面进行交易,故很多情况下需要系统自动处理订单超时。在一个订单流程中,有很多环节需要订单超时自动处理,常见的有:
- 买家超时未付款:如买家下单后超过 15 分钟没有支付,则订单需要自动取消。
- 商家超时未发货:如商家超过一个月没有发货,则订单需要自动取消。
- 买家超时未收货:如买家收到货后没有在 14 天内点击确认收货,则订单需要自动确认收货。
1 定时任务扫描
1.1 定时任务扫描 实现原理
使用定时任务每隔一定时间扫描数据库,查询超时订单并执行自动处理操作。在实际应用中,需要根据业务场景合理设置定时任务时间间隔,如对一些实时性要求不高的场景,可适当增加时间间隔,减少数据库访问频率,以降低数据库负载。
在 java 语言环境中,定时任务常见的实现方式有:spring 框架提供的 @Scheduled 注解, Quartz Scheduler 框架、 XXl-JOB 框架等。
1.2 定时任务扫描 优缺点
- 优点:
- 实现简单易理解;
- 较少依赖易维护;
- 适用于订单量小(日均 < 10万)、对实时性要求不高的场景。
- 缺点:
- 实时性差,取决于扫描频率;
- 资源浪费,基石没有订单也要定时执行;
- 数据量大时性能差,需要全表扫描;
1.3 定时任务扫描 示例代码
以 spring 提供的 @Scheduled 注解实现为例:
@Component
public class ScheduledTask {
// 每分钟执行一次
@Scheduled(cron = "0 * * * * ?")
public void orderSchedule() {
// 查询 30 分钟前创建且未支付的订单
LocalDateTime expireTime = LocalDateTime.now().minusMinutes(30);
List<Order> list = new ArrayList<>(); // 假装是为支付的订单列表
for (Order order : list) {
// todo 关闭订单 恢复库存等
}
}
}
2 Redis 过期键监听
2.1 Redis 过期键监听 实现原理
创建订单时,将订单 ID 存入 redis 并设置过期时间,通过监听 redis key 过期事件,当订单 ID 过期(超时)时执行订单超时自动处理操作。注意,使用时需要在 redis 配置文件中开启键过期事件监听回调,即配置 notify-keyspace-events Ex。
2.2 Redis 过期键监听 优缺点
- 优点:
- 实现简单性能好(受益于 redis 支持分布式);
- 适用于对可靠性要求不高的场景,如买家超时未确认收货;
- 缺点:
- 过期事件不可靠(与 redis 过期策略有关),可能丢失,可通过定时任务扫描兜底(如每小时执行);
- redis 宕机或重启有可能丢失过期事件,可通过定时任务扫描兜底(如每小时执行);
2.3 Redis 过期键监听 示例代码
// 配置 redis 消息监听容器
@Configuration
public class RedisListenerConfig {
@Bean
RedisMessageListenerContainer container(RedisConnectionFactory factory){
RedisMessageListenerContainer container=new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
return container;
}
}
// key 过期事件消息监听器
@Component
public class RedisKeyExpireListener extends KeyExpirationEventMessageListener {
public RedisKeyExpireListener(RedisMessageListenerContainer container) {
super(container);
}
@Override
public void onMessage(Message message, byte[] pattern) {
String expiredKey = message.toString();
// todo 关闭订单 恢复库存等
}
}
redis 过期事件为什么不可靠?这和 redis 的过期策略有关。redis 中主要采用 定期删除 + 惰性删除 + 内存淘汰机制 的策略来进行过期 key 的删除:
- 定期删除:每隔一段时间(默认 100ms)随机抽取一些设置了过期时间的 key,检查其是否已过期,若已过期则将其删除。定时删除会造成没有设置过期时间且实际上该缓存数据已经过期或设置了过期时间但已经过期的 key 没有被随机抽取到,于是就有了惰性删除。
- 惰性删除:当客户端访问某个 key 时,
redis会先检查其是否设置了过期时间,若设置了则再检查是否已过期,若已过期则直接删除并返回空。惰性删除会造成长时间不被使用且已过期的 key 依旧存在,于是就有了内存淘汰机制。 - 内存淘汰机制:
redis提供了八种内存淘汰机制,碍于篇幅原因,有兴趣的同学可自行了解。但内存淘汰机制本质上还是随机抽取,抽不到怎么办?只能等下一次咯。
从 定期删除 + 队形删除 + 内存淘汰机制 这一套组合拳的实现逻辑来看,redis 的过期删除机制实际上是不精准的,即过期的 key 不能被立即删除,存在延迟,那就会导致过期事件的通知也会存在延迟,进而延迟订单超时自动处理。
同时,redis 过期事件的通知也是不可靠的,redis 在过期事件通知的时候,若应用重启了或 redis 宕机/重启,那就有可能导致通知事件丢失。故如果一定要在可靠性要求较高的场景用 redis 过期键监听策略实现订单超时自动处理,建议通过定时任务等机制进行补偿。
3 Redis Zset 有序集合
3.1 Redis Zset 有序集合 实现原理
利用 redis zset(Sorted Set)有序集合,创建订单时以订单 ID 作为 member,过期时间的时间戳作为 score 存入zset,然后通过定时任务执行 ZRANGEBYSCORE 命令查询已过期的记录进行订单超时自动处理操作。被存入 zset 中的订单信息将以 score(即过期时间时间戳)升序排列(默认升序排列),处理完成超时订单后需要删除对应的 member。
3.2 Redis Zset 有序集合 优缺点
- 优点:
- 高性能、高可靠度;
- 精确度高;
- 适用于、高并发场景,推荐使用。
- 缺点:
- 需要定时轮询;
3.3 Redis Zset 有序集合 示例代码
// 使用 RedisTemplate 操作 redis
redisTemplate.opsForZSet().add("TIMEOUT_ORDER_KEy", orderId, expireTime);
// 定时任务处理超时订单
@Component
public class ScheduledTask {
// 每 5秒 执行一次
@Scheduled(cron = "0/5 * * * * ?")
public void orderSchedule() {
// 查询 score <= now 的订单
long now = System.currentTimeMillis();
Set<String> orderIds = redisTemplate.opsForZSet().rangeByScore("TIMEOUT_ORDER_KEy", 0 now);
if (CollectionUtils.isEmpty(orderIds)) {
return;
}
for (String orderId : orderIds) {
// todo 关闭订单 恢复库存等
}
}
}
4 JDK 延时队列
4.1 JDK 延时队列 实现原理
JDK 内置了一种延时队列数据结构 DelayQueue,其本质是基于优先级队列 PriorityQueue 实现的无界阻塞队列,可对队列内元素进行排序。创建订单时将订单 ID 放入队列,并以超时时间作为排序条件,订单 ID 会在队列内以超时时间从小到大进行排序,最小的在队头,并起一个线程不停轮询队列头部,若超时则出队进行订单超时自动处理操作。
因 JDK 延时队列是基于内存的,为防止服务宕机或重启导致数据丢失,则需要服务启动时从数据库加载未完成订单,初始化到延时队列中。
4.2 JDK 延时队列 优缺点
- 优点:
- 实现简单成本低;
- 精确到毫秒级,时效性好(
DelayQueue的轮询是通过for (;;)实现的); - 适用于中小规模订单量,对实时性要求较高的场景;
- 缺点:
- 重启后数据会丢失,需要持久化;
- 存在单点故障,不能做到分布式,但可结合分布式锁在集群中选一个 leader 专门处理,效率低;
- 数据量大时占用内存大;
4.3 JDK 延时队列 示例代码
// 放入 DelayQueue 的对象需要实现 Delayed 接口
@Data
public class OrderDelayed implements Delayed {
private final String orderId; // 订单 ID
private final Long delayTime; // 超时时间 如 15 分钟(需要转换成毫秒)
public OrderDelayed(String orderId, Long delayTime) {
this.orderId = orderId;
this.delayTime = System.currentTimeMillis() + delayTime;
}
// 获取剩余延迟时间
@Override
public long getDelay(TimeUnit unit) {
long diff = this.delayTime - System.currentTimeMillis();
return unit.convert(diff, TimeUnit.MILLISECONDS);
}
// 比较器 安过期时间升序排列
@Override
public int compareTo(Delayed o) {
return this.delayTime.compareTo(((OrderDelayed) o).delayTime);
}
}
// 延时订单管理器
@Component
public class OrderDelayedManager implements ApplicationRunner, DisposableBean {
// 延时队列 本身线程安全
private final DelayQueue<OrderDelayed> queue = new DelayQueue<>();
// 消费线程池
private ExecutorService executor;
// 应用启动后执行
@Override
public void run(ApplicationArguments args) throws Exception {
// 创建三个线程消费
executor = Executors.newFixedThreadPool(3);
// 启动线程进行消息
for (int i = 0; i < 3; i++) {
executor.submit(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
// 若队列为空或头节点未达超时时间 则一直阻塞
OrderDelayed orderDelayed = queue.take();
// todo 关闭订单 恢复库存等
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 消费线程被中断
break;
} catch (Exception e) {
// 异常处理
}
}
});
}
}
// 添加延时订单
public void addDelayOrder(String orderId, long delayTime) {
this.queue.put(new OrderDelayed(orderId, delayTime));
}
// 应用关闭时关闭线程池
@Override
public void destroy() throws Exception {
if (executor == null) {
return;
}
executor.shutdown();
// 停止时若有任务正在执行 则等待其执行完毕(等待 10秒)
try {
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdown();
}
}
}
5 RabbitMQ 延时队列
5.1 RabbitMQ 延时队列 实现原理
利用 RabbitMQ 的消息 TTL(Time To Live) + 死信交换机(Dead Letter Exchange, DLX) 实现延时效果。关于消息的 TTL 与 死信交换机:
- TTL:Time To Live,即消息的存活时间。RabbitMQ 可以对队列和消息分别设置 TTL,若对队列设置,则队列中的所有消息都具有相同的过期时间。当消息存活时间超过了 TTL 时,则认为这个消息死了,称之为死信。
- DLX:Dead Letter Exchange,即死信交换机。当消息满足下列条件之一时,消息会进入死信交换机:
- 消息被消费者拒收了,并且
reject方法的参数requeue值为false,即消息不会被再次放在队列里,被其他消费者使用。 - TTL 到期的消息。
- 队列满了被丢弃的消息。
- 消息被消费者拒收了,并且
由此可知,基于 RabbitMQ 的消息 TTL + DLX 实现订单超时自动处理的工作流程为:
- 1、消息发送到延时队列(设置了 TTL,但没有消费者);
- 2、消息在队列中等待 TTL 到期;
- 3、TTL 到期后,消息变成死信;
- 4、死信被路由到死信交换机;
- 5、死信交换机将死信路由到死信队列;
- 6、消费者监听死信队列获取消息并处理;
5.2 RabbitMQ 延时队列 优缺点
- 优点:
- 可靠性高,支持持久化;
- 海量数据,支持分布式;
- 精确控制延时时间;
- 适用于大规模分布式系统,对可靠性要求较高的场景;
- 缺点:
- 需依赖 MQ 中间件;
- 使用较复杂,需要配置延时队列与交换机等;
5.3 RabbitMQ 延时队列 示例代码
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
// RabbitMQ 配置
@Configuration
public class RabbitMQConfig {
/**
* 延时队列名称
*/
public static final String ORDER_DELAY_QUEUE = "order.delay.queue";
// 延时队列(无消费者监听)
@Bean
public Queue orderDelayQueue() {
Map<String, Object> map = new HashMap<>();
map.put("x-message-ttl", 30 * 60 * 1000); // 消息 TTL 30min
map.put("x-dead-letter-exchange", "order.dlx.exchange"); // 死信交换机
map.put("x-dead-letter-routing-key", "order.close"); // 死信路由键
return new Queue(ORDER_DELAY_QUEUE, true, false, false, map);
}
// 死信交换机
@Bean
public DirectExchange deadLetterExchange() {
return new DirectExchange("order.dlx.exchange", true, false);
}
// 死信队列(真正被消费的队列)
@Bean
public Queue deadLetterQueue() {
return new Queue("order.close.queue", true);
}
// 绑定死信队列到死信交换机
@Bean
public Binding deadLetterBinding(Queue deadLetterQueue, DirectExchange deadLetterExchange) {
return BindingBuilder.bind(deadLetterQueue)
.to(deadLetterExchange)
.with("order.close");
}
}
// 生产者发送消息
@Service
@RequiredArgsConstructor
public class DelayOrderProducer {
private final RabbitTemplate rabbitTemplate;
// 发送消息
public void sendDelayOrder(String orderId) {
Message message = MessageBuilder.withBody(orderId.getBytes(StandardCharsets.UTF_8))
.setContentType(MessageProperties.CONTENT_TYPE_TEXT_PLAIN)
.setMessageId(UUID.randomUUID().toString())
.build();
this.rabbitTemplate.send(RabbitMQConfig.ORDER_DELAY_QUEUE, message);
}
// 发送消息(支持自定义延时时间)
// MessagePostProcessor 为消息后置处理器 会单独设置消息 TTL(优先级高于队列 TTL)
public void sendDelayOrder(String orderId, long delayTime) {
MessagePostProcessor processor = message -> {
message.getMessageProperties()
.setExpiration(String.valueOf(delayTime));
return message;
};
this.rabbitTemplate.convertAndSend(RabbitMQConfig.ORDER_DELAY_QUEUE, ((Object) orderId), processor);
}
}
// 消费者消费消息
@Component
public class DelayOrderListener {
// 监听死信队列 消费超时订单消息
@RabbitListener(queues = "order.close.queue", ackMode = "MANUAL", concurrency = "3-5")
public void handleDelayedOrder(String orderId, Message message, Channel channel) {
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
// todo 关闭订单 恢复库存等
channel.basicAck(deliveryTag, false); // 手动确认
} catch (Exception e) {
// 消费失败
// todo 消费失败后可尝试重新入队(入私信队列) 若不能重试或超过指定次数后需要保存失败消息并告警
}
}
}
6 RocketMQ 延时消息
6.1 RocketMQ 延时消息 实现原理
RocketMQ 支持定时消息和延时消息,通过特殊的延时级别实现。其预设 18 个延时级别:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h ,用编号 1 ~ 18 表示这些级别,如 16 表示 30m。其延时消息工作流程为:
- 1、生产者发送消息时指定延时级别
delayLevel,如 16(30m); - 2、Broker 将消息暂存到特殊主题
SCHEDULE_TOPIC_XXXX; - 3、定时任务扫描该主题,将到期消息投递到真实主题(即实际被消费者消费的主题);
- 4、消费者从真实主题消费消息;
实际上特殊主题 SCHEDULE_TOPIC_XXXX 对应多个队列,即每个延时级别对应其中一个队列,定时任务扫描这些队列,然后将到期消息转发到目标主题。
⚠️ 注意
在 RocketMQ 4.9.3+ 版本中,支持自定义任意延时时间。
6.2 RocketMQ 延时消息 优缺点
- 优点:
- 使用门槛低,和普通消息一样;
- 缺点:
- 需依赖 MQ 中间件;
- 消息延迟,消息转发依赖于定时任务,同一时刻大量消息会导致消息延迟,丢失精准度;
6.3 RocketMQ 延时消息 示例代码
// 生产者发送消息
@Service
@RequiredArgsConstructor
public class DelayOrderProducer {
private final RocketMQTemplate rocketMQTemplate;
// 发送消息
public void sendDelayOrder(String orderId) {
rocketMQTemplate.sendDelayMessage("delay_order_topic", orderId, 30 * 60 * 1000);
}
}
// 消费者消费消息
@Component
public class DelayOrderListener {
@RocketMQMessageListener(topic = "delay_order_topic", consumerGroup = "delay_order_group")
public class OrderTimeoutListener implements RocketMQListener<String> {
@Override
public void onMessage(String message) {
// todo 关闭订单 恢复库存等
}
}
}
7 时间轮算法
7.1 时间轮算法 实现原理
时间轮是一种高效的定时器算法,netty 与 kafka 等中间件中使用该算法实现高性能定时器,适用于海量定时任务场景。
时间轮的结构类似于时钟的环形结构,环上的每个时刻代表一个槽位,每个槽位挂着一个链表,链表中存储在该时刻要执行的任务。其工作原理如下:
- 1、轮盘分为 N 个槽位
ticksPerWheel,如 60; - 2、每个槽位代表一个时间刻度
tickDuration, 如 1秒; - 3、指针每隔一个时间刻度
tickDuration移动到下一个槽位(若分为 60 个槽位,时间刻度为 1秒,则轮盘转一圈需要 60秒); - 4、每个槽位上挂着一个链表,存储该时刻要执行的任务;
- 5、超过一圈的任务,通过轮数
rounds记录;
7.2 时间轮算法 优缺点
- 优点:
- 性能极高,插入和删除时间复杂度为
O(1); - 内存占用小;
- 适用于超高并发、海量订单场景;
- 性能极高,插入和删除时间复杂度为
- 缺点:
- 实现复杂度较高;
- 与 JDK 延时队列缺点类似,时间轮为纯内存实现,重启后数据会丢失,需要持久化;
- 若任务抛出异常,不会自动重试,需要包装异常处理和重试逻辑;
7.3 时间轮算法 示例代码
以 netty 的时间轮算法实现为例:
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.108.Final</version>
</dependency>
// 时间轮配置
@Configuration
public class TimeWheelConfig {
// 时间轮配置
@Bean
public HashedWheelTimer hashedWheelTimer() {
return new HashedWheelTimer(new ThreadFactoryBuilder()
.setNamePrefix("delay_order_timer_")
.setDaemon(true)
.build(), // 任务执行线程配置
1, // tickDuration 每格 1 秒
TimeUnit.SECONDS,
60, // tickPerWheel 60个槽位
true, // leakDetection 内存泄漏检测
512); // maxPendingTimeout 最大待处理任务数(0 表示无限制)
}
// 应用停止后停止时间轮
@PreDestroy
public void destroy() {
HashedWheelTimer wheelTimer = hashedWheelTimer();
if (wheelTimer != null) {
wheelTimer.stop();
}
}
}
// 时间轮管理器
@Component
@RequiredArgsConstructor
public class TimeWheelManager {
// 存储订单 ID 与 {@link Timeout} 映射关系 用于取消任务
private final Map<String, Timeout> orderTimeoutTasks = new ConcurrentHashMap<>();
private final HashedWheelTimer wheelTimer;
// 添加任务
public void addTimeoutTask(String orderId, long delayTime, TimeUnit timeUnit) {
try {
Timeout timeout = wheelTimer.newTimeout(t -> {
try {
// todo 关闭订单 恢复库存等
orderTimeoutTasks.remove(orderId); // 处理完后移除映射关系
} catch (Exception e) {
// 订单超时自动处理失败
}
}, delayTime, timeUnit);
orderTimeoutTasks.put(orderId, timeout);
} catch (Exception e) {
// 时间轮停止
}
}
}
// 使用
@Service
@RequiredArgsConstructor
public class OrderService {
private final TimeWheelManager manager;
// 创建订单
// 注:订单完成后(已支付、已发货、已确认收货等)需要将其从 orderTimeoutTasks 中移除
@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderDTO dto) {
// 添加超时任务 30分钟后订单取消
manager.addTimeoutTask(dto.getId(), 30, TimeUnit.MINUTES);
}
}
8 惰性处理 与 Redisson 延时队列
8.1 惰性处理
惰性处理是指懒加载的思想,即只有在用户查看订单时才检查是否关闭,适用场景较少,如可用于 买家超时未确认收货 场景。
8.2 Redisson 延时队列
Redisson 也具有延时队列实现,底层基于 redis 的 Sorted Set + Pub/Sub 机制。但 Redisson 官网有大量用户反馈(2025 年 12 月 25 日左右),该延时队列存在巨大缺陷,且 Redisson 官方回应该缺陷由于设计问题基本无法修复,但,官方又说在 Redisson 的 pro 版本(即商业版本,要花大钱的那种)中,没有问题。那么它到底有没有问题呢,请用过商业化版本的同学在评论区分享。
总结:
条条大路通罗马,没有最好的,只有最合适的,跟自己业务场景匹配的才是最优解。当然,你也可以参考以下建议:
- 1、小型项目(订单量 < 1万/天),推荐使用 定时扫描 或 Redis Zset,实现简单、运维成本低、性能满足。
- 2、中型项目(订单量 1万 ~ 100万/天),推荐使用 Redis Zset + 定时扫描,部署简单性能好、可靠性高。
- 3、大型项目(订单量 > 100 万/天),推荐使用 RabbitMQ 或 RocketMQ,高并发、高可用、分布式、有完善的监控和运维工具。
- 4、超大型项目(订单量 > 1000 万/天),推荐使用 RocketMQ + 时间轮 + 数据库兜底,多层保障、极致性能、可靠性高。
- 5、对于可靠性要求极高的金融场景,还可以使用定时对账机制。
- 6、如果你不知如何抉择,或者不确定未来的业务增量,那可以使用 Redis Zset + RocketMQ + 定时扫描 组合技, 主流程上使用高性能的 Redis Zset,然后依赖 RocketMQ 的高可靠性进行备份,最后使用定时任务扫描数据库兜底,保证 100% 不遗漏。

1073

被折叠的 条评论
为什么被折叠?



