做交易类系统(电商、票务、本地生活),"下单后30分钟不支付就自动取消并释放库存"是个标配需求。实现方式常见的有三种:定时任务扫描、Redis过期监听、消息队列延迟消息。这篇文章用 Go 给出三种方案的最小实现,把各自的原理、坑和适用边界讲清楚。
先明确需求本身比看起来复杂。超时取消不是简单地把状态改成"已取消",它是一组必须原子完成的动作:关闭订单、释放预占库存、返还优惠券、记录流水。任何方案讨论之前,这条不变量要先立住——后面会看到,它直接决定了方案能不能用。
一、方案一:定时任务扫描表
最朴素的实现:每分钟跑一次,把"待支付且已超时"的订单捞出来关掉。
-- orders 表上必须有 (status, created_at) 的联合索引
SELECT id FROM orders
WHERE status = 'UNPAID' AND created_at < NOW() - INTERVAL 30 MINUTE
LIMIT 200;func CloseTimeoutOrders(ctx context.Context, db *sql.DB) error {
rows, err := db.QueryContext(ctx, `
SELECT id FROM orders
WHERE status = 'UNPAID'
AND created_at < ?
LIMIT 200`, time.Now().Add(-30*time.Minute))
// ...逐笔在事务里执行关闭+释放库存+退券
}注意两点工程细节。一是 LIMIT 分批:全量捞超时订单在大单量下会锁一堆行,分批处理。二是状态推进要用条件更新兜底:
res, err := tx.ExecContext(ctx, `
UPDATE orders SET status = 'CLOSED_TIMEOUT'
WHERE id = ? AND status = 'UNPAID'`, orderID)
// RowsAffected == 0:说明并发下已被支付或已被别处关闭,直接跳过这一句 AND status = 'UNPAID' 是关键防线。用户恰好在第30分钟支付成功、扫描任务同时发起取消——条件更新保证"支付"和"取消"只有一个赢,不会出现钱收了订单却被关了的资损。
这个方案的优点是零额外组件、逻辑直白、可重入可补偿(扫漏了下轮再扫)。代价是时效性:精度取决于扫描间隔,间隔1分钟则平均延迟30秒、最坏1分30秒。对"30分钟超时"这种量级完全够用;对"5秒内必须释放"的场景不够用。
二、方案二:Redis Key过期监听——一个经典的坑
思路很诱人:下单时往Redis写一个 order:timeout:{id} 的key,TTL设30分钟,监听key过期事件触发取消:
rdb.Set(ctx, fmt.Sprintf("order:timeout:%d", orderID), 1, 30*time.Minute)
// 订阅 __keyevent@0__:expired 频道,收到事件即触发关闭必须直接说结论:这个方案不能单独用于生产,最多只能当"加速器"用。原因有三:
所以它的正确用法只有一种:作为定时扫描的补充,让大部分订单按时关闭、降低扫描延迟,同时保留方案一作为兜底。丢了的事件由扫描任务捡回来,两者都执行时靠上面的条件更新保证幂等。
三、方案三:消息队列延迟消息
下单成功后发一条延迟30分钟的消息,到期由消费者执行关闭:
// 以支持延迟级别的MQ为例;不同MQ延迟机制不同,语义一致
msg := NewMessage("order_timeout", OrderTimeoutMsg{OrderID: orderID})
msg.WithDelay(30 * time.Minute)
err := producer.Send(ctx, msg)func HandleTimeout(ctx context.Context, m OrderTimeoutMsg) error {
// 消费端同样必须用条件更新,且整个关闭逻辑幂等
return closeOrderIfUnpaid(ctx, m.OrderID)
}延迟消息到期后,消费者拿订单ID回查状态:已支付则直接ACK跳过,未支付才执行关闭。消费端必须假设消息会重复投递(至少一次投递是常态),所以关闭逻辑幂等不是加分项而是必需品——和方案一一样,条件更新就是幂等防线。
这个方案时效精确(秒级)、单量再大也没有扫描压力,是目前中大型交易系统的标准做法。代价同样真实:引入MQ组件的运维成本,以及延迟消息自身的边界——很多MQ的延迟精度、最长延迟时间、延迟消息堆积能力都有限制,选型时要看清文档里的这些数字,而不是默认"想延迟多久都行"。
四、三个方案怎么选
按系统阶段对号入座:
定时扫描:日单量十万以下、超时分钟级,它就是正确答案。简单、可靠、可补偿,不要提前引入MQ。
Redis过期监听:不要单独用。可以作为扫描方案的加速层,把时效从分钟级改善到秒级,但必须有扫描兜底。
延迟消息:单量大到扫描有压力(高频扫描开始影响线上),或对关闭时效有秒级要求(秒杀库存快速释放、票仓回滚),再上MQ。同时建议保留低频扫描(如每10分钟)作为对账兜底——消息可能堆积、消费者可能挂,扫描是最后一道防线。
还有一个容易忽略的正交问题:支付回调和超时取消的竞态不只发生在取消侧。第三方支付回调本身也可能迟到(用户付了钱,订单已被关闭,回调才到)。这要求支付成功回调也做状态机校验:遇到"已关闭"的订单不能简单置为已支付,要进入退款或人工处理流程。状态机的每个迁移都校验前置状态,这比选哪个延迟方案更能决定系统会不会出资损。
写在最后
订单超时取消的本质是一个"延迟触发的状态迁移",三个方案的区别只在触发器:时钟、Redis TTL、MQ延迟。真正保证正确性的不是触发器,而是两件所有方案通用的事——条件更新防并发、幂等设计防重复。触发器可以随单量增长升级,这两件事从第一天就要做对。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。