ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

订单超时关闭的三种高效方案:定时任务、延迟队列、时间轮

订单超时关闭的三种高效方案:定时任务、延迟队列、时间轮 订单超时关闭这个问题看着简单做起来全是坑。我最早接触是在做电商中台的时候订单创建后 30 分钟未支付就得自动关闭还要回滚库存、释放优惠券。当时并发量一上来定时任务扫表把数据库 CPU 直接打满订单倒是关掉了其他业务全被拖垮。后来陆续用了几种方案才把这块彻底理顺。这篇就把我在实际项目中验证过的 3 种高效方案完整拆开讲一遍从最朴素的定时任务扫表到延迟队列再到时间轮。会讲清楚每种方案的核心思路、能扛多大并发、有什么坑、适合什么业务阶段最后附上选型对比和我在线上踩过的典型问题。内容偏实战适合正在做订单中心、支付模块、任务调度的后端开发或者准备做技术方案选型的朋友参考。1. 问题拆解订单超时关闭到底难在哪里1.1 超时关闭的本质是状态机的时间约束订单从创建到最终状态本身是一条状态流待支付 → 已支付 → 已发货 → 完成或者 待支付 → 已关闭 → 回到库存。超时关闭要做的就是在“待支付”状态停留超过阈值后强制触发一次状态流转。听起来就是一个简单的 update 语句但放在高并发场景下难点就出来了。第一订单量大同一时刻可能有一大批订单落在同一个超时点附近比如整点下单高峰后 30 分钟就是一个超时集中爆发点。第二每个订单需要“精确延时触发”不能提前也不能拖太久。提前关会把用户正在支付的订单关掉造成资损拖太久会积压库存影响转化率。第三超时动作往往不是孤立操作关闭订单的同时还要解冻库存、失效优惠券、给用户发通知这一串动作必须保证一致性。所以超时关闭问题的本质不是一个简单的定时任务而是一个“海量延迟消息的精准调度与可靠消费”问题。想清楚这一点后续方案选型就有了方向。1.2 三种方案的底层逻辑与演进脉络市面上常见的方案绕不开三条技术路线。第一种是数据库轮询 定时任务扫描。实现最简单起一个 XXL-Job 或者 Spring Scheduled 任务每 30 秒扫一次订单表把超时未支付的订单批量关掉。适合单量不大、对实时性要求不高的阶段。第二种是延迟队列。把订单超时事件作为一个延迟消息发给 MQ比如 RabbitMQ 死信队列、RocketMQ 定时消息、或者 Redis ZSet 做延迟队列。到了时间点消息被投递到消费端触发关单逻辑。这种方案把“扫描”变成了“事件驱动”实时性好能抗住中等规模并发。第三种是时间轮。把超时任务放在内存里的环形队列用指针拨动逐个触发。典型实现是 Netty 的 HashedWheelTimer或者自研一个分层时间轮。吞吐量极高适合超大规模、高实时性要求的场景但要做落地加固比如任务预热、持久化、宕机补偿。这三个方案是逐渐演进的关系不是非此即彼。我见过很多团队一上来就想搞时间轮结果订单量还没到那个级别徒增运维成本。方案选型一定要匹配业务阶段这一点后文会详细展开。2. 方案一定时任务扫表最简单也最容易翻车2.1 实现思路与关键代码先把这个方案讲透因为它是很多项目的起点。核心逻辑很简单单独建一张订单表订单创建时记录 create_time设置一个超时阈值 expire_time定时任务定期扫描 expire_time now 且 status 待支付 的订单批量更新状态。我提供一个我实际用过的 Spring Boot MyBatis-Plus 实现配合 XXL-Job 做分布式调度。// 订单实体核心字段 // order_id, status, create_time, expire_time // Mapper - 扫描超时订单 public interface OrderMapper extends BaseMapperOrder { // 分批扫描避免一次加载过多 Select(SELECT id FROM t_order WHERE status 0 AND expire_time NOW() ORDER BY id ASC LIMIT #{limit}) ListLong selectTimeoutOrderIds(Param(limit) int limit); } // 定时任务 - 每天整点跑实际用 XXL-Job 配置 cron Component public class OrderTimeoutJob { Resource private OrderMapper orderMapper; Resource private OrderCloseService orderCloseService; XxlJob(orderTimeoutCloseJob) public void execute() { int batchSize 500; while (true) { ListLong orderIds orderMapper.selectTimeoutOrderIds(batchSize); if (CollectionUtils.isEmpty(orderIds)) { break; } // 批量执行关单内部包含状态校验、库存回滚、通知等 orderCloseService.closeOrders(orderIds); if (orderIds.size() batchSize) { break; } } } }这里我用了两个关键设计。第一是 LIMIT 分批拉取每批 500 条处理完再拉下一批避免一次性把几千几万条订单全加载进内存把 JVM 撑爆。第二是 while 循环持续拉取直到这一轮没有超时订单为止保证数据不会被漏掉。2.2 数据库轮询的致命缺陷与优化手段这个方案最大的问题是扫描频率和数据库压力是直接矛盾的。扫描间隔设短一点比如 5 秒订单关闭实时性好但对订单表就是每秒上千次全表范围查询并发一高数据库 CPU 瞬间飙红。扫描间隔设长一点比如 30 秒甚至 1 分钟数据库压力小但订单超时后可能会延迟很久才被关闭用户都去别家下单了库存还没释放。我的优化经验是三层递进。第一层是索引优化expire_time 和 status 建联合索引让扫描走索引而不是全表。第二层是扫描位点优化不每次扫描全表记住上一次扫到的最大 ID下次从 ID 之后继续减少重复扫描。第三层是分片订单量大时按订单 ID 哈希取模分多片每个分片一个任务线程并发扫提高吞吐。注意分片任务不要共用事务否则一个分片失败全部回滚。即便如此这个方案能支撑的并发上限也就是每秒几十到几百个订单创建量。再往上定时任务扫表就不合适了因为无效扫描太多。比如每分钟只有几十个订单超时但你每分钟要扫一次全表90% 以上的查询都是无效的资源浪费严重。3. 方案二延迟队列把超时变成事件驱动3.1 为什么 RabbitMQ 死信队列适合订单超时延迟队列的思路和定时任务完全不同。不再是主动扫表而是订单创建的时候就把一个“延迟消息”扔进队列消息带一个 TTL过期时间TTL 到了之后消息自动变成死信被路由到真正的处理队列里消费者收到消息后执行关单。这样做的好处非常明显。第一实时性强消息一到时间就触发误差控制在秒级。第二解耦订单服务和关单消费者完全分离关单逻辑可以独立扩展。第三削峰填谷MQ 本身就具备消息堆积能力即便某一瞬间订单量集中爆发消息在队列里排队消费端可以平滑处理。这里要说清楚一个核心概念TTL 一过消息不会消失而是会被转发到死信队列。RabbitMQ 允许你在声明业务队列时指定 x-dead-letter-exchange 和 x-dead-letter-routing-key当消息过期、被拒绝或者队列达到最大长度时消息就会进入这个死信交换器再路由到指定的死信队列。我们的消费者监听的是死信队列收到的就是已经“到点”的订单事件。3.2 完整配置与核心代码我以 RabbitMQ 为例直接贴一份我项目里的完整配置。注意版本用的是 Spring Boot 2.x RabbitMQ 3.8不同版本略有差异但原理一样。Configuration public class RabbitDelayConfig { // 业务队列带 TTL 和死信路由 Bean public Queue orderDelayQueue() { MapString, Object args new HashMap(); args.put(x-dead-letter-exchange, order.close.exchange); args.put(x-dead-letter-routing-key, order.close.key); args.put(x-message-ttl, 30 * 60 * 1000); // 30 分钟单位毫秒 return new Queue(order.delay.queue, true, false, false, args); } // 死信队列消费者监听这个队列 Bean public Queue orderCloseQueue() { return new Queue(order.close.queue, true); } Bean public DirectExchange orderCloseExchange() { return new DirectExchange(order.close.exchange); } Bean public Binding binding() { return BindingBuilder.bind(orderCloseQueue()) .to(orderCloseExchange()) .with(order.close.key); } }消息发送端创建订单成功后发送一条延迟消息消息体只需要订单 ID 和创建时间。Component public class OrderTimeoutProducer { Resource private RabbitTemplate rabbitTemplate; public void sendTimeoutMessage(Long orderId) { String message JSON.toJSONString( Collections.singletonMap(orderId, orderId)); rabbitTemplate.convertAndSend( , order.delay.queue, message.getBytes(StandardCharsets.UTF_8) ); } }消费端监听死信队列收到消息后执行关单。Component public class OrderTimeoutConsumer { Resource private OrderCloseService orderCloseService; RabbitListener(queues order.close.queue) public void onMessage(Message message) { String body new String(message.getBody(), StandardCharsets.UTF_8); JSONObject json JSON.parseObject(body); Long orderId json.getLong(orderId); // 关键校验可能已被支付或关闭必须做幂等 orderCloseService.closeOrderQuietly(orderId); } }3.3 消息可靠性、乱序与重复消费的实战处理这套方案看着顺线上跑起来就发现细节全在“可靠性”三个字上。第一个坑是单个消息积压导致后续消息全部阻塞。RabbitMQ 的死信队列默认是先进先出如果队头的某条消息消费很慢后面到期的消息全被堵住。比如 14:00 有一条消息消费端处理了 10 秒14:00:01 到期的订单就会随之延迟 10 秒。解决办法是设置消费端的 prefetch 参数让消费者一次只取一条消息处理完再取下一条尽量降低单点阻塞影响。第二个坑是重复消费。RabbitMQ 消费端在业务处理成功后、手动 ACK 之前宕机消息会被重新投递。所以关单逻辑必须做幂等我在 closeOrderQuietly 里先查订单状态只有 status待支付 才执行关单操作并且用乐观锁 UPDATE ... WHERE status0 保证并发下只有一个请求能成功更新。Transactional public boolean closeOrderQuietly(Long orderId) { // 乐观锁更新防止重复关闭 int rows orderMapper.closeIfWaiting( orderId, System.currentTimeMillis()); if (rows 0) { return false; } // 执行回滚库存、失效优惠券等动作 stockService.releaseStock(orderId); couponService.invalidateCoupon(orderId); return true; } // Mapper SQL // UPDATE t_order SET status 2, close_time #{now} // WHERE order_id #{orderId} AND status 0第三个坑是消费端异常导致消息丢失。RabbitMQ 默认是自动 ACK即消费者拿到消息后立即确认不管业务处理是否成功。如果我们的代码里没有手动 ACK关单逻辑抛异常时消息已经确认没了订单永远不会被关闭。建议改成手动 ACK业务处理成功才确认失败可以重新入队或者进死信队列。这是线上最容易出隐形事故的地方。RocketMQ 的实现逻辑类似但它是原生支持定时消息的不需要依赖死信队列的“弯弯绕”直接声明一个延迟级别对应的时间即可。我用过 RocketMQ 4.x 版本支持 18 个延迟级别默认对应 1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h订单超时通常对应 30 分钟这个级别。固定延迟级别够不上自定义时间需求时还是得回到 RabbitMQ 的 TTL 思路。4. 方案三时间轮调度高吞吐的内存触发引擎4.1 时间轮原理用生活的类比讲清楚时间轮这个概念我用一个生活例子就能解释明白。想象家里有一个圆形的钟表一圈被分成 12 个格子每个格子代表一小时。你在 3 点钟方向放了一个需要 3 小时后执行的纸条指针每小时走一格走到 6 点钟的时候就把纸条拿下来执行。程序里的时间轮就是这个钟表。Netty 的 HashedWheelTimer 把时间划分为一个个 tick比如每个 tick 是 100ms一圈有 512 个槽位。任务根据延迟时间算出应该挂在哪个槽位上指针每 tick 前进一步把当前槽位上的所有任务取出来执行。如果有任务延迟超过一圈比如 100 秒而一个 tick 是 100ms、一圈 512 个 tick 总共 51.2 秒那这个任务就要放在两圈后的槽位用一个 round 字段记录圈数每次经过该槽位时判断圈数是否归零。时间轮的优势是 O(1) 的任务插入与取消复杂度所有操作都是内存操作不依赖数据库和外部 MQ。对于订单超时这种“创建时注册一个任务、到点触发”的场景天然契合。4.2 基于 Netty HashedWheelTimer 的实现示例先引入依赖dependency groupIdio.netty/groupId artifactIdnetty-common/artifactId version4.1.100.Final/version /dependency核心代码Component public class OrderTimeoutWheel { private final HashedWheelTimer timer; public OrderTimeoutWheel() { // tick 100ms512 个槽位整轮 51.2 秒 this.timer new HashedWheelTimer( new DefaultThreadFactory(order-timeout-wheel), 100, TimeUnit.MILLISECONDS, 512, true ); } // 订单创建时调用注册一个延迟任务 public void registerTimeout(Long orderId, long delayMillis) { timer.newTimeout(timeout - { try { orderCloseService.closeOrderQuietly(orderId); } catch (Exception e) { // 任务异常必须捕获否则 Netty 会打印错误但不影响其他任务 log.error(close order timeout failed: {}, orderId, e); } }, delayMillis, TimeUnit.MILLISECONDS); } }使用很简单订单创建成功后在业务代码里调用 registerTimeout 即可。这里有个细节HashedWheelTimer 的任务回调是异步线程池里执行的多个任务之间不会互相阻塞。但要注意回调里的异常一定要捕获因为 Netty 的默认异常处理只是打日志不会影响其他任务但如果不捕获异常信息可能被吞掉排错会很困难。4.3 时间轮的四个致命坑与加固方案时间轮的能力强但落地难度也是三个方案里最大的。我逐个说坑。第一个坑是任务不会持久化服务重启即丢。时间轮完全在内存里JVM 挂了或者服务重启所有未触发的超时任务全部丢失。要解决必须在订单创建的时候落一张超时任务表服务启动时扫描未执行的记录重新注册到时间轮里。相当于做了一层“预热恢复”。第二个坑是时间精度和内存容量的权衡。tick 越小精度越高但空转的次数就越多CPU 消耗越高。我实测下来100ms 的 tick 对订单超时场景足够了没必要追求 1ms 级别。槽位数量也不是越大越好512 个槽位能承载几十万级别的任务没问题再大就考虑分层时间轮但实现复杂度会明显上升。第三个坑是任务执行超时会拖慢指针轮转。虽然任务回调是异步线程池但如果回调内部又调用了同步的资源访问比如数据库更新而数据库恰好慢了会导致任务线程阻塞积累。建议把任务回调设计成只做“投递事件”把真正耗时的关单操作再丢给 MQ 或者业务线程池异步执行。第四个坑是时间轮不适合直接跨实例共享。如果部署了多台实例每个实例都有自己的时间轮任务注册在 A 实例A 宕机了任务就丢了。要么做任务分片让每个实例只负责一部分订单要么用 Redis 做共享元数据每个实例定期从 Redis 拉取归属于自己的任务。后一种方案复杂度已经接近自研延迟队列了所以我会建议除非订单量真的到了单机上百万级别否则优先选延迟队列更稳妥。5. 方案对比、线上问题排查与选型建议5.1 三方案横向对比速查表我根据实际项目经验把三个方案的核心维度整理成一张表。做技术选型的时候直接对着看就行。对比项定时任务扫表延迟队列RabbitMQ 死信时间轮Netty实现复杂度低中高实时性受扫描周期影响秒级到分钟级秒级受 MQ 投递影响毫秒级tick 决定精度吞吐量受数据库性能限制受 MQ 和消费端处理能力限制纯内存操作吞吐量最高可靠性依赖数据库状态断电不丢依赖 MQ 持久化可靠性较好内存存储宕机丢失需补偿运维成本几乎为零需要维护 MQ 集群需维护任务恢复逻辑典型场景企业后台、订单量小电商订单、中等规模抢购秒杀、超大订单量有人会问既然时间轮吞吐量最高为什么我不直接推荐它因为订单超时关闭这个场景真正吃紧的不是触发任务的吞吐量而是任务触发后的关单处理链路。关单要回滚库存、更新订单、发通知这一串链路下来单条消息的耗时在几十毫秒到几百毫秒之间。吞吐量的瓶颈在事务性操作和下游系统不在触发机制本身。所以延迟队列在绝大多数订单系统里已经够用。5.2 线上常见问题的判断与处理先说订单没有被关闭。排查顺序是先确认消息是否发到了延迟队列再看延迟队列里的消息有没有在达到 TTL 后进入死信队列最后看死信队列消费者有没有报错。RabbitMQ 管理页面能直接看到各队列的 Ready/Unacked 数量如果死信队列的 Ready 一直是 0说明消息没被投递过来重点检查死信 exchange 和 routing key 的绑定关系。如果 Ready 有值但消费不掉看消费者的日志和手动 ACK 逻辑。再说订单被提前关闭。第一次发生的时候我吓了一跳查了半天发现是服务器时钟漂移导致 TTL 计算错误。RabbitMQ 的 TTL 是消息进入队列的那一刻计算的如果发送端服务器和 MQ 服务器的时钟不同步消息可能提前“满龄”。解决方法是统一用 NTP 同步所有服务器时间或者在发送端把 TTL 设在消息头里用创建时间 超时时长精确计算而不是依赖队列级别的统一 TTL。再分享一个消费端 prefetch 踩坑实录。线上某次压测时发现订单关闭延迟从 1 秒飙到 30 多秒排查发现是某个消费线程卡在库存服务调用上一次拉取的 20 条消息全部阻塞后面所有消息跟着排队。把 prefetch 改成 1 之后单条消息失败只影响自己不阻塞同伴整体延迟直线下降。这个参数很多人忽略在高并发消息消费场景里是必调的。5.3 基于业务规模的分阶段落地建议我做技术方案不会只看技术指标更看重团队维护能力和业务发展阶段。这里给一个我实际推动过多次的分阶段建议可以直接照着落地。订单量在每日几千到几万团队人少优先用定时任务扫表。闭着眼睛都能维护出了问题也能顺着 SQL 排查。唯一要做的是加好索引、分批处理配置一个合理的扫描频率比如每 10 秒扫一次对数据库压力可以接受。订单量到每日几十万或者用户对体验要求高比如 30 分钟超时希望误差控制在几秒内果断切延迟队列。RabbitMQ 死信队列或者 RocketMQ 定时消息都行成本主要在 MQ 集群的运维。实在不想自建 MQ用 Redis 的 ZSet 做延迟队列也能顶上但可靠性要自己考虑Redis 挂掉会丢消息。到了每日千万级订单、集群规模大、需要精准控制每一笔超时订单的触发时间再考虑时间轮或自研分层时间轮。而且一般不会单独用时间轮而是“时间轮 任务表 MQ 兜底”的组合时间轮负责高效触发任务表负责持久化恢复关单事件丢 MQ 异步执行。我见过的大厂订单超时方案基本都是这个套路。5.4 最后的两个经验之谈第一别把全部超时任务寄托在一个机制上。我现在的线上方案是“延迟队列为主 定时任务兜底”。延迟队列正常时关单实时性很好如果 MQ 抖动或者消费堆积了定时任务扫描还能把超时订单捞回来。两条链路互不干扰数据最终一致可靠性翻倍。第二关单操作尽量做成异步串行不要在一个事务里做太多事。我早期把库存回滚、优惠券失效、订单状态更新都塞在一个大事务里结果库存服务一抖动事务超时回滚订单反而没关掉。后来拆成两段第一段先更新订单状态为关闭中并占用事务第二段以独立事务去操作下游。即便某个步骤失败也还有重试机会不会因为下游故障连累关单主流程。线上压测和灰度观察是方案落地的最后一道关。三种方案我都在真实订单链路里跑过不是说哪种绝对好而是要看你的订单量、团队配置、消息中间件依赖程度。选型前先想清楚你的业务处在哪个阶段别一上来就奔着最强方案去最合适的才是最好的。
返回列表