订单30分钟未支付自动取消,这个需求在电商、外卖、预约、抢座这类系统里实在太太太常见了,常见到很多人第一眼看到它就直接开写定时任务。但如果你真把它当成一个“延时触发功能”来做,上线后大概率会被支付回调并发、库存没释放、消息丢失这种事追着跑。我前阵子在做一套带库存预约的交易系统时,也正儿八经把这条链路重新设计了一遍,从早期“定时扫库”改成了“延迟消息 + 数据库到期时间戳 + 兜底扫描”的组合方案。这篇文章不是教科书式讲理论,而是把那个项目里踩过的坑、做过的选型对比、最后落地的核心代码和边界处理都完整复盘出来。只要你的系统里出现过“超时未支付单需要自动关单”这几个字,这套思路基本可以直接迁移。
1. 先想清楚需求边界:这个“取消”不是一个定时器,而是一组状态流转
1.1 核心不在“30分钟”,而在“状态只能合法转换一次”
很多初版实现会把问题拆成两个动作:第一,下单后30分钟触发;第二,把订单状态改成“已取消”。这两个动作看起来是串行的,但实际上整个业务里同时存在多条链路在操作同一笔订单。
用户可能在手机上下单,订单状态是“待支付”;用户同时去支付,支付网关异步回调;另一个角落,取消任务的定时器到点触发。这三条链路之间没有任何天然的先后顺序,可能同时发生,也可能某个回调晚到了半分钟。假如取消任务只是简单执行“update set status='CANCELLED' where order_id=xxx”,完全不判断当前状态,那么它在支付回调到达之前先跑了,就会把一笔用户已经付了钱的订单给取消掉。这种问题一旦发生,就是妥妥的资损事故。
所以第一步设计的不是定时逻辑,而是订单状态机。最基本的一组状态是:
- PENDING:待支付
- PAID:已支付
- CANCELLED:已取消
- CLOSED:关闭或已完成
合法流转只有两条:PENDING 到 PAID,以及 PENDING 到 CANCELLED。PAID 不能再回到 CANCELLED,CANCELLED 也不能再被支付回调改成 PAID。数据库里的 status 字段必须作为事实的唯一来源,所有链路在改状态之前都要遵守“从 PENDING 才能迁出”的约束。
这个看似很简单的约束,才是整个自动取消方案最核心的不变量。后面所有的代码、SQL、幂等设计,本质上都是在守护这个不变量。
1.2 取消动作要释放的东西,往往比状态切换更重要
订单状态从 PENDING 改成 CANCELLED,只是完成了表面动作。真正的麻烦在于,创建订单时往往还预占了一批其他资源。
做电商,下单时锁定了库存;做预约,可能占用了某个时段的可约名额;做营销活动,可能冻结了一张优惠券;做会员体系,可能暂扣了积分或里程。这些资源在下单时已经被“预留”了,如果订单超时取消,而预留资源没有被释放,后续真实可售的库存就会越来越少,直到运营发现明明有库存却卖不出去。
我在项目里就遇到过类似问题:定时任务确实把订单状态更新成了已取消,但没有调库存释放接口。结果后台的待支付订单确实是关闭了,可那些名额依然被死死占着。排查了半天,才发现问题根本不在取消任务本身,而是取消流程里少了下游资源释放这一步。
因此在设计阶段,要把“订单取消”定义成一个完整的流程,而不是一条 SQL。流程至少包括:
- 校验订单当前状态,保证只处理 PENDING 且确实超时的订单;
- 更新订单状态为 CANCELLED,记录取消时间和原因;
- 释放库存、恢复优惠券、解冻积分等资源返还动作;
- 记录审计日志或操作流水。
如果库存、优惠券并不在同一个服务里,那这些释放动作还要做到幂等。最常用的办法是让释放接口接收一个业务幂等键,比如 orderId,下游保证同一个 orderId 的释放操作不管来几次都只有一次生效。这样才能支撑取消任务的重试和消息的重复投递。
1.3 边界 case 清单,拿来对齐需求
写代码之前,我习惯先列一个边界清单,拿给产品、后端、前端一起过。这份清单在我自己项目里基本长这样:
- 用户在第29分钟时发起了支付,支付扣款成功,但支付回调在订单被取消之后才到达,怎么处理?
- 取消任务执行到一半,应用重启了,重启后这个订单还会被继续取消吗?
- MQ消息重复投递,同一笔订单被取消两次,会不会释放两次库存?
- 消息队列消费积压,原来30分钟的取消延迟变成50分钟才触发,怎么兜底?
- 用户已经取消的订单,又收到支付成功的异步回调,是否要自动退款?
- 定时扫描兜底任务上线后,多台机器同时执行,会不会把同一笔订单重复取消?
这些问题不一定要全在第一个版本解决,但必须在一开始就知道哪些是靠设计规避的,哪些是靠状态机兜底的,哪些是靠对账手动修的。我见过太多团队把“30分钟未支付自动取消”估计成一个小需求,排期两三天,结果上线后每天都要人工处理异常单。提前把这份清单拿出来对齐,后面能少挨不少骂。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:从轮询扫表到延迟队列,我为什么选了“MQ+到期时间戳+兜底”组合
2.1 如果只想用定时任务扫表,会有什么问题
最朴素的做法是每隔一段时间用 XXL-Job 或 Spring Scheduled 扫描一次订单表,找出所有 status='PENDING' 且 create_time < now() - 30分钟 的订单,批量取消。这个方案实现起来确实最简单,短时间内也找不到什么毛病,所以被大量小规模系统采用。
但它的短板在你动辄几十万、上百万订单之后会暴露得很明显。首先,无条件扫描或者索引设计不合理会导致慢查询,每次扫描都扫到大量历史数据,数据库压力会越来越大。其次,任务的触发频率决定了取消的滞后程度,如果一分钟扫一次,最坏情况下用户在下单后30分59秒才会被取消,在时间敏感的业务里体验并不好。更危险的是,如果这台挂着定时任务的机器出问题、调度平台漏触发、或者那段时间任务队列堵了,那批应该被取消的订单就会一直悬在 PENDING 状态,没人处理。
说这些并不是说扫表方案不能用。如果你的业务量很小,用户支付超时晚一两分钟无感知,团队里也没有延迟队列设施,那它完全够用。但如果你对自动取消的实时性有要求,尤其还牵扯库存释放,那就不能让它当唯一手段,至少要做成“主要手段 + 兜底手段”里的那层兜底。
2.2 延迟方案逐个过一遍,讲清楚各自的适用边界
市面上聊得最多的延迟方案有几种:JDK DelayQueue、时间轮、Redis 过期监听、Redis ZSET 延迟队列、RabbitMQ 延迟消息、RocketMQ 延迟消息。
JDK DelayQueue 和 Netty 的 HashedWheelTimer 都属于进程内延迟任务。它的优点是触发精度高,代码写起来也轻便,缺点同样致命:任务放在内存里,应用一重启全部丢失,而且如果服务是多实例部署,下单请求被负载均衡分发到不同机器,那“到期取消”这个动作必须知道原本那笔订单的状态,稍微不自洽就没办法水平扩展。所以这类方案我一般只用在本地延迟小任务、定时清理本地缓存这种场景,不会拿来做核心订单的取消。
Redis 过期监听是网上被引用最多的方案:给某个 Key 设置 30 分钟过期,通过 Keyspace Notifications 监听过期事件。但用过的人都知道,Redis 的过期事件是“尽力通知”,并不保证每条过期事件都能被送达。主从切换、网络抖动、事件堆积都可能导致丢失,而且为了开启通知还要修改 Redis 配置。在我眼里,它当一个辅助提醒可以,作为订单自动取消的唯一依据是绝对不行的。
Redis ZSET + 轮询是另一种更可控的延迟队列实现。把订单 ID 作为 member,把过期时间戳作为 score,存进一个 ZSET。另起一个任务不断取 score 小于当前时间的任务,再用 ZREM 原子删除并执行取消。只要不用 zrange + delete 两步走,基本能做到不重复消费。这个方案实现成本适中,既能支撑多实例,也不依赖 MQ,是个很好的轻量级替代。我在一些没有消息队列的小项目里就推荐这么干。
RabbitMQ 延迟消息和 RocketMQ 延迟消息则是目前支撑交易系统最常用的两类方案。RabbitMQ 可以通过 TTL+死信队列实现延迟,也可以用 rabbitmq_delayed_message_exchange 插件直接支持任意延迟时间,投递可靠性由 MQ 本身保证。RocketMQ 则有内建的延迟消息能力,只需要选择对应的延迟等级。如果团队本身已经重度使用了 MQ,那基于 MQ 来实现延迟任务是水到渠成的选择。
2.3 组合方案设计与决策依据
我最终在项目里采用的,并不是上面某一种单一方案,而是三个部分组合:
- RabbitMQ 延迟消息作为触发主链路,负责在订单创建后约30分钟把一笔“待取消提醒”投递给消费者;
- 订单表里落一个 expire_at 真实到期时间,消费者收到消息后以数据库时间为准判断是否真的超时;
- 定时任务每分钟扫描一次状态为 PENDING 且 expire_at 小于当前时间的订单,作为最终兜底。
这么设计的原因很直接。我需要的首先是可靠,消息丢了或者 MQ 集群故障不能导致订单永远不会被取消;其次需要及时,不能容忍半小时的超时等到第二天才被批量任务扫掉;还希望不要给订单表造成太大扫描压力,不要每次为了找“到期的几十单”而扫全表。
所以延迟消息负责大部分到点取消,让系统保持自动触发的灵敏性;定时扫描只是“保险丝”,解决所有延迟消息链路可能出现的漏网问题。一张订单如果真的因为消息丢失而没有被及时取消,最迟也会在下一次兜底扫描中被处理,最坏情况大概就是比设定时间晚了几十秒或一分钟,业务上完全可接受。
2.4 方案横向对比表
| 方案 | 实现成本 | 实时性 | 可靠性 | 水平扩展 | 典型风险 |
|---|---|---|---|---|---|
| 定时任务扫库 | 最低 | 受扫描频率限制 | 依赖任务正常触发 | 需处理分布式锁 | 慢查询、漏触发、无补偿 |
| JDK DelayQueue / 时间轮 | 低 | 高 | 低 | 差 | 应用重启丢失,无法跨实例 |
| Redis Keyspace 过期通知 | 低 | 较高 | 低 | 较好 | 过期事件可能丢失 |
| Redis ZSET 延迟队列 | 中 | 高 | 中 | 好 | 非事务,消费时需保证幂等 |
| MQ 延迟消息 | 中高 | 高 | 高 | 好 | 需要额外中间件成本 |
| MQ + DB到期时间戳 + 兜底 | 较高 | 高 | 很高 | 好 | 实现复杂度最高,适合核心订单 |
如果你业务简单、订单量不大,完全可以选前面几种轻量方案;但如果这笔订单牵涉到资金、库存、优惠这类强一致资源,那组合方案虽然写起来多了一点,长期看是最省心的。
3. 落地实现:关键代码、SQL 与事务边界
3.1 订单表里多存的几个字段
我的建议是订单表里至少要保留这几个与超时取消强相关的字段:
- expire_at:订单的绝对到期时间,在订单创建时写入,比如 now() + 30 分钟
- actual_cancel_at:实际取消时间
- cancel_reason:取消原因
- status:订单状态
其中 expire_at 是整条方案的定海神针。它把“30分钟后取消”这种相对时间变成数据库里的绝对时间,后面消费者、兜底任务、监控都在拿当前时间和 expire_at 做比较,而不是去猜“这条消息是什么时候创建的、是不是已经过了30分钟”。
建表 SQL 大致长这样:
sql复制CREATE TABLE `t_order` (
`order_id` varchar(32) NOT NULL COMMENT '订单号',
`user_id` bigint NOT NULL COMMENT '用户ID',
`status` varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING_待支付/PAID_已支付/CANCELLED_已取消',
`expire_at` datetime NOT NULL COMMENT '过期时间,超过则允许取消',
`actual_cancel_at` datetime DEFAULT NULL COMMENT '实际取消时间',
`cancel_reason` varchar(50) DEFAULT NULL COMMENT '取消原因',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`order_id`),
KEY `idx_status_expire` (`status`, `expire_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里索引非常关键。状态和过期时间两个条件一起走,后面的定时兜底扫描才不会变成全表扫。
3.2 延迟消息发送:要处理的不是 MQ 本身,而是“事务和消息怎么保持一致”
下单接口在同一个事务里做两件事:往订单表插入 PENDING 记录,然后发送一条 30 分钟后到期的延迟消息。这里的经典坑是:如果先插入订单,再发消息,发消息环节失败了怎么办?订单已经创建成功,用户甚至可以看到订单,但取消事件永远没发出去。
组合方案的兜底扫描能够把这批漏掉的订单救回来,所以我不主张在这个环节设计得特别复杂。但如果希望做到更严格的可靠,可以引入本地消息表机制,也就是 Transactional Outbox。
思路很简单:
- 在同一个数据库事务里插入订单记录,同时往本地消息表 order_cancel_outbox 插入一条待发送消息;
- 事务提交成功后,由后台任务异步读取 outbox 中未发送的消息,投递到 RabbitMQ;
- 投递成功后,把 outbox 消息状态标记为已投递。
这样做能保证订单创建和“未来要取消”这件事至少先落库,不会因为代码刚好在事务中间抛异常导致丢消息。outbox 表本身也承担着监控职责,如果里面的消息越积越多,说明投递链路出了问题。
发送延迟消息时,我用的是 RabbitMQ delayed message 插件。这种方式比 TTL+死信队列更直观,延迟时间直接写在消息头里,不用为每个延迟级别单独建队列。配置一个延迟交换机大致如下:
java复制@Bean
public CustomExchange delayExchange() {
Map<String, Object> args = new HashMap<>();
args.put("x-delayed-type", "direct");
return new CustomExchange("exchange.order.cancel.delay", "x-delayed-message", true, false, args);
}
发送消息时设置 x-delay header:
java复制String payload = JSON.toJSONString(new OrderCancelMsg(orderId));
Message message = MessageBuilder
.withBody(payload.getBytes(StandardCharsets.UTF_8))
.setHeader("x-delay", 30 * 60 * 1000)
.setContentType(MediaType.APPLICATION_JSON_VALUE)
.build();
rabbitTemplate.send("exchange.order.cancel.delay", "order.cancel", message);
注意一点:这里发的是一条“提醒”,不是一条“执行取消”的指令。我从来不在消息里直接携带“该订单已超时,请立刻取消”这种结论。取消不取消,以数据库里的 expire_at 和 status 为准。
3.3 取消消费者:一条原子 UPDATE 顶住并发
消费者收到消息后,第一件事不是取消,而是查库。如果订单已经支付、已经取消,或者还没到 expire_at,就直接忽略。只有确认订单仍然是 PENDING 且已经过期,才真正执行取消。
核心的取消逻辑可以抽象成一个方法:
java复制@Transactional(rollbackFor = Exception.class)
public boolean cancelExpiredOrder(String orderId) {
// 通过一条条件 UPDATE 原子地完成“状态校验 + 翻转”
int rows = orderMapper.cancelIfPendingAndExpired(orderId, new Date());
if (rows == 0) {
log.info("订单 {} 状态已变化或未到过期时间,跳过取消", orderId);
return false;
}
// 走到这里说明本实例成功拿到了取消资格
doReleaseResources(orderId);
return true;
}
对应的 SQL 是:
sql复制UPDATE t_order
SET status = 'CANCELLED',
actual_cancel_at = NOW(),
cancel_reason = 'TIMEOUT'
WHERE order_id = #{orderId}
AND status = 'PENDING'
AND expire_at <= NOW();
这段 update 是整个方案里最需要理解的地方。它利用数据库行锁和条件更新,让“检查状态”和“修改状态”变成一条不可分割的原子操作。多个请求并发执行时,InnoDB 会给同一行记录加锁,后到的更新必须等前一个事务提交或回滚,所以最终只有一行 status 能从 PENDING 被改成 CANCELLED。
很多人容易写错的是先 select 查一次订单状态,如果发现是 PENDING 再 update。这样在并发场景里完全不可靠,因为你在 select 和 update 之间,其他线程可能已经把订单改成 PAID 了。必须把判断条件写进 update 的 where 里面,靠返回的影响行数来判断这次操作是否成功。
3.4 取消后的释放动作失败怎么办
取消流程里还有一个容易踩的坑:订单状态已经成功改成 CANCELLED,但下游的库存释放接口超时了,或者优惠券服务报错了。这时候订单已经被取消,如果资源没有释放,就会出现“单关了但库存还没还”的不一致。你不能因为这步失败就回滚订单状态,因为订单已经过期,不能让它“复活”成待支付,否则用户仍然可以继续支付,又会产生新的问题。
更合理的模型是引入一张额外的任务表,记录取消之后还需要完成的资源释放操作:
sql复制CREATE TABLE `order_cancel_task` (
`id` bigint NOT NULL AUTO_INCREMENT,
`order_id` varchar(32) NOT NULL,
`task_type` varchar(30) NOT NULL COMMENT 'STOCK_RELEASE/COUPON_RESTORE',
`status` varchar(20) NOT NULL DEFAULT 'INIT' COMMENT 'INIT/RETRY/SUCCESS/FAIL',
`retry_count` int DEFAULT 0,
`next_retry_at` datetime DEFAULT NULL,
`last_error` varchar(500) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_status_next_retry` (`status`, `next_retry_at`)
) ENGINE=InnoDB;
订单取消的本地事务里,在 update 成功之后,再往这张 task 表插入需要执行的资源释放任务。真正释放库存、恢复优惠券的动作可以由后台任务异步消费 task 表。成功就标记为 SUCCESS,失败则按指数退避重试,超过最大重试次数进入 FAIL,这个时候就需要告警并人工介入了。
这个设计把“订单取消”拆成了“状态翻转”与“资源释放”两件独立的事情。状态翻转必须保证及时、准确、不重复;资源释放可以异步执行,但通过任务表和重试机制保证最终完成。
4. 最容易被忽略的边界:支付回调与自动取消并发
4.1 真实并发时序:钱到了,单也被取消了
我们设想一个很常见的时序:用户在下单之后第29分50秒开始支付,微信或支付宝已经完成了扣款,但回调报文因为网络原因延迟了十几秒才到达我们的后端。此时,自动取消任务已经到了执行时间,这条订单的状态还是 PENDING,于是取消任务成功把它改成了 CANCELLED。几秒之后,支付回调到了,发现订单状态是 CANCELLED,直接忽略,但用户的资金已经被扣了。
这就是“钱到、单取消”的并发窗口,任何只做定时取消不处理支付回调的代码都会踩中。
有一种思路是禁止自动取消最近一两分钟内创建的订单,给支付回调留出时间窗口。这能降低概率,但不能根治问题,而且会让超时时间变得不准确。真正靠谱的做法还是依靠状态机和回调后的自动处理。
4.2 用状态机更新而不是分布式锁
处理支付回调和取消任务并发,我见过不少团队引入 Redis 分布式锁,给每笔订单加锁,然后再查状态、改状态。这当然能解决问题,但其实没有必要。
更简单的做法是在支付回调的处理逻辑里也使用条件更新,与取消任务共用同一个状态机约束:
sql复制UPDATE t_order
SET status = 'PAID'
WHERE order_id = #{orderId}
AND status = 'PENDING';
这条 SQL 和取消 SQL 同时执行时,数据库行锁会保证只有一个能成功。谁先执行谁成功,后执行的那一方因为 status 已经变了,更新影响行数为 0,就知道自己应该走另一条分支。
支付回调的代码可以写成:
java复制public void handlePayCallback(PayCallbackRequest request) {
int rows = orderMapper.markPaidIfPending(request.getOrderId());
if (rows == 1) {
// 从 PENDING -> PAID 成功,走支付成功后续逻辑
orderService.afterPaid(request.getOrderId());
return;
}
Order order = orderMapper.selectById(request.getOrderId());
if (order != null && "CANCELLED".equals(order.getStatus())) {
// 支付成功但订单已经被取消,这里必须主动退款
refundService.applyRefund(order, "订单取消后支付成功回调");
}
}
这里需要注意,update 影响行数为 0 并不能区分订单是 CANCELLED 还是已经是 PAID,所以第二次查询是必要的。
4.3 取消后支付成功:自动退款兜底
如果说“取消任务先执行,支付回调后执行”这种场景无法彻底消除,那业务上就必须给一个兜底结果。我在实际项目里最常用的兜底策略就是:收到支付成功回调时,如果发现订单状态是 CANCELLED,自动触发原路退款,并记录一条支付异常流水。
在这个模型下,订单本身不“复活”回 PAID 状态,因为库存可能已经释放给其他用户了,订单不能再被履约。用户端看到的情况是:钱被扣了,但订单显示已取消,随后系统自动退款。虽然体验上有点奇怪,但至少不会产生资损。
如果业务上希望尽量避免这种让用户困惑的体验,可以考虑在下单成功后创建一笔支付单,并让支付中的订单状态从 PENDING 先迁移到 PAYING。PAYING 状态的订单不会进入自动取消的候选集。但这个方案会引入新的问题:如果用户发起支付后一直不完成,订单就一直占着库存,最终还是需要另一套超时机制来关闭正在支付中的订单。复杂度会上升不少,不建议小团队直接上。更低成本的做法是把退款通道做顺,把异常流水监控起来,同时让取消任务尽量以 expire_at 为准,不给提前误杀留机会。
4.4 消息重复消费的幂等,靠什么保证
MQ 的投递语义通常是 at least once,即消息可能重复。取消任务消费同一个订单的延迟消息,可能不止一次。好在前面那张带条件的 UPDATE 已经天然实现了幂等:第一次执行时成功把 PENDING 改成 CANCELLED,第二次再执行时 where 里的 status 条件不满足,影响行数为 0,方法直接返回,不会重复释放资源。
这里尤其要提醒的是:资源释放动作不要放在条件 UPDATE 之前。如果先调用库存释放接口,再更新订单状态,那重复消息就会导致库存被释放两次,除非下游接口本身有幂等处理。所以执行顺序必须是“先通过 UPDATE 争夺到状态变更资格,再去做资源释放”,把资源释放和幂等保护严格绑定在状态更新成功之后。
5. “30分钟”的时间语义与可靠性细节:误差、积压、服务重启
5.1 消息积压后,不能让“30分钟”变成“2小时”
很多人设计延迟消息时,习惯在消费者里直接取消订单,理由是“消息到了就说明30分钟到了”。但这个推导是有问题的。假设 MQ 消费端积压了十万条消息,或者服务刚好在那段时间重启,恢复之后开始大量消费积压消息,原来应该在第30分钟触发的取消任务,可能到第2个小时才开始处理。
如果消费代码不判断数据库的 expire_at,而是收到消息就取消,那这些积压消息反而不会造成漏取消,最多是取消得晚。真正需要注意的是另一种情况:消费者处理速度很快,但延迟消息因为各种各样的原因被提前投递了,或者服务时钟有偏差,导致用户在还没到30分钟时订单就被取消。这对用户体验来说是致命的。
解决办法就是我把 expire_at 落库的另一个原因:消费者收到消息后,不要默认“现在一定超时了”,而要去查数据库。只有 expire_at 确实小于等于当前时间,才允许执行取消;如果还没到期,那说明消息来早了,直接丢弃即可,未来到点时兜底扫描也会处理。
5.2 消费者的时间判断以 DB 为准,消息只是提醒
把消费者当作“到了该取消的时候提醒我去检查一下”的触发器,比把消费者当作“执行取消命令”的执行器更稳健。消费者收到消息后的逻辑应该是:
java复制@RabbitListener(queues = "order.cancel.queue")
public void onCancelMessage(OrderCancelMessage message) {
Order order = orderMapper.selectById(message.getOrderId());
if (order == null) {
log.warn("订单不存在: {}", message.getOrderId());
return;
}
if (!"PENDING".equals(order.getStatus())) {
return;
}
if (order.getExpireAt().after(new Date())) {
// 还没到真正超时时间,什么都不做
return;
}
cancelService.cancelExpiredOrder(order.getOrderId());
}
这套实现看起来多了一次查询,多了一层判断,但价值很大。它把 DB 里的 expire_at 变成了时间语义的唯一权威来源,消息本身只负责把系统“叫醒”,避免因为 MQ 的投递时间偏差而误判。
5.3 兜底扫描任务:写法和注意事项
定时扫描不是简单地把“过期订单全查出来”然后 for 循环取消,那样当数据量大的时候很容易把数据库打爆。我采用的兜底写法是每个周期只取一小批订单,处理完再取下一批。核心代码逻辑如下:
java复制public void scanExpiredOrders() {
int batchSize = 200;
while (true) {
List<String> orderIds = orderMapper.selectExpiredPendingIds(new Date(), batchSize);
if (CollectionUtils.isEmpty(orderIds)) {
break;
}
for (String orderId : orderIds) {
cancelService.cancelExpiredOrder(orderId);
}
if (orderIds.size() < batchSize) {
break;
}
}
}
对应的扫描 SQL 需要用到联合索引:
sql复制SELECT order_id
FROM t_order
WHERE status = 'PENDING'
AND expire_at <= NOW()
LIMIT 200;
如果服务是多实例部署,定时任务不能每台机器都跑,否则会对同一批订单执行重复取消。即使取消逻辑幂等,也会造成不必要的锁竞争和日志噪音。解决办法是用 XXL-Job 这类分布式调度平台,让任务只在某一台机器执行;或者用一个分布式锁包住这个方法,只有拿到锁的实例才执行扫描。
兜底任务的频率我一般设置为每分钟一次。如果业务对实时性要求更高,可以调到30秒一次,但要注意对数据库的影响。扫描频率和批量大小需要做压测观察,不要照搬。
5.4 可观测性与监控告警
自动取消这条链路完整跑通后,不要急着认为万事大吉。线上必须能看到取消任务到底有没有在正常工作。我上线时会关注几个核心指标:
- 每天通过延迟消息触发的取消单量;
- 每天通过兜底扫描触发的取消单量;
- 订单从 expire_at 到 actual_cancel_at 的延迟时间分布;
- 订单取消后资源释放任务的成功率与重试次数;
- MQ 里的消费积压数量与 outbox 表里的未投递消息数。
正常情况下,延迟消息触发的取消单量应该占据绝大多数。如果某天兜底扫描发现的取消单量占比突然变高,基本可以断定 MQ 链路或消费链路
