返回资源中心

订单自动关单设计:支付回调与库存释放的一致性

技术文章围绕订单过期自动关单,拆解到期触发、支付竞争裁决、库存与优惠券释放、延迟消息、补偿对账和故障恢复。小红学堂更新于 2026年8月29日60 次阅读

电商平台中,订单未支付过期如何实现自动关单?

阅读说明与事实边界:这不是“选哪种延迟队列”的单选题

订单自动关单的表面需求是:用户下单后 30 分钟仍未支付,系统自动取消订单。

真正困难的是,30 分钟到达那一刻,系统看到的“未支付”未必代表用户没有付钱:

  • 用户可能已经在支付平台完成付款,但回调还在路上;
  • 回调已经到达支付服务,订单服务尚未消费支付成功事件;
  • 支付请求超时,本地不知道第三方最终结果;
  • 关单任务和支付回调同时更新订单;
  • 订单关掉后,库存、优惠券、积分、运费额度等资源只释放了一部分;
  • 延迟消息丢失或任务执行失败,订单一直占着库存。

所以完整答案必须同时设计三部分:

  1. 到期触发:怎样在到期附近唤醒订单;
  2. 关单裁决:谁有权把订单从待支付变成已关闭,如何处理支付竞争;
  3. 资源收敛:关单后怎样可靠释放库存和优惠券,漏执行时怎样发现和补偿。

本文使用一个普通电商订单作为设计案例:订单创建时预占库存和优惠券,支付有效期 30 分钟,支持微信或支付宝等外部支付渠道。30 分钟只是案例规则,不同商品、渠道和活动可以使用不同的 expire_at

一、项目基础:先明确业务边界

1.1 为什么订单需要过期

待支付订单不是一行无害数据,它占用了真实稀缺资源:

  • 商品可售库存被预占;
  • 用户优惠券被锁定;
  • 活动名额或限购额度被占用;
  • 商家履约容量可能被预留;
  • 订单列表、客服和财务系统都要处理它的状态。

如果用户一直不支付又不关单,恶意用户可以通过批量下单锁死库存,正常用户看到有货却买不到。

1.2 关单的产品口径

设计前需要确认:

问题本文采用的口径
何时开始计时订单事实成功落库时生成 expire_at
以谁的时间为准服务端数据库时间,不信任客户端时间
到期一刻是否必须立刻关闭允许小幅调度延迟,但到期后不再发起新支付
用户已付款但回调迟到主动查询支付渠道,再决定保留订单或关闭
关单后又确认资金成功进入异常支付处理,通常原路退款,不直接复活订单
部分支付、组合支付需要独立支付单状态机,不能只看订单字段
取消后能否恢复原券按优惠券规则处理,不能假设所有券都可恢复

“到期后不再发起新支付”与“到期后立即认定没付钱”不是同一件事。前者可以由支付入口拦截;后者必须结合支付事实判断。

1.3 系统要守住的不变量

  1. 一个订单最多有一个最终业务结局:支付成立或关闭,不允许同时成立。
  2. 已经确认支付成功的订单不能被普通超时任务关闭。
  3. 未确认支付结果时,不能只凭本地待支付状态释放全部资源。
  4. 关单、库存释放、优惠券退回都必须幂等。
  5. 延迟消息只是提醒器,不是订单是否过期的真相源;真相是 expire_at 和当前状态。
  6. 所有最终状态都能由订单、支付单、资源流水和外部渠道记录对账得到。

二、四个对象、四套状态,不要挤在一个 status 里

2.1 订单状态

引入 CLOSING 是为了表达“任务已经开始裁决,但结果尚未确定”。如果只允许 PENDING_PAYMENT -> CLOSED 一次更新,任务为了查询外部渠道而持有数据库锁会太久;如果先释放锁再查渠道,又没有状态阻止新的支付动作。

2.2 支付单状态

订单状态表达交易业务结局,支付单状态表达资金渠道事实。二者关联但不能合并:一个订单可能有多次支付尝试,也可能在本地订单关闭后才收到渠道成功事实。

2.3 库存预占和优惠券锁定

