在趣玩搭社交平台中,用户抢到活动名额后需要在 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 最终选择

维度

数据库轮询

DelayQueue

Redis过期

RocketMQ延时

可靠性

低(内存丢失)

低(事件丢失)

高(持久化)

时效性

差(分钟级)

优(毫秒级)

不稳定

良好(秒级)

性能

一般(DB压力)

分布式支持

需分布式锁

需额外方案

需额外方案

天然支持

维护成本

综合考量选择了 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 在整个项目中的四大角色:

角色

Topic

说明

异步解耦

order-create-topic

抢票成功后异步创建订单

延时任务

order-timeout-topic

超时未支付订单自动关闭

流量削峰

device-data-topic(数字工厂)

设备数据上报削峰

事件广播

tenant-config-change

租户配置变更通知所有实例

MQ 在微服务架构中就像"神经系统"——服务之间不直接调用,而是通过消息进行松耦合通信。一个服务挂了,消息不会丢失,等恢复后继续消费。

七、经验总结

延时任务的可靠性要求决定了方案选型。 如果漏处理的后果可以接受(如发送营销通知),用 Redis 过期事件就够了。如果漏处理的后果严重(如订单关闭、库存回补),必须用有持久化保障的方案(MQ、数据库)。

RocketMQ 4.x 的延时等级虽然有限,但通过组合可以满足绝大多数场景。 15 分钟 = 10 分钟 + 5 分钟,30 分钟 = 20 分钟 + 10 分钟。拆分后还可以在中间阶段做二次确认或发送提醒。

幂等性是分布式系统的基本功。 MQ 消息可能重复投递,定时任务可能和 MQ 消费者同时处理同一订单。所有状态变更操作都必须是幂等的——用乐观锁(WHERE status = 'PENDING')是最简单有效的手段。

主方案 + 兜底方案是工程上的最佳实践。 主方案追求效率和实时性,兜底方案追求覆盖率。两者不是互斥的,而是互补的。


如果这篇文章对你有帮助,欢迎访问我的博客 robinzhu.top 获取更多实战分享。


本站由 困困鱼 使用 Stellar 创建。