尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring Boot订单超时自动取消:RabbitMQ延迟队列与定时任务兜底实践

Spring Boot订单超时自动取消:RabbitMQ延迟队列与定时任务兜底实践 下单 15 分钟不支付就自动关单同时把库存释放掉、优惠券退回给用户。这个需求看着简单但真落到 Spring Boot 项目里做起来坑比想象中多。我这两年给好几个电商项目做过订单超时取消从最初Scheduled扫表到后来换成 RabbitMQ 延迟消息中间踩了不少坑。这篇文章我就用一套完整案例把订单超时自动取消从方案选型到生产落地讲清楚适合刚接触订单系统、想了解消费者如何兜底的工程师也适合准备在项目里做超时关单但还没想好方案的读者。先说结论没有哪种方案是银弹超时关单一定是主链路 兜底链路的组合。下面我把我的完整实现思路和踩坑记录都拆出来。1. 先搞清楚订单超时取消这个需求到底难在哪很多新手看到超时自动取消脑子里第一个反应就是写个定时器每分钟查一次订单表超时的就关掉。这思路没错但如果你只按这个想法去实现上线后大概率会被真实流量教育一番。1.1 业务规则往往不是一个定时器那么简单我们先看常见的完整业务规则用户下单后 15 分钟内未支付订单自动变为已取消同时要释放锁定的库存、退回使用的优惠券、如果订单关联着秒杀活动还要恢复活动库存甚至要给用户发一条订单已超时关闭的短信或站内通知。也就是说关单这个动作不是一个update就能完事的它背后牵扯库存服务、营销服务、消息通知服务。一旦关单失败库存就会一直被占用用户视角是我下了单怎么库存一直锁着运营视角是怎么超时单子没关掉。这个复杂度决定了我们不能随便写个定时器就完事。1.2 难点拆解时间、状态、资源三件事时间维度订单是持续不断产生的每个订单的超时时间点各不相同。如果有一个全局的过期时间集合那好办但现实是每笔订单从create_time开始算 15 分钟系统里同时存在几千个不同的到期时间点。扫描逻辑必须能精准命中该到期的订单而不是把所有待支付订单拉全表比对。状态维度关单前必须校验订单当前状态。如果用户刚好在这一秒支付成功、下一秒定时任务把它关掉那就是严重的线上事故。所以关单必须是带条件的状态转移不是无脑更新。资源维度当订单量大了以后高频全表扫描是不可接受的。我见过一个项目订单表 500 万行定时任务每 30 秒执行一次SELECT * FROM orders WHERE status待支付 AND create_time ?直接把数据库慢查询日志刷屏。所以在方案设计时一定要考虑扫描的范围和频率。把这三件事想明白我们再去看技术方案就会清楚很多方案的核心不是定时而是准点触发 安全状态转移 可控的资源消耗。2. 主流方案横评为什么我没上来就用最难的那套市面上的方案我大致分五类各有各的适用场景。我不爱一上来就推荐高可靠分布式的复杂方案因为很多项目单体部署、订单量也不大杀鸡用牛刀反而增加维护成本。关键是先看你的业务量级和技术栈。2.1 五种方案的真实优缺点方案实现难度延迟精度额外依赖分布式支持可靠性定时任务轮询数据库低低无需要分布式锁中JDK DelayQueue / 时间轮中中无不支持低Redis 过期监听低中Redis支持中RabbitMQ 延迟队列TTL死信中高RabbitMQ支持高RocketMQ 延迟消息中中RocketMQ支持高下面逐个说我的看法。定时任务轮询数据库适合项目初期、订单量小、对延迟不敏感的场景。Scheduled加一个扫描 SQL 就能跑起来但它有两个先天缺陷一是扫描周期决定的延迟误差比如每分钟扫一次最坏情况订单要多等将近一分钟二是随着订单表变大扫描成本和数据库压力直线上升。JDK DelayQueue / 时间轮本质是在 JVM 内存里维护一个到期队列。触达精准、实现也简单但最大的问题是进程一重启内存里的延迟任务全部丢失。更麻烦的是多实例部署时每个实例只知道自己内存里的订单订单 A 落在实例 1实例 2 完全不知道它无法做分布式协调。这方案我一般只用在单机、允许丢失重跑的场景。Redis 过期监听很多人被它吸引是因为实现看起来简单订单创建时往 Redis 塞一个 key过期时间设为 15 分钟开启 keyspace notifications然后订阅过期事件收到事件就关单。但这里有个致命点Redis 的过期事件是基于惰性删除 定期删除策略触发的不是严格到点触发而且 Pub/Sub 消息不持久化如果消费者刚好不在线事件直接丢失。实际生产里它只能作为一个辅助手段绝对不能当主链路。RabbitMQ 延迟队列我最终选定并稳定运行下来的方案。通过死信队列或者延迟消息插件实现消息可靠、触达时间误差小、天然支持多实例消费。代价是需要引入 RabbitMQ 依赖并且要做好消息确认和幂等。RocketMQ 延迟消息如果你团队已经上了 RocketMQ那直接用它的延迟消息很简单。它有个限制是只支持固定的几个延迟级别比如 1s、5s、10s、30s、1m 等如果你的超时时间刚好是 15 分钟用18级别就能对上但如果业务要 13 分钟这种自定义值就有点尴尬。2.2 一眼看懂选型逻辑我的选型逻辑很简单主链路用 RabbitMQ 延迟队列兜底扫描用定时任务。为什么不用 RocketMQ因为我当时所在的项目已经引入了 RabbitMQ没必要再维护一套消息中间件。为什么保留定时任务因为消息链路再可靠也可能出意外——消费者进程挂了、消息被误删、RabbitMQ 集群抖动所以我永远会在旁边加一个定时任务兜底扫描那些该关没关的订单。这个主链路 兜底的思路是我认为订单超时关单架构里最重要、也是很多教程不会告诉你的一点。3. 第一版实现Spring Boot 定时扫描方案代码与教训在正式上延迟队列之前我们先把定时扫描方案完整走一遍。这样做有两个原因一是它逻辑直观、容易理解适合作为团队的起步版本二是它作为兜底方案的核心早晚要用到提前写顺手。3.1 建表与状态机准备先看订单表的简化结构CREATE TABLE t_order ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, order_no VARCHAR(32) NOT NULL COMMENT 订单号, user_id BIGINT NOT NULL COMMENT 用户ID, status TINYINT NOT NULL COMMENT 状态: 0待支付, 1已支付, 2已取消, 3已关闭, amount DECIMAL(10,2) NOT NULL COMMENT 金额, create_time DATETIME NOT NULL COMMENT 下单时间, timeout_time DATETIME DEFAULT NULL COMMENT 超时关单时间点, pay_time DATETIME DEFAULT NULL COMMENT 支付时间, cancel_time DATETIME DEFAULT NULL COMMENT 取消时间, update_time DATETIME DEFAULT NULL COMMENT 更新时间, PRIMARY KEY (id), KEY idx_status_timeout (status, timeout_time) ) COMMENT 订单表;这里的timeout_time字段很多人会忽略但它对扫描效率至关重要。有了这个字段SQL 可以直接按status timeout_time走索引避免全表扫描。3.2 Scheduled 扫描代码用 Spring Boot 的Scheduled写一个最基础的扫描任务Component Slf4j public class CloseTimeoutOrderJob { Resource private OrderMapper orderMapper; Resource private OrderCloseService orderCloseService; /** * 每 30 秒执行一次每次最多处理 200 条 */ Scheduled(fixedDelay 30_000, initialDelay 10_000) public void scanTimeoutOrders() { ListOrder timeoutOrders orderMapper.selectTimeoutOrders( LocalDateTime.now().minusMinutes(15), 200); if (timeoutOrders.isEmpty()) { return; } for (Order order : timeoutOrders) { try { orderCloseService.closeOrder(order.getId()); } catch (Exception e) { log.error(超时关单失败, orderId{}, order.getId(), e); } } } }对应的 Mapper SQL 这样写select idselectTimeoutOrders resultTypeOrder SELECT id, order_no, status FROM t_order WHERE status 0 AND timeout_time lt; #{now} AND timeout_time gt; #{scanStart} ORDER BY timeout_time LIMIT #{limit} /select有几个细节我必须强调fixedDelay和fixedRate区别很大。fixedDelay是上次执行完再等 30 秒fixedRate是每 30 秒启动一次不管上次是否结束。扫描任务如果用fixedRate一旦某次执行超过 30 秒任务就会堆积重叠所以我会明确用fixedDelay。扫描条件要加上timeout_time 上次扫描开始时间之类的下界防止同一批订单在超时时间边界被反复捞出来重复处理。关单操作要独立 try-catch不能让某条失败阻塞整批。3.3 三个绕不开的副作用这个方案上线后我马上遇到了三个问题。问题一扫描延迟不可控。30 秒扫一次意味着最坏情况下一个订单到期后要等 30 秒才被关掉。业务如果对时间敏感比如活动库存只锁 15 分钟用户会明显感觉到我的库存怎么没被释放。缩短扫描周期能缓解但代价是数据库查询频率翻倍。问题二数据库压力。在订单量 10 万级别时上述 SQL 只要命中索引压力还能接受。但订单量到了百万级加上ORDER BY timeout_time的排序操作每次扫描都会造成一定的 IO 开销高峰期定时任务和业务 SQL 抢数据库资源。后来我改成每次只取LIMIT 200、并记录本次扫描位置才把压力降下来。问题三多实例部署直接炸。项目做集群部署后两台服务同时跑定时任务同一笔订单会被两个实例同时查出来、同时去关单。虽然关单是更新状态的操作并发执行会导致状态错乱。解决办法是引入分布式锁ShedLock 或 Redis 锁保证同一时刻只有一个实例执行扫描。ShedLock 配置很简单如果不想引入额外组件用 Redisson 的RLock也可以。定时扫描方案作为第一版是合格的但作为长期方案它解决不了延迟精度问题。所以我启动了第二步用 RabbitMQ 延迟队列做主链路。4. 升级到 RabbitMQ 延迟队列从原理到落地代码RabbitMQ 本身没有延迟消息这个概念但我们可以通过两个方式实现TTL 死信队列或官方延迟消息插件。我项目中用的是 TTL 死信队列因为插件需要额外安装部署环境不一定允许而 TTL 死信是 RabbitMQ 原生能力只要版本支持就稳。4.1 延迟消息的两个实现思路先理解一下 TTL 死信的套路它分三步消息发到等待队列也叫延迟队列不设消费者。队列声明时指定x-message-ttl为 15 分钟或者消息本身带过期时间。消息到期后RabbitMQ 会把它按死信规则转发到死信交换机再路由到真正的消费者队列。这里有一个非常关键、也是后来踩坑最深的细节RabbitMQ 对消息过期只检查队列头部的消息。如果队列头部的消息 15 分钟才过期后面排着一条 3 分钟过期的消息那后面这条也得等头部到期后才被检查。这就是队头阻塞。因为这个原因如果你有多种超时时间不要把所有不同 TTL 的消息塞进同一个队列要么用队列级统一 TTL要么每条消息单独一个队列要么换延迟插件。4.2 Spring Boot 侧完整配置先加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-amqp/artifactId /dependency配置文件spring: rabbitmq: host: 127.0.0.1 port: 5672 virtual-host: / username: guest password: guest publisher-confirm-type: correlated # 开启发送方确认 listener: simple: acknowledge-mode: manual # 手动 ack prefetch: 10队列和交换机声明Configuration Slf4j public class OrderDelayRabbitConfig { public static final String ORDER_DELAY_EXCHANGE exchange.order.delay; public static final String ORDER_DELAY_QUEUE queue.order.delay.wait; public static final String ORDER_DELAY_ROUTING_KEY routing.order.delay; public static final String ORDER_DEAD_EXCHANGE exchange.order.dead; public static final String ORDER_DEAD_QUEUE queue.order.dead; public static final String ORDER_DEAD_ROUTING_KEY routing.order.dead; // 等待队列消息到期后转发到死信交换机 Bean public Queue orderDelayWaitQueue() { return QueueBuilder.durable(ORDER_DELAY_QUEUE) .withArgument(x-dead-letter-exchange, ORDER_DEAD_EXCHANGE) .withArgument(x-dead-letter-routing-key, ORDER_DEAD_ROUTING_KEY) .withArgument(x-message-ttl, 15 * 60 * 1000) .build(); } // 死信队列真正的消费者监听这个队列 Bean public Queue orderDeadQueue() { return QueueBuilder.durable(ORDER_DEAD_QUEUE).build(); } Bean public DirectExchange orderDelayExchange() { return new DirectExchange(ORDER_DELAY_EXCHANGE, true, false); } Bean public DirectExchange orderDeadExchange() { return new DirectExchange(ORDER_DEAD_EXCHANGE, true, false); } Bean public Binding orderDelayBinding() { return BindingBuilder.bind(orderDelayWaitQueue()) .to(orderDelayExchange()) .with(ORDER_DELAY_ROUTING_KEY); } Bean public Binding orderDeadBinding() { return BindingBuilder.bind(orderDeadQueue()) .to(orderDeadExchange()) .with(ORDER_DEAD_ROUTING_KEY); } }我用的是队列级 TTL也就是x-message-ttl写在队列上。这样所有进入该队列的消息统一 15 分钟后过期避免了队头阻塞问题。4.3 下单发送延迟消息与消费关单下单成功后把订单号发到等待队列Service Slf4j public class OrderService { Resource private RabbitTemplate rabbitTemplate; public void createOrder(OrderCreateDTO dto) { // 1. 保存订单状态待支付 // 2. 锁定库存等 // 3. 发送延迟关单消息 OrderDelayMessage msg new OrderDelayMessage(order.getId(), TIMEOUT_CLOSE); CorrelationData correlationData new CorrelationData(UUID.randomUUID().toString()); // 队列级TTL下不需要设置 expiration直接发即可 rabbitTemplate.convertAndSend( OrderDelayRabbitConfig.ORDER_DELAY_EXCHANGE, OrderDelayRabbitConfig.ORDER_DELAY_ROUTING_KEY, msg, correlationData); } }生产者消息确认的回调Configuration public class RabbitConfirmConfig { Bean public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, RabbitTemplate.ConfirmCallback confirmCallback) { RabbitTemplate rabbitTemplate new RabbitTemplate(connectionFactory); rabbitTemplate.setConfirmCallback(confirmCallback); return rabbitTemplate; } }我在实际项目里会把confirmCallback单独写成一个日志记录组件确认成功就标记消息已发送确认失败或超时就记录orderId并触发补偿。这里不要以为开了 confirm 就万事大吉发送成功只能表示消息到了交换机如果路由不到队列还是会丢失。我同时配置了mandatory和ReturnsCallback来处理路由失败的消息。消费端监听死信队列Component Slf4j public class OrderTimeoutConsumer { Resource private OrderCloseService orderCloseService; RabbitListener(queues OrderDelayRabbitConfig.ORDER_DEAD_QUEUE) public void onTimeoutMessage(OrderDelayMessage msg, Message message, Channel channel) throws IOException { long deliveryTag message.getMessageProperties().getDeliveryTag(); try { orderCloseService.closeTimeoutOrder(msg.getOrderId()); channel.basicAck(deliveryTag, false); } catch (Exception e) { log.error(消费关闭订单消息失败, orderId{}, msg.getOrderId(), e); // 这里不直接 nack 重回队列而是记录后人工处理或进补偿 channel.basicNack(deliveryTag, false, false); } } }关单核心方法Service Slf4j public class OrderCloseService { Resource private OrderMapper orderMapper; Resource private InventoryService inventoryService; Resource private CouponService couponService; Transactional(rollbackFor Exception.class) public boolean closeTimeoutOrder(Long orderId) { // 1. CAS式更新只有待支付状态才能被关单防止并发把已支付订单关了 int rows orderMapper.casCloseOrder(orderId, LocalDateTime.now()); if (rows ! 1) { // 说明订单已经不是待支付状态直接返回 log.info(订单无需关闭, orderId{}, orderId); return false; } // 2. 释放库存 inventoryService.releaseStock(orderId); // 3. 退回优惠券 couponService.releaseCoupon(orderId); // 4. 发送通知短信/站内信需要记住消息失败不能影响主流程 return true; } }对应 SQLupdate idcasCloseOrder UPDATE t_order SET status 2, cancel_time #{now}, update_time #{now} WHERE id #{orderId} AND status 0 /update看到没有这个UPDATE ... WHERE status 0就是状态机安全的核心。哪怕消费者收到重复消息、哪怕支付和关单并发只要这个 SQL 更新行数为 0就说明状态已经不是待支付了直接放弃。5. 生产环境踩坑实录消息延迟、重复消费、状态回跳方案落地不等于结束真正的考验在上线后的数据反馈里。我把这段时间遇到的三个比较典型的坑记录在这里每一条都对应一个具体的排查链路。5.1 队头阻塞延迟队列的意外提前过期当时订单要支持两种超时时间普通订单 15 分钟、秒杀订单 5 分钟。我在设计消息时图省事统一发到同一个等待队列靠单条消息的expiration属性设置不同过期时间。上线后观察发现秒杀订单的关单时间经常变成 15 分钟甚至 20 分钟。排查链路先看消费者日志发现消息确实是在到期后才进入死信队列但时间点普遍晚于预期。再查 RabbitMQ 管理界面等待队列中排队的消息里头部是一条 15 分钟 TTL 的普通订单后面压着若干 5 分钟 TTL 的秒杀消息。最后看 RabbitMQ 官方对 TTL 的实现说明确认它到期检查只针对队头消息。问题根因清楚了。修复方案我把两个超时场景拆成了两个独立的等待队列各设各的x-message-ttl秒杀的队列 TTL 设为 5 分钟普通订单 15 分钟互不干扰。如果你的订单超时时间本身就是统一固定值那一个队列就够了。结论是单条消息 TTL 在队列场景下不可靠尽量用队列级 TTL 区分不同过期时间。5.2 延迟消息丢了从静默丢失到定位某天运营反馈有订单超时了没自动关闭。我上 RabbitMQ 管理界面发现等待队列里没有积压死信队列里也没有消息正常情况应该每笔订单到点就出现在死信队列。这说明消息根本没过来。排查链路第一步看生产者 confirm 日志。我有记录发送确认的回调日志发现这批订单的confirmCallback全部是成功返回。说明消息确实到达了交换机。第二步看交换机到队列的绑定。如果消息到达交换机但路由不到队列ReturnsCallback会收到通知。查日志发现没有未路由记录。消息应该进入了等待队列。第三步看队列消息持久化情况。RabbitMQ 重启过运维做过版本升级而等待队列声明了durable但当时发送消息时没有设置MessageDeliveryMode.PERSISTENT。队列持久化不等于消息持久化消息默认是临时性的broker 重启就没了。修复方案发送消息时显式设置持久化模式rabbitTemplate.convertAndSend( exchange, routingKey, msg, message - { message.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT); return message; }, correlationData);另外为了最大化可靠性我给等待队列的死信规则又加了一层兜底如果消费者在收到消息后执行业务失败basicNack不重回队列而是记录到一张t_order_timeout_message_log表由定时任务扫描这张表里未成功处理的消息重新触发关单。毕竟关单操作本身是从订单表读取最新状态来执行的重复触发是安全的。5.3 支付与关单并发状态回跳的根源这是我在做压力测试时真实遇到的同一笔订单支付回调线程和超时消费线程同时执行。假设当前订单状态是待支付。超时线程先执行UPDATE SET status2 WHERE id? AND status0成功状态变成已取消。紧接着支付回调线程执行UPDATE SET status1 WHERE id? AND status0此时status已经不是 0更新行数为 0所以支付线程应该失败。这个链路本身是安全的。但问题出在先释放库存、后更新状态的反向顺序。早期我为了减少数据库行锁持有时间把关单操作设计成先发消息释放库存再改订单状态然后支付回调那边先改订单状态再释放库存。结果两个链路交错出现释放库存后订单状态还没变支付线程又把订单改成了已支付库存却被释放了用户看到已支付但库存没锁定。最终我把状态更新和资源释放收敛到同一个本地事务里并且统一采用先更新订单状态再释放资源的顺序。因为状态更新是一把全局锁先拿到状态的控制权后面的资源释放无论成功失败订单状态都不会再往回跳。资源释放失败就回滚整个事务让消息消费失败走到补偿链路由补偿任务重新关单。这样从机制上消除了状态回跳。6. 最终方案延迟队列为主 定时任务兜底经过上面的折腾最终稳定运行的架构是这样的主链路订单创建时发送延迟消息到 RabbitMQ 等待队列15 分钟后进入死信队列消费者触发关单。兜底链路定时任务每 3 分钟扫一次订单表把timeout_time now且status0的订单批量关掉每次LIMIT 200配合 ShedLock 保证单实例执行。消息日志表t_order_timeout_message_log记录每笔订单延迟消息的发送、消费、关单结果出现异常时可以手动补偿或有定时任务自动重试。6.1 兜底扫描怎么做才不重复兜底扫描和主链路的消费者都在执行关单两者如何保证互不冲突核心还是那句UPDATE ... WHERE status0。无论哪条链路先执行谁先把状态从待支付改成已取消另一个就会因为更新行数为 0 直接放弃。这就是幂等设计不需要额外引入分布式锁来协调主链路和兜底链路。兜底扫描 SQL 我优化成了分批拉取 状态标记的方式避免一次拉太多。大致逻辑Component Slf4j public class OrderTimeoutCompensateJob { Scheduled(fixedDelay 3 * 60 * 1000, initialDelay 60 * 1000) public void compensate() { ListOrder orders orderMapper.selectTimeoutOrders(LocalDateTime.now(), 500); for (Order order : orders) { try { orderCloseService.closeTimeoutOrder(order.getId()); } catch (Exception e) { log.error(兜底关单失败, orderId{}, order.getId(), e); } } } }注意这里有一个隐藏的坑如果兜底任务每 3 分钟跑一次而某订单的timeout_time已经过去好几小时了之前主链路消息一直没到兜底任务就会反复扫描到它。第一次关单成功后状态变成已取消后面再扫到也不会命中status0所以不会重复但扫描本身会浪费。优化方式是用timeout_time now - 30 分钟作为下界只补偿最近半个小时内到期的订单更早的交给人工运维或专门的对账任务。6.2 我给新项目的落地清单如果你现在要在一个新的 Spring Boot 项目里做订单超时自动取消我建议按下面这张清单走阶段要做的事优先级表设计订单表增加timeout_time建idx_status_timeout联合索引必做状态机锁定待支付status0关单用UPDATE ... WHERE status0必做主链路按需选 RabbitMQ 延迟队列或 RocketMQ 延迟消息按团队栈选择消息确认开启 publisher-confirm队列和消息都持久化消费端手动 ack必做兜底链路定时任务扫描 分布式锁ShedLock必做监控告警记录消息发送/消费日志超时未关单数超过阈值就告警强烈建议补偿入口消息日志表 手动重发接口建议我个人在实际项目里的体会是订单超时关单这种需求技术方案真的不难难的是把每个链路的异常场景想清楚。RabbitMQ 延迟队列再可靠也架不住生产环境里一些不可控的意外定时扫描再简单也替代不了消息链路触达的精准。所以正确的态度不是二选一而是让两条链路过上幂等检查这道闸门谁先完成关单谁就算数。最后再分享一个小技巧如果你的订单超时时间经常调整建议把超时值放到配置中心或者订单类型表里不要在代码里硬编码 15 分钟。我见过因为改了活动规则、忘了同步定时任务和延迟队列 TTL导致秒杀订单超时时间变成两套口径的线上事故。把这个值收敛到一个配置源两个链路都从那里读取就永远不会出现配置漂移的问题。
返回列表