资源服务不应该通过“收到消息次数”增减库存,而应通过“这笔预占从哪个状态变到哪个状态”处理。重复收到关单事件时,RESERVED -> RELEASED 只能成功一次。

2.4 关单任务状态

延迟消息中间件可以替代部分任务调度,但仍建议保留可查询的执行记录或事件日志,否则很难回答“某个订单为什么没有被关”。

三、核心数据模型

3.1 必要约束和索引

  • order_id 全局唯一;
  • 支付渠道通知用 channel + notify_id 唯一去重;
  • 渠道交易号在相同渠道内唯一;
  • 关单任务以 order_id 唯一,防止创建多个无主任务;
  • 资源预占以 order_id + resource_type + resource_key 唯一;
  • 扫描兜底索引建议覆盖 status + expire_at + order_id
  • version 或条件状态用于 CAS,不依赖先查后改。

3.2 哪些是真相源

数据角色
订单表的状态和 expire_at订单业务真相
支付单与渠道查询结果本地资金事实和外部资金证据
延迟消息、时间轮或 Redis ZSET到期提醒,不是最终事实
资源预占流水库存、券、积分是否真正释放的证据
Outbox 事件跨服务发布是否完成的可恢复证据

即使延迟消息重复、提前、迟到或丢失,消费者都必须回到订单真相判断,而不是看到消息就无条件关单。

四、下单时必须同时留下“未来要关单”的证据

4.1 创建订单链路

图中跨服务预占只是示例。实际可用 Saga、库存服务先预占后订单落库、或订单事件驱动资源预占。无论选择哪种,必须处理订单创建失败但资源已占用的补偿,不能只讨论关单那一刻。

订单和关单任务建议在同一本地事务中落库,或在订单 Outbox 中记录 ORDER_CREATED,由可靠投递器创建延迟消息。若先提交订单,再普通调用 MQ 发送延迟消息,中间宕机会产生“订单存在但永远没有到期提醒”的窗口。

4.2 expire_at 要固化在订单上

不能在任务执行时用“创建时间加当前活动配置”重新计算。活动方把支付窗口从 30 分钟改成 15 分钟后,老订单应遵守创建时固化的规则。订单至少保存:

created_at
expire_at
expiry_rule_version

调度系统使用自己的时间可以早到或迟到,消费者统一判断数据库中的 expire_at <= database_now

五、核心业务流程:到期提醒来了,完整关单流程是什么

5.1 第一阶段:领取关单裁决权

消费者收到订单 O1001 的到期提醒后,先检查:

  1. 订单是否存在;
  2. 当前是否仍为 PENDING_PAYMENT 或允许关闭的支付中状态;
  3. expire_at 是否真正到达;
  4. 是否已有另一个 Worker 取得关单权。

可以使用短事务条件更新:

UPDATE trade_order
SET status = 'CLOSING',
    version = version + 1
WHERE order_id = :order_id
  AND status IN ('PENDING_PAYMENT', 'PAYING')
  AND expire_at <= CURRENT_TIMESTAMP;

更新成功表示当前 Worker 获得裁决权;更新失败则重新读取订单:已支付和已关闭直接幂等结束,尚未到期则按剩余时间重新调度。

5.2 第二阶段:核实资金事实

不能仅查询本地订单,因为支付回调可能延迟。关单服务检查本地支付单:

  • 已有可信 SUCCEEDED:订单收敛到 PAID
  • 明确未发起或渠道确认失败:可以继续关单;
  • PROCESSING/UNKNOWN:主动查询支付渠道;
  • 渠道查询超时:不释放资源,进入短时重试或人工核查。

5.3 第三阶段:提交业务结局和 Outbox

订单状态和 ORDER_CLOSED Outbox 必须在同一本地事务中提交。否则可能出现订单已经关闭但释放事件没有发布,导致库存一直被占。

六、核心技术实现:支付回调正好撞上关单,谁赢

这是整道题最有价值的追问。

6.1 竞争矩阵

回调看到的订单状态渠道资金事实处理结果
PENDING_PAYMENT/PAYING成功且金额、商户、订单匹配CAS 到 PAID
CLOSING成功抢占或通知关单流程收敛到 PAID,禁止释放资源
CLOSED成功记录异常支付,发起退款,不静默复活订单
PAID重复成功回调幂等返回成功
任意状态签名或金额不匹配拒绝并告警

