订单超时关闭
“下单后 30 分钟未支付就关闭”包含两条并发路径:到期任务尝试关闭订单,支付回调尝试确认付款。任务系统负责让检查得到执行,订单状态机决定哪一次业务变更能够提交。
下面使用 PostgreSQL 18 和数据库任务队列中的任务表。SQL 的 :参数名 由驱动绑定;订单表与支付接入属于应用现有模型,示例重点是事务与竞争条件。
从等待 30 分钟推导持久化检查
假设最初的实现是在下单进程中启动一个 30 分钟定时器。它可以表达等待,但进程在第 20 分钟重启后,内存里的等待就不再代表一份可靠待办。
| 候选方案 | 合适的条件 | 需要承担的成本 |
|---|---|---|
| 进程内计时 | 可以从持久化订单重新构建,且允许恢复延迟 | 重启重建与多实例协调 |
| 周期扫描待支付订单 | 允许一个扫描周期的处理延迟 | 到期索引、分批领取和补扫 |
| 持久化延迟任务 | 需要追踪每笔订单的检查 | 任务存储、重复处理与失效判断 |
本篇使用持久化检查任务,让下单提交时就保存未来待办。到期任务表达“检查这笔订单”,不表达“无条件关闭”:用户可能已付款,或者业务已改期。
付款与关闭并发时,应由订单事务决定本地状态转换;渠道已经发生的收款则由对账处理。仅用更精确的定时器解决不了这个业务冲突。
支付与关闭的竞争时序
图中的两条路径可能同时运行。并发执行块不规定谁先拿到行更新资格,本地结果由条件更新决定;实际收款事实仍需保留并核对。
图中的成功表示对应事务已经提交。释放库存、渠道通知和差异核对各有独立结果,不能用订单关闭成功替代它们。
明确业务期限
示例采用以下规则:
- 创建订单时保存
expires_at,不在每次重试时重新计算 30 分钟。 - 到期后拒绝新的本地支付发起请求,但已经在途的支付仍需核对渠道结果。
- 只有待支付订单能够关闭,确认支付也只能从允许的状态转入。
- 关闭后的迟到支付进入查询、退款或人工核对流程,具体由业务规则决定。
是否有支付宽限期、渠道何时算付款成功、能否主动关闭渠道交易,都需要在支付集成中另行确定。本地订单状态不能独自代表渠道最终状态。
数据模型与开发入口
| 记录 | 关键字段与约束 |
|---|---|
| 订单 | 用户、状态、关闭原因、金额币种、期限、到期版本;创建请求键唯一 |
| 支付尝试与流水 | 订单、渠道账户、交易身份、确认结果;来源交易身份唯一 |
| 库存预留 | 订单、预留单、数量、状态;释放只发生一次 |
| 关闭检查任务 | 订单与到期版本组成业务键,保存计划时间 |
| 后续动作 | 预留释放、通知和渠道差异处理分别记录结果 |
创建订单接口返回明确的待支付状态与期限。发起支付接口复核订单归属、状态与剩余期限;支付确认入口先验证渠道身份并持久化,不能由客户端“支付成功”的页面跳转直接确认订单。
查询接口返回本地已确认状态;关单任务、主动取消和支付回调复用订单状态转换逻辑。人工修复入口必须关联证据与原因,不直接无条件写 paid 或 closed。
本例将超时关闭和用户主动取消都归入 closed,以关闭原因区分;两者都只允许从待支付状态转换,并可靠保存后续释放动作。若业务另外定义 cancelled 状态,检查任务读到该状态时应记录 skipped,不能将正常取消归为状态异常。
从下单到关闭的执行分工
下单请求:
获取请求幂等资格
事务创建订单、库存预留与到期检查任务
支付请求:
复核当前待支付与期限,再按渠道流程创建支付尝试
支付确认:
可信事实去重,事务更新支付流水与允许变化的订单状态
到期 Worker:
核对领取资格、期限与到期版本,条件关闭
同事务保存释放预留与通知任务
独立 Worker:
按预留身份释放,按通知身份发送,按差异身份核对渠道本地预留位于同一数据库时可与下单原子提交;跨服务库存需要可恢复的预留确认与补偿流程,不能把三项写入画在一个事务里就假定跨服务也原子。
下单时同时创建检查任务
假设订单表至少有 id、status、expires_at、expiry_version、closed_at 和 close_reason。其中 expiry_version 仅在到期规则变化时增加。
BEGIN;
WITH created AS (
INSERT INTO orders (id, status, expires_at, expiry_version)
VALUES (
:order_id,
'pending_payment',
statement_timestamp() + interval '30 minutes',
1
)
RETURNING id, expires_at, expiry_version
)
INSERT INTO background_tasks (
task_type, business_key, payload, scheduled_at, available_at
)
SELECT
'close_expired_order',
id::text || ':expiry:' || expiry_version::text,
jsonb_build_object(
'order_id', id,
'expiry_version', expiry_version
),
expires_at,
expires_at
FROM created;
COMMIT;订单与任务在同一数据库事务中提交,可以避免“订单已创建,检查任务却未保存”。创建接口自身仍需要请求幂等,不能依赖任务唯一约束处理所有重复下单问题。
如果采用外部队列,先在同一事务写入 Outbox,再由投递器发送,见事件驱动与异步任务。
到期时用条件更新竞争
Worker 读取任务参数后,执行以下条件更新:
UPDATE orders
SET status = 'closed',
close_reason = 'payment_timeout',
closed_at = statement_timestamp()
WHERE id = :order_id
AND status = 'pending_payment'
AND expires_at <= statement_timestamp()
AND expiry_version = :expiry_version
RETURNING id;支付回调在完成来源校验、支付流水去重、金额与币种等业务校验后,也只能条件更新本地状态:
UPDATE orders
SET status = 'paid'
WHERE id = :order_id
AND status = 'pending_payment'
RETURNING id;这里表达的是数据库竞争规则,未包含完整支付事务。支付流水记录、订单变更及后续任务应一起设计;收到回调不能直接执行这一条语句就结束。
在 PostgreSQL 的 Read Committed 隔离级别下,这类针对同一行的并发更新会等待冲突事务,并在需要时对新版本重新检查 WHERE 条件,因此不会同时从待支付提交到两个不同状态。参见事务隔离说明。
这个保证限定在本地订单行。若渠道已经扣款,而关闭事务先提交,支付回调仍可能到达一个已关闭的订单,需要进入后续核对流程。
这条关闭语句中的三个条件承担不同职责:status 阻止覆盖已支付结果,expires_at 阻止提前关闭,expiry_version 阻止旧计划关闭改期后的订单。先在应用中查询一次,再去掉这些写入条件,无法保留同样的并发约束。
更新不到记录时如何收尾
不能把受影响行数为 0 一概记为成功或失败,需要重新读取当前状态:
| 当前事实 | 检查任务如何处理 |
|---|---|
| 已支付 | 记录 skipped,不再关闭 |
| 已关闭,包含本例的用户主动取消 | 视为不再待支付,幂等完成;不覆盖原关闭原因 |
| 到期版本已变化 | 记录 skipped,确认新版本已有对应任务 |
| 仍待支付但尚未到期 | 校正下次可执行时间,不忙循环 |
| 订单缺失或状态不符合预期 | 记录异常,依据数据保留规则核对 |
如果订单允许改期,应在同一事务更新期限、增加 expiry_version 并保存新检查任务。旧任务即使已经入队,也会因版本不匹配退出。
关闭与后续动作的事务
关闭订单后往往还需要释放库存预留、通知用户。外部动作放在数据库事务之外,事务内保存持久化意图:
BEGIN
校验并锁定本次任务的领取 token 和有效租约
条件关闭订单,或读取当前状态决定 skipped
如果本次确实关闭:
创建 release_reservation 任务
创建 notify_order_closed 任务
更新本次检查任务为 succeeded 或 skipped
如果所有权或结果校验失败:ROLLBACK
COMMIT这是应用事务的伪代码。实现时要统一任务行与订单行的加锁顺序,并处理死锁或序列化失败后的事务重试。
释放库存任务应携带预留单标识,以预留状态做条件释放;重复执行不能无条件增加可售库存。通知任务使用稳定业务键,下游是否支持发送幂等则按外部副作用的边界处理。
对账与兜底
兜底扫描可以发现到期仍待支付的订单,以及缺少检查任务的订单。补建时复用订单与到期版本组成的业务键,避免扫描本身制造重复动作。
渠道支付状态需要独立核对。对“本地已关闭、渠道已付款”这样的差异,记录可追踪的处理单,再执行约定的退款或人工流程;不能把它当成普通关闭任务继续重试。
登录检查与批量到期
用户登录或打开订单页时,可以根据期限刷新展示;访问支付发起接口时也要校验订单状态与有效期。后台关闭任务仍然独立运行,覆盖不再打开订单页的用户,避免预留库存长期无法回收。
例如 1000 笔订单同一时刻到期,扫描器每批发现 100 笔,Worker 按空闲槽位领取,各订单独立执行条件更新。实际批次与并发是容量配置样例,不是必须把 1000 笔放进同一事务。
每个有效关闭事务分别保存释放预留与通知意图。一笔订单的通知失败,不回滚已完成的关闭;释放库存失败按预留单独重试,不能把整个批次的库存再次加回。
通知消费者在发送前重新核对订单状态,避免渠道差异或后续修复已经改变结果时仍发送过时文案。发送侧采用全局渠道额度,通知积压不会让过期订单重新获得发起支付的资格。
如果选择读取时惰性关闭,仍应复用同一状态转换事务和幂等键。后台扫描与请求路径都可能命中同一订单,不能各写一套无条件更新。
一次支付与关闭竞争的记录演变
假设订单 O81 在 10:30 到期,预留单为 R81:
| 操作顺序 | 本地结果 | 后续动作 |
|---|---|---|
| 10:30 关闭事务先获得更新资格 | pending_payment 转为 closed | 同事务保存释放 R81 与通知待办 |
| 支付回调随后到达,渠道报告收款 | paid 条件更新不到待支付行 | 保存付款事实,建立渠道差异处理 |
| 释放任务成功,确认响应丢失 | R81 已经从 reserved 转为 released | 再次执行读取原释放结果 |
| 关闭任务重复到达 | 订单仍是 closed | 幂等收尾,不再释放一次库存 |
如果支付事务先提交,则关闭条件不再满足,检查任务按已支付分支退出。两种结果都要有明确定义,不能只验证“定时器按时响了”。
这个推演也说明业务键为什么分开:O81 的关闭检查、R81 的释放、渠道交易的核对,是三个不同动作,各自需要可恢复的结果。
业务验证场景
以下是实现后值得验证的并发和故障场景:
| 场景 | 应观察到的结果 |
|---|---|
| 同一检查任务重复投递 | 订单只发生一次有效关闭 |
| 关闭与支付回调同时到达 | 本地状态条件更新互斥,渠道差异可追踪 |
| 关闭事务提交前 Worker 崩溃 | 业务与任务确认一起回滚,可再次处理 |
| 租约过期后旧 Worker 恢复 | 旧尝试不能覆盖新任务状态 |
| 订单改期后旧检查到达 | 旧版本跳过,新版本仍有检查任务 |
| 通知已发送但确认丢失 | 按下游幂等或核对规则恢复 |
| 创建订单成功但响应丢失 | 重复请求返回同一订单,不重复占用库存 |
| 释放预留成功但任务确认失败 | 再次执行不额外增加库存 |
| 支付尝试在期限前发起、回调在关闭后到达 | 保存渠道事实并按既定规则核对,不丢弃已发生的收款 |
记录有效关闭时间减原到期时间、最老未释放预留年龄、支付与关闭冲突数、渠道差异未解决年龄。订单关闭成功和库存释放成功分别验收。
阿里云定时消息方案中的业务边界
阿里云的 RocketMQ 5.x 定时消息文档把订单超时检查列为典型场景,并明确说明:已发送的定时消息不能在到时前撤回或修改时间;大量消息定在同一时刻,可能造成分发延迟。这是产品能力与接入方案说明,并非某家电商的完整关单事故复盘。
对应到本文,用户付款后无需以“删除旧消息成功”作为付款成功条件。到时消息仍可到达,消费者依据订单当前状态退出;订单允许延长期限时,则更新业务期限和版本、可靠保存新检查计划,旧计划靠版本校验退出。定时消息只负责唤起检查,支付与关闭的互斥仍由业务事务保证。
同一场活动结束时集中到期,要同时观察到期消息的投递延迟、消费积压和关单完成延迟。允许缓冲的提醒可以错峰,合同约定的订单期限应保持原值,消费者不能靠随机延长期限掩盖容量不足。以上是基于产品约束对本文方案的设计推导。
腾讯大规模延迟消息:容量与消费进度
腾讯云的 TDMQ Pulsar 大规模延迟消息方案进一步讨论了到期时间分布不均带来的消费空洞、延迟索引内存压力与重载后的重复消费风险,并介绍外部多级时间轮索引。这里解决的是等待期间的存储与消费进度问题,与前文“同一时刻大量订单需要关单”的执行压力是两个维度。
对本文订单方案,可以分别压测长时间未到期的消息积累、促销结束时集中到期,以及消费者重启后的重投。持有未来一个月的订单检查,不代表现在已经形成到期积压;相反,待处理数量看似不大,也可能存在少量长期未关闭订单。应同时保留到期积压年龄和关单完成延迟。
该文档还明确其定时与延时消息不支持 producer 的 batch 模式。业务层分页创建 1000 个检查任务,与 SDK 把多条消息编码成一个发送批次并非同一件事;选用这一产品时,应按其 SDK 约束发送,消费者仍按订单身份幂等处理。不能因队列改进了进度管理,就省略支付与关闭的条件更新。
参考资料
腾讯云官方实践:Pulsar 大规模延迟消息
阿里云官方文档:RocketMQ 5.x 定时/延时消息
PostgreSQL 18:Read Committed Isolation Level
AWS Prescriptive Guidance:Transactional outbox pattern