基于本文回答

播面 播面

刷题像听歌,多听自然懂
0
评论

如何使用消息队列实现一个订单 30 分钟未支付自动关闭的功能?

知识点图片

使用消息队列(MQ)实现“订单 30 分钟未支付自动关闭”的核心技术是延迟消息(Delayed Message)

下面我将从整体业务流程不同 MQ 的具体实现方案以及生产环境的核心注意事项三个方面为你详细解答。


一、 整体业务流程

无论使用哪种 MQ,整体的业务架构流转都是相似的:

  1. 创建订单:用户下单,数据库生成订单记录,状态为“待支付”。
  2. 发送延迟消息:订单系统向 MQ 发送一条包含 order_id 的延迟消息,设置延迟时间为 30 分钟。
  3. 消息滞留:消息在 MQ 中“等待” 30 分钟。
  4. 消费消息:30 分钟后,MQ 将该消息投递给消费者(订单超时处理服务)。
  5. 校验状态:消费者拿到 order_id 后,查询数据库或支付网关,检查该订单的最新状态。
  6. 执行动作
    • 如果订单状态仍为“待支付”:将订单状态更新为“已关闭”,并执行库存回滚、优惠券退回等操作。
    • 如果订单状态已变为“已支付”:说明用户已付款,直接丢弃该消息(不做任何处理)。

二、 主流 MQ 的具体实现方案

不同的消息队列对延迟消息的实现方式不同,以下是常用的三种方案:

1. RabbitMQ 方案

RabbitMQ 有两种实现延迟消息的方式:

  • 方式一:死信队列(DLX)+ TTL
    • 原理:利用消息的存活时间(TTL)和死信交换机(Dead Letter Exchange)。
    • 流程
      1. 创建一个没有消费者的“缓冲队列”,并设置其消息的 TTL 为 30 分钟。
      2. 为该队列绑定一个死信交换机。
      3. 订单系统把消息发到这个缓冲队列。由于没有消费者,30 分钟后消息过期,变成“死信”。
      4. RabbitMQ 自动将“死信”转发到绑定的死信交换机,再路由到“死信队列”。
      5. 你的消费者监听这个“死信队列”,即可处理刚好延迟 30 分钟的订单。
  • 方式二:延迟消息插件(rabbitmq-delayed-message-exchange)
    • 原理:安装官方插件后,会新增一种 Exchange 类型。消息发到 Exchange 后不会立即路由到队列,而是保存在 Mnesia 数据库中,等时间到了再路由。
    • 优点:配置更简单,不需要创建死信队列。

2. RocketMQ 方案

RocketMQ 原生支持延迟消息,使用起来最简单。

  • RocketMQ 4.x 版本:支持固定级别的延迟。
    • 默认支持 18 个级别:1s 5s 10s 30s 1m 2m ... 30m 1h 2h
    • 30 分钟刚好是 Level 16
    • 代码示例message.setDelayTimeLevel(16); 发送即可。
  • RocketMQ 5.x 版本:支持基于时间戳的任意精度延迟
    • 代码示例message.setDeliveryTimestamp(System.currentTimeMillis() + 30 * 60 * 1000);

3. Redis 方案(非 MQ,但常用于轻量级场景)

如果系统中没有引入笨重的 MQ,Redis 也是极佳的选择:

  • 使用 ZSet(有序集合)
    • 发送:下单时,执行 ZADD order:delay_queue <当前时间戳 + 30分钟的时间戳> <order_id>
    • 消费:后台启动一个定时任务(每秒轮询),执行 ZRANGEBYSCORE order:delay_queue 0 <当前时间戳>,查出已到期的 order_id,处理后用 ZREM 将其删除。
  • 注意:不推荐使用 Redis 的键空间通知(Key Expiration Notification),因为 Redis 的过期通知不是实时的,且没有持久化保证,宕机会丢消息。

三、 生产环境的核心注意事项(避坑指南)

在真正的生产环境中,不能仅仅把状态改成“已关闭”就完事了,必须考虑以下问题:

1. 状态二次确认(防并发冲突)

在 29 分 59 秒时,用户可能恰好完成了支付,而 30 分 00 秒时延迟消息刚好触发。
解决方案:消费者在关闭订单前,不仅要查本地数据库,最好还要去调用微信/支付宝的查询接口,确认真的没有支付成功,才能执行关闭逻辑。并且更新数据库状态时,要带上乐观锁,例如:
UPDATE orders SET status = 'CLOSED' WHERE id = ? AND status = 'PENDING';

2. 消费的幂等性

MQ 都有可能发生消息重复投递的情况(比如网络抖动导致消费者没有成功 ACK)。
解决方案:消费者逻辑必须是幂等的。如果订单已经是“已关闭”或“已支付”状态,再次收到该订单的延迟消息时,应该直接返回成功(ACK),不作任何处理。

3. 消息丢失与可靠性保证

如果 MQ 宕机导致延迟消息丢失,订单就会永远卡在“待支付”状态。
兜底方案:除了使用 MQ 的延迟消息,还要在后台跑一个定时任务(如 XXL-JOB/Quartz),每隔半小时扫描一次数据库中 创建时间 < 30分钟前 且 状态 = '待支付' 的订单记录,作为兜底补偿机制。

4. 业务完整性(库存与优惠券)

关闭订单是一个事务操作,不仅仅是改状态,还必须释放锁定的资源:

  • 恢复商品库存(如果是下单减库存的话)。
  • 退还用户使用的优惠券/积分

总结建议

  • 如果你的系统中已经有 RocketMQ,直接使用其原生的延迟消息,最省事。
  • 如果使用 RabbitMQ,推荐使用 DLX+TTL 的死信队列方案,最稳定可靠。
  • 如果是体量较小的单体或微服务项目,使用 Redis ZSet 配合定时轮询是最轻量级的做法。
  • 无论用哪种方案,都务必加上“数据库状态乐观锁校验”和“定时任务兜底”。
00:00
00:00