6.2 为什么关单后不直接复活订单

订单关闭后,库存和优惠券可能已经释放并被其他订单使用;履约、价格和促销资格也可能过期。简单把 CLOSED -> PAID 会制造超卖和权益冲突。

因此关单后才确认支付成功时,通常进入 REFUND_PENDING,核对真实入账后原路退款。如果业务允许保留交易,也必须重新校验库存、价格、促销和履约能力,并设计独立的异常支付恢复流程,不能改一列状态了事。

6.3 一次具体竞争时序

关键不是武断规定“回调优先”或“关单优先”,而是支付成功事实一旦被可信确认,就不能被普通关单覆盖;若业务资源已经按关闭结局释放,则资金走异常退款路径。

七、技术架构、设计取舍与演进:六种到期触发方案怎么选

7.1 数据库定时扫描

status + expire_at + order_id 分页扫描到期订单,多 Worker 使用分片或 SKIP LOCKED 领取。

优点是简单、可查询、不会依赖某条消息永久存在;缺点是扫描延迟和数据库压力。它很适合作为小规模主方案,也是所有规模下的兜底方案。

错误写法是每秒执行全表:

SELECT * FROM trade_order
WHERE expire_at < NOW();

正确方向是只扫终态前的窄索引、游标翻页、限制批次、分片调度,并记录领取状态。

7.2 单机 DelayQueue

适用于单实例工具或可丢失的临时任务,不适合作为电商订单唯一关单机制。实例重启、扩容迁移和内存压力都会影响任务;即使做本地持久化,也是在重新实现调度系统。

7.3 Redis 过期监听

Redis Key 过期通知不适合充当可靠业务事件:过期不保证恰好准时通知,订阅者离线期间可能错过通知,集群和配置也会影响行为。可以用于缓存清理提示,不能作为订单关单唯一证据。

7.4 Redis ZSET 延迟队列

score = expire_at 保存订单,Worker 读取到期元素并原子领取。它延迟较低、实现直观,但要自己处理持久化、主从切换、重复领取、分片热点、任务状态和死信。适合已有成熟封装且规模中等的团队。

7.5 MQ 延迟消息

创建订单时发送指定延迟级别或任意时间投递的消息。优点是削峰、消费扩展和重试体系成熟;限制取决于中间件:延迟精度、最大延迟、消息堆积、重平衡和成本都不同。即使 MQ 承诺可靠投递,也需要数据库扫描兜底,因为业务层还可能漏发或消费长期失败。

7.6 分布式调度平台或时间轮服务

把大量定时任务按时间桶分片,临近执行时加载到内存时间轮。适合多业务共享的大规模定时事件平台,但建设和运维成本最高。订单量没有达到瓶颈前,不应为了面试展示而自研。

7.7 推荐组合

常见稳妥组合是“延迟消息负责准时唤醒,数据库扫描负责发现遗漏”。两条触发路径都调用同一个幂等关单用例,不能各写一套状态更新逻辑。

八、资源释放不是一条消息 +1

8.1 库存释放

库存服务收到 ORDER_CLOSED 后,根据 reservation_id 做状态迁移:

UPDATE inventory_reservation
SET status = 'RELEASED', version = version + 1
WHERE reservation_id = :reservation_id
  AND status = 'RESERVED';

只有更新成功的那次操作才能把对应数量加回可售库存。重复消息看到 RELEASED 直接成功;若已是 CONFIRMED,说明订单支付链路已经确认扣减,必须告警并核查订单状态,不能释放。

8.2 优惠券退回

优惠券比库存更复杂:

  • 订单取消时活动是否仍有效;
  • 券是否已经过自然有效期;
  • 平台券和商家券是否都允许退;
  • 同一用户是否已经领取替代券;
  • 券退回后是否恢复原有效期。

因此关单事件只表达“订单已关闭,请按规则处理锁定资源”,不能强行要求所有资源都恢复为原始状态。优惠券服务应记录 LOCKED -> AVAILABLE/EXPIRED/VOID 的实际结局和原因。

8.3 并行释放与局部失败

订单已关闭不等于所有下游已经瞬时完成。管理后台应能看到哪些资源仍在处理,而不是只显示一个笼统的“取消成功”。

九、可靠性设计:消息提前、迟到、重复、丢失分别怎么办

9.1 提前到达

消费者读取订单的 expire_at。尚未到期则重新投递剩余延迟,或写回任务表的下一次执行时间。不能因为 MQ 时钟或延迟级别不精确就提前关单。

9.2 迟到

只要订单仍待支付且资金事实允许关闭,迟到消息仍可执行。业务 SLA 关注“到期后多久完成关闭”,而不是消息是否在毫秒级准点。

9.3 重复

条件状态更新和资源预占状态机保证幂等。消息 ID 去重只能减少重复计算,不能替代业务状态幂等,因为去重记录也可能过期或丢失。

9.4 丢失

兜底扫描查询 status in (...) and expire_at <= now 的订单,重新投递或直接调用关单用例。扫描水位、批次和最后成功时间必须可观测。

9.5 消费执行到一半宕机

订单状态已经是 CLOSING 时,新 Worker 不能永远跳过。记录 closing_started_at 和处理者租约;超过合理时长后,恢复任务重新核实支付状态并继续收敛。恢复不是直接改回 PENDING_PAYMENT,因为这可能重新开放支付入口。

十、分库分表后怎样扫描到期订单

10.1 不能跨所有订单分片做全局排序

订单按 user_idorder_id 分库后,全局扫描 expire_at 会很贵。可以选择:

  1. 每个订单分片独立维护到期索引和扫描 Worker;
  2. 创建订单时向集中式调度表写一条窄任务记录,任务表按时间桶分片;
  3. 主要依赖延迟消息,订单分片扫描只做低频抽查和补偿;
  4. 将未完成订单写入按分钟分桶的到期任务队列。

集中调度表只保存 order_id、shard_id、execute_at、status,不复制完整订单。Worker 根据 shard_id 路由到订单真相源再次判断。

10.2 任务分片和领取

批量领取要有租约、超时回收和最大重试,不要长时间持有数据库事务。不同订单互不影响,单个支付渠道超时不能阻塞整个批次。

十一、可观测、告警和人工处理

11.1 核心指标

指标说明
待支付订单数及最老年龄判断是否有漏关和整体积压
到期到关闭的 P50/P95/P99衡量调度与裁决延迟
延迟消息提前、迟到、重复数判断触发器质量
CLOSING 超时订单数发现渠道查询或 Worker 卡死
支付查询超时率和结果未知数判断资金风险
关单后异常支付数与退款积压关键资金告警
各资源释放失败数和最老年龄判断库存、券是否悬挂
兜底扫描发现量长期不应持续高企

11.2 管理后台需要什么

客服或运维查询一个订单时,应能看到:

  • 订单创建、到期、进入关单、最终关闭的时间;
  • 所有支付尝试及渠道查询证据;
  • 延迟任务和兜底任务的执行记录;
  • 库存、优惠券、积分等资源的当前状态;
  • 异常支付退款单及处理进度;
  • 可审计的人工重试、强制核查入口。

人工按钮不能直接把任意状态改成 CLOSED。它应调用同一业务用例,执行权限校验、支付核查、幂等和审计。

十二、对账怎样补上最后一道保险

12.1 订单和支付对账

按渠道账单或主动查询结果寻找:

  • 渠道成功、本地订单未支付;
  • 本地订单已支付、渠道没有对应成功记录;
  • 金额、商户号或币种不一致;
  • 订单关闭后出现成功资金;
  • 退款任务与渠道退款结果不一致。

12.2 订单和资源对账

反向也要查:资源已经释放,但订单最终是 PAID,这是比“释放稍慢”更严重的不变量破坏,需要立即告警和人工处理。

十三、测试应该覆盖哪些竞争窗口

13.1 状态与幂等

  • 同一延迟消息重复消费多次,订单只关闭一次;
  • 消息提前到达,不提前关单;
  • 消息丢失,扫描任务能发现并关闭;
  • 多 Worker 同时关一个订单,只有一个取得 CLOSING
  • CLOSING Worker 宕机,租约过期后能恢复;
  • 重复 ORDER_CLOSED 事件不会重复增加库存。

13.2 支付竞争

  • 支付回调先提交,关单 CAS 失败并幂等结束;
  • 关单先进入 CLOSING,渠道查询发现已支付,收敛到 PAID
  • 渠道结果未知,订单保持保护状态,不释放资源;
  • 订单 CLOSED 后收到真实成功回调,创建且只创建一个退款任务;
  • 伪造签名、错误金额、错误商户号均被拒绝和告警。

13.3 故障演练

  • MQ 不可用时创建订单,Outbox 恢复后补发到期提醒;
  • 支付渠道超时,验证重试节奏和人工入口;
  • 库存服务不可用,验证订单已关闭但释放任务持续可见、可恢复;
  • 数据库主从切换,验证任务不会永久停在 CLOSING
  • 兜底扫描暂停一段时间,再恢复并测量清空积压所需时间。

容量测试需要真实包含订单索引、支付查询 Mock 延迟分布、资源事件和失败重试。没有测试报告时,只能说明架构设计能水平扩展,不能声称“已经支撑千万订单”。

十四、面试时怎样回答

14.1 两分钟主回答

订单创建时固化 expire_at,并在同一本地事务写订单、关单任务或 Outbox。主触发可以用 MQ 延迟消息,数据库按 status + expire_at 的分片扫描作为兜底。消息到达后不能直接关闭,而是用 CAS 把待支付订单推进到 CLOSING,核查本地支付单;结果未知时主动查询或关闭第三方渠道。确认未支付后,在本地事务把订单改成 CLOSED 并写 ORDER_CLOSED Outbox。库存和优惠券按预占流水做幂等状态迁移。支付回调与关单并发时,以可信资金事实和状态机收敛;如果订单已关闭且资源已释放后才确认付款,进入异常支付退款,不直接复活订单。延迟消息提前、重复、丢失分别靠到期校验、业务幂等和兜底扫描处理,最后通过支付与资源对账发现漏单。

14.2 高频追问

  1. 为什么 Redis 过期监听不可靠?它不是持久任务队列,订阅者离线可能错过,过期通知也不承诺精确定时。
  2. 有延迟消息为什么还要扫表?防止业务漏发、长期消费失败和配置错误,扫描是独立发现通道。
  3. 关单任务查询支付渠道超时怎么办?保留保护状态并重试,不能当未支付处理。
  4. 回调和关单都拿行锁不就行了吗?本地锁无法覆盖外部渠道的结果未知,而且不能长事务持锁等待远程调用。
  5. 为什么要 CLOSING?阻止新支付并表达裁决中,支持短事务和超时恢复。
  6. 关单后支付成功为什么不恢复订单?资源可能已被其他订单使用,直接复活会破坏库存和促销规则。
  7. 怎样保证库存只释放一次?以资源预占单做条件状态迁移,而不是每收到一次消息就加库存。
  8. 分库分表怎么扫描?各分片局部扫描或使用按时间分片的窄调度表,再路由回订单真相源核验。
  9. 如何证明没有漏关?看最老待支付订单、兜底扫描发现量,并做订单、支付和资源三方对账。

14.3 回答中不要说什么

  • “用了 MQ 就绝对不会丢消息”;
  • “Redis Key 过期会准时回调”;
  • “加个分布式锁就不会和支付冲突”;
  • “订单关闭后库存直接 +1”;
  • “系统支持多少 QPS”,却没有环境、数据分布和测试报告;
  • 把设计案例说成亲历的线上事故或生产成绩。

总结

订单自动关单不是一个定时器功能,而是一笔交易在资金事实、订单状态和资源状态之间的收敛过程。

成熟方案通常由三层组成:延迟消息或调度服务负责及时唤醒;订单状态机和支付核查负责做出唯一业务裁决;Outbox、幂等资源流水、扫描和对账负责在故障后恢复。只回答“用 RocketMQ 延迟消息”只能解释任务怎么醒来,回答不了为什么不会误关已付款订单,也回答不了库存和优惠券最终能否正确释放。