在趣玩搭社交平台中,用户抢到活动名额后需要在 15 分钟内完成支付,否则订单自动取消、名额释放。本文记录延时订单关闭方案的选型过程、RocketMQ 延时等级的局限性,以及如何实现精确到秒级的超时控制。
一、业务场景与挑战
1.1 业务流程
趣玩搭的活动票务流程如下:
用户抢票成功 → 创建待支付订单(锁定名额)→ 用户支付 → 订单完成
│
│ 15分钟未支付
▼
自动关闭订单 → 释放名额(Redis库存回补 + MySQL库存回补)关键约束是:名额资源是有限的,如果用户抢到后不支付,这个名额就被无效占用,其他想参加活动的用户无法抢到。因此超时未支付的订单必须被可靠地自动关闭。
1.2 方案需满足的要求
可靠性: 绝对不能漏关。一个订单没有被关闭,就意味着一个名额永久丢失。
时效性: 超时后应尽快关闭订单释放名额,延迟越大对其他用户越不公平。
性能: 平台日均产生数万笔订单,方案需要能够高效处理。
低维护成本: 团队规模不大,方案不宜过于复杂。
二、方案选型
在动手之前,我对比了四种常见的延时任务方案。
2.1 方案对比
方案一:数据库轮询。 定时任务(如每分钟一次)扫描订单表,查找 status = PENDING AND create_time < NOW() - 15min 的订单并关闭。
优点是实现最简单。缺点是延迟大(最差情况下扫描间隔 + 查询耗时),订单量大时全表扫描或索引扫描对数据库压力不小,而且扫描频率和延迟是矛盾的——扫得越频繁延迟越低,但数据库压力越大。
方案二:JDK DelayQueue。 在内存中维护一个延时队列,订单创建时放入队列,到期后自动弹出处理。
优点是精确到毫秒级。致命缺点是纯内存方案,服务重启后队列数据丢失,订单永远不会被关闭。多实例部署时还需要解决分布式一致性问题。
方案三:Redis 过期事件(keyspace notification)。 给每个订单设置一个 15 分钟过期的 Key,通过 Redis 的 key 过期事件通知触发关闭逻辑。
看似优雅,但有一个很多人不知道的坑:Redis 的过期事件是不可靠的。Redis 的惰性删除 + 定期删除策略意味着 Key 过期的实际触发时间不精确,且在 Redis 负载高时可能显著延迟。更重要的是,如果事件发送时客户端断线,该事件会永久丢失——Redis 的 Pub/Sub 不保证消息送达(fire and forget)。
方案四:RocketMQ 延时消息。 订单创建时发送一条延时 15 分钟的 MQ 消息,消息到期后被消费者消费,执行订单关闭逻辑。
RocketMQ 的消息有持久化保障(写入 CommitLog),不怕服务重启;支持集群消费,天然适配多实例部署;消费失败有重试机制,不会漏处理。
2.2 最终选择
综合考量选择了 RocketMQ 延时消息作为主方案,数据库轮询作为兜底方案。
三、RocketMQ 延时消息实现
3.1 延时等级的限制
RocketMQ 开源版本(4.x)的延时消息不支持任意延时时长,而是提供了 18 个预设的延时等级:
1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h对应等级 1-18。15 分钟不在预设等级中,最接近的是等级 14(10 分钟)和等级 15(20 分钟)。
3.2 基础方案:使用等级 15(20 分钟延时)
最简单的做法是使用 20 分钟延时,消费者收到消息后判断订单是否已超过 15 分钟:
// 生产者:订单创建后发送延时消息
@Service
public class OrderTimeoutProducer {
@Autowired
private RocketMQTemplate rocketMQTemplate;
public void sendTimeoutMessage(Long orderId, Long activityId, Long userId) {
OrderTimeoutMessage msg = new OrderTimeoutMessage();
msg.setOrderId(orderId);
msg.setActivityId(activityId);
msg.setUserId(userId);
msg.setCreateTime(System.currentTimeMillis());
// 延时等级15 = 20分钟
rocketMQTemplate.syncSend("order-timeout-topic",
MessageBuilder.withPayload(msg).build(),
3000, // 发送超时
15 // 延时等级
);
}
}// 消费者:延时消息到达后处理
@RocketMQMessageListener(
topic = "order-timeout-topic",
consumerGroup = "order-timeout-consumer"
)
@Service
public class OrderTimeoutConsumer implements RocketMQListener<OrderTimeoutMessage> {
@Autowired
private OrderService orderService;
@Override
public void onMessage(OrderTimeoutMessage msg) {
// 查询订单当前状态
Order order = orderService.getById(msg.getOrderId());
if (order == null) {
log.warn("订单 {} 不存在,忽略超时消息", msg.getOrderId());
return;
}
// 如果已支付或已取消,无需处理
if (order.getStatus() != OrderStatus.PENDING) {
log.info("订单 {} 状态为 {},无需关闭", msg.getOrderId(), order.getStatus());
return;
}
// 二次确认:检查是否真的超过15分钟
long elapsed = System.currentTimeMillis() - order.getCreateTime().getTime();
if (elapsed < 15 * 60 * 1000) {
// 还没到15分钟(理论上不会走到这里,但做防御性判断)
log.info("订单 {} 未超时 ({}ms),跳过", msg.getOrderId(), elapsed);
return;
}
// 执行订单关闭
orderService.closeTimeoutOrder(msg.getOrderId(), msg.getActivityId(), msg.getUserId());
}
}3.3 订单关闭的完整逻辑
@Service
public class OrderService {
@Transactional
public void closeTimeoutOrder(Long orderId, Long activityId, Long userId) {
// 1. 更新订单状态(乐观锁:只有PENDING状态才能关闭)
int affected = orderMapper.closeOrder(orderId, OrderStatus.PENDING, OrderStatus.TIMEOUT_CLOSED);
if (affected == 0) {
// 状态已变更(可能刚刚支付成功),不处理
log.info("订单 {} 关闭失败(状态已变更),跳过", orderId);
return;
}
// 2. MySQL 库存回补
activityMapper.incrementStock(activityId);
// 3. Redis 库存回补
String stockKey = "activity:{" + activityId + "}:stock";
redisTemplate.opsForValue().increment(stockKey);
// 4. Redis 已抢用户集合移除
String usersKey = "activity:{" + activityId + "}:users";
redisTemplate.opsForSet().remove(usersKey, userId.toString());
// 5. 通知用户
notifyService.sendOrderClosed(userId, orderId, "订单已超时关闭,名额已释放");
log.info("订单 {} 超时关闭成功,活动 {} 库存已回补", orderId, activityId);
}
}关闭逻辑中的几个关键设计:
乐观锁防并发。 closeOrder 的 SQL 带有 WHERE status = 'PENDING' 条件,如果订单已被支付(状态变为 PAID),更新影响行数为 0,直接跳过。避免了"支付回调和超时关闭同时到达"的竞态条件。
先改状态再回补库存。 顺序不能反——如果先回补库存再改状态,在这两步之间如果用户支付成功,就会出现"库存多回补了一个"的问题。
幂等性保障。 即使延时消息被重复投递(MQ 的 at-least-once 语义),乐观锁保证了只有第一次关闭会成功,后续重复消费不会有副作用。
四、进阶:精确到秒级的超时控制
4.1 问题:5 分钟的误差
使用 20 分钟延时等级意味着订单实际关闭时间是创建后 20 分钟,而不是精确的 15 分钟。多出的 5 分钟里,名额被白白占用。在热门活动中,5 分钟的名额占用可能导致大量用户等待。
4.2 方案:二级延时消息
思路是将 15 分钟拆分为多个延时等级的组合:
第一条消息:延时10分钟(等级14)
→ 消费后检查:如果订单已支付则结束;如果未支付,发送第二条消息
第二条消息:延时5分钟(等级13)
→ 消费后检查并关闭订单这样总延时精确为 15 分钟(10 + 5),误差为零。
@RocketMQMessageListener(
topic = "order-timeout-topic",
consumerGroup = "order-timeout-consumer"
)
@Service
public class OrderTimeoutConsumer implements RocketMQListener<OrderTimeoutMessage> {
@Override
public void onMessage(OrderTimeoutMessage msg) {
Order order = orderService.getById(msg.getOrderId());
if (order == null || order.getStatus() != OrderStatus.PENDING) {
return; // 已支付或已取消,无需处理
}
if (msg.getPhase() == 1) {
// 第一阶段消息到达(10分钟后),订单仍未支付
// 发送第二阶段延时消息(再等5分钟)
msg.setPhase(2);
rocketMQTemplate.syncSend("order-timeout-topic",
MessageBuilder.withPayload(msg).build(),
3000, 13); // 等级13 = 5分钟
// 可选:给用户发送"即将关闭"的提醒
notifyService.sendPaymentReminder(order.getUserId(), order.getOrderId(),
"您的订单将在5分钟后关闭,请尽快完成支付");
} else if (msg.getPhase() == 2) {
// 第二阶段消息到达(总计15分钟后),执行关闭
orderService.closeTimeoutOrder(msg.getOrderId(), msg.getActivityId(), msg.getUserId());
}
}
}额外的好处是:在第一阶段(10 分钟)到达时可以给用户发一条"即将关闭"的提醒通知,提升支付转化率。实际上线后,这条提醒将支付完成率提升了约 12%。
4.3 如果需要任意精度怎么办?
RocketMQ 5.x 版本已经支持任意延时时长(基于 Timer 机制),如果升级到 5.x 可以直接使用。在 4.x 版本下,如果需要精确到秒级的任意延时(比如 13 分 47 秒),可以考虑以下方案:
方案一:时间轮(Timing Wheel)。 在应用层实现一个时间轮,精确控制延时。但和 JDK DelayQueue 一样存在内存丢失问题,需要配合持久化。
方案二:Redis + Sorted Set。 将订单超时时间作为 score 存入 Sorted Set,定时任务每秒用 ZRANGEBYSCORE 查询已到期的订单。精确到秒级,且 Redis 有持久化保障。
在我们的场景中,二级延时消息(10 + 5 分钟)已经完全满足需求,没有引入额外的复杂方案。
五、兜底方案:数据库扫描
无论主方案多可靠,都需要一个兜底。我设置了一个低频的数据库扫描任务,每 5 分钟执行一次,查找所有超时但状态仍为 PENDING 的"漏网之鱼":
@Scheduled(fixedRate = 300000) // 每5分钟
public void scanTimeoutOrders() {
// 查找超过20分钟仍未支付的订单(比正常超时多5分钟的安全余量)
LocalDateTime deadline = LocalDateTime.now().minusMinutes(20);
List<Order> timeoutOrders = orderMapper.selectTimeoutOrders(
OrderStatus.PENDING, deadline, 100); // 每次最多处理100条
if (!timeoutOrders.isEmpty()) {
log.warn("兜底扫描发现 {} 条超时未关闭订单", timeoutOrders.size());
for (Order order : timeoutOrders) {
try {
orderService.closeTimeoutOrder(
order.getId(), order.getActivityId(), order.getUserId());
} catch (Exception e) {
log.error("兜底关闭订单 {} 失败", order.getId(), e);
}
}
}
}查询使用 20 分钟而不是 15 分钟作为截止时间,是为了给延时消息留出处理的时间窗口,避免和 MQ 消费者争抢处理同一个订单。
实际运行中,兜底任务发现的订单数量为零(说明 MQ 方案完全可靠),但这个兜底机制的存在让人心里踏实。
六、RocketMQ 在整个项目中的角色全景
延时订单关闭只是 RocketMQ 在趣玩搭中承担的职责之一。梳理一下 RocketMQ 在整个项目中的四大角色:
MQ 在微服务架构中就像"神经系统"——服务之间不直接调用,而是通过消息进行松耦合通信。一个服务挂了,消息不会丢失,等恢复后继续消费。
七、经验总结
延时任务的可靠性要求决定了方案选型。 如果漏处理的后果可以接受(如发送营销通知),用 Redis 过期事件就够了。如果漏处理的后果严重(如订单关闭、库存回补),必须用有持久化保障的方案(MQ、数据库)。
RocketMQ 4.x 的延时等级虽然有限,但通过组合可以满足绝大多数场景。 15 分钟 = 10 分钟 + 5 分钟,30 分钟 = 20 分钟 + 10 分钟。拆分后还可以在中间阶段做二次确认或发送提醒。
幂等性是分布式系统的基本功。 MQ 消息可能重复投递,定时任务可能和 MQ 消费者同时处理同一订单。所有状态变更操作都必须是幂等的——用乐观锁(WHERE status = 'PENDING')是最简单有效的手段。
主方案 + 兜底方案是工程上的最佳实践。 主方案追求效率和实时性,兜底方案追求覆盖率。两者不是互斥的,而是互补的。
如果这篇文章对你有帮助,欢迎访问我的博客 robinzhu.top 获取更多实战分享。