超时关单设计
2026/1/21大约 4 分钟约 1078 字
超时关单设计
适用场景:电商订单超时关闭、到期自动收货、超时自动退款等"到期触发型"业务
1. 设计目标
- 不资损:过期订单不能变成 PAID
- 最终一定关:触发丢失、消费失败、重启都能最终关掉
- 关单时效可控:减少"过期仍待支付"的时间窗口
- 后续动作可靠:库存/券/通知等可靠执行
2. 方案架构
┌─────────────────────────────────────────────────────────────┐
│ 角色定位 │
├─────────────────────────────────────────────────────────────┤
│ DB(订单表) → 真相源(status + expire_time) │
│ Redisson 延迟队列 → 准点触发器(加速关单) │
│ 定时任务 → 兜底保险(扫漏,保证最终一致) │
│ Outbox → 可靠事件(后续动作不丢) │
│ t_event_consumed → 消费幂等(防重复副作用) │
└─────────────────────────────────────────────────────────────┘核心闭环
- 下单:落库
UNPAID + expire_time→ 投递 Redisson 延迟任务 - 到期:Redisson 转移到 ready 队列 → worker 执行 CAS 关单
- 兜底:定时扫描
UNPAID && expire_time<=now补漏 - 后续:关单成功写 Outbox → 下游消费释放资源
3. 核心正确性规则
3.1 支付链路强校验(防资损底线)
-- 支付回调:仅当未过期且未支付才能更新为 PAID
UPDATE t_order
SET status='PAID', pay_time=NOW()
WHERE order_no=?
AND status='UNPAID'
AND expire_time > NOW();3.2 关单必须幂等(CAS)
-- 关单:条件更新,避免并发错乱
UPDATE t_order
SET status='CLOSED', close_time=NOW(), close_reason='TIMEOUT'
WHERE order_no=?
AND status='UNPAID'
AND expire_time <= NOW();- 影响行数=1 → 关单成功
- 影响行数=0 → 幂等忽略(已支付/已关闭)
4. 数据模型
订单表 t_order
| 字段 | 说明 |
|---|---|
| order_no | 订单号(唯一) |
| status | UNPAID/PAID/CLOSED |
| expire_time | 过期时间 |
| close_reason | TIMEOUT/USER_CANCEL |
关键索引:idx_status_expire(status, expire_time)
Outbox 表 t_outbox_event
| 字段 | 说明 |
|---|---|
| event_id | 全局唯一 ID |
| event_type | ORDER_PAID/ORDER_CLOSED |
| status | NEW/SENT/FAILED |
消费去重表 t_event_consumed
| 字段 | 说明 |
|---|---|
| consumer_name | 消费者名称 |
| event_id | 事件 ID |
唯一键:(consumer_name, event_id)
5. 流程详解
5.1 下单流程
@Transactional
public void createOrder(Order order) {
// 1. 写订单表
order.setStatus("UNPAID");
order.setExpireTime(LocalDateTime.now().plusMinutes(30));
orderMapper.insert(order);
// 2. 如果有需要异步通知的服务,此处也要写 outbox
outboxMapper.insert(new OutboxEvent("ORDER_CREATED", orderNo));
// 2. 锁资源(库存/券)
lockInventory(order);
}
// 事务提交后投递延迟任务
redissonDelayedQueue.offer(
new CloseTask(orderNo, expireTime),
30, TimeUnit.MINUTES
);5.2 Redisson 关单消费
// Worker 消费 ready 队列
CloseTask task = readyQueue.take();
// CAS 关单
int rows = orderMapper.closeOrder(task.getOrderNo());
if (rows == 1) {
// 写 Outbox 事件
outboxMapper.insert(new OutboxEvent("ORDER_CLOSED", orderNo));
}5.3 定时任务兜底
@Scheduled(fixedRate = 60000) // 每分钟
public void scanExpiredOrders() {
List<Order> expired = orderMapper.selectExpired(1000);
for (Order order : expired) {
closeOrderService.close(order.getOrderNo());
}
}定时任务注意点
- 控制扫描频率(如 1 分钟/次)和单批数量(如 500 条)
- 分库分表场景使用分片扫描,配合 XXL-Job 等分布式调度
- 限制单轮执行时长,防止积压导致任务重叠
- 确保扫描 SQL 走索引(idx_status_expire)
6. 后续动作(事件驱动)
ORDER_CLOSED 触发
- 释放库存
- 解锁优惠券
- 发送超时通知
ORDER_PAID 触发
- 创建发货单
- 发放权益/积分
- 支付成功通知
消费幂等模板
public void consume(Event event) {
// 1. 插入去重记录
try {
consumedMapper.insert(consumerName, event.getId());
} catch (DuplicateKeyException e) {
return; // 已处理过
}
// 2. 执行业务
doBusinessLogic(event);
}7. 失败场景与补偿
| 场景 | 兜底方案 |
|---|---|
| Redisson 任务投递失败 | 定时任务扫漏补 |
| 消费中宕机 | 定时任务扫漏补 |
| 支付回调晚到(已关单) | 自动退款 |
| Outbox 投递失败 | 重试 + 告警 |
| MQ 消息重复 | t_event_consumed 去重 |
8. 监控告警
- 关单延迟:
close_time - expire_timeP99 - 队列堆积:readyQ 长度
- Outbox 堆积:NEW 状态数量
- 晚到支付:支付成功但订单已关闭
9. 面试总结
一句话:DB 状态机为真相源,Redisson 准点触发,定时任务兜底,Outbox 保证后续动作,消费端幂等去重。
核心要点:
- 正确性:
status + expire_time为真相源,支付回调强校验 - 准点性:Redisson 延迟队列加速关单
- 最终一致:定时任务扫描补漏
- 后续可靠:Outbox 事件驱动
- 幂等: 有些资产的累加,例如积分,金额等,又比如调用第三方接口,这些接口没实现幂等,那么就调用两次,也不行,因此一些需要保证幂等。可以加一个t_event_consumed 消费去重 防止重复。
- 重复安全:CAS 更新 + 消费去重
