【面试场景题】订单超时自动取消功能如何设计
文章目录
- 一、核心需求与边界
- 二、技术方案对比与选型
- 方案1:定时任务扫描(适合中小规模)
- 方案2:延迟队列(适合中大规模)
- 方案3:Redis过期回调(轻量级方案)
- 三、推荐方案:消息队列延迟消息(高可靠、高并发场景)
- 1. 核心流程
- 2. 关键设计细节
- (1)防止重复取消(幂等性)
- (2)资源释放的一致性
- (3)用户主动操作的拦截
- (4)失败重试机制
- 3. 性能优化
- 四、总结
订单超时自动取消是电商、外卖等系统的核心功能(如“下单后15分钟未支付自动取消”),其设计需满足时效性(按时取消)、可靠性(不遗漏订单)、性能(不影响主流程)和数据一致性(释放库存、优惠券等资源)。以下是具体设计方案:
一、核心需求与边界
- 触发条件:订单创建后,若在规定时间内未完成目标操作(如支付、确认收货等),则自动取消。
例:电商订单“待支付”状态超过15分钟 → 自动取消;外卖订单“商家未接单”超过5分钟 → 自动取消。- 核心动作:
- 更改订单状态(如“待支付”→“已取消”)。
- 释放关联资源(如回滚库存、恢复优惠券/积分、解除用户限购等)。
- 通知用户(短信、APP推送等)。
- 约束:
- 不能提前取消(避免误操作)。
- 不能漏取消(否则占用资源)。
- 取消逻辑需幂等(防止重复执行)。
二、技术方案对比与选型
根据业务规模(订单量、并发量)和可靠性要求,常见方案如下:
方案1:定时任务扫描(适合中小规模)
原理:通过定时任务(如Quartz、Spring Scheduler)周期性扫描数据库中“超时未处理”的订单,执行取消逻辑。
实现步骤:
- 订单表设计时增加
create_time
(创建时间)和status
(状态)字段。- 定时任务每N分钟执行一次,查询满足条件的订单:
-- 例:查询15分钟前创建、状态为“待支付”的订单
SELECT id FROM `order`
WHERE status = 'PENDING_PAY' AND create_time < NOW() - INTERVAL 15 MINUTE;
- 对查询到的订单,批量执行取消逻辑(更新状态+释放资源)。
优点:实现简单(无需额外中间件),适合订单量小(日均<10万)的场景。
缺点:
- 时效性差:延迟取决于扫描周期(如每5分钟扫一次,最大延迟5分钟)。
- 性能风险:订单量大时,全表扫描(即使有索引)会占用数据库资源,影响主流程。
方案2:延迟队列(适合中大规模)
原理:订单创建时,将订单ID放入“延迟队列”,队列会在超时时间后自动弹出订单ID,触发取消逻辑。
常见实现:
- Java DelayQueue:内存级延迟队列(基于优先级队列),元素需实现
Delayed
接口(定义过期时间)。- Redis ZSet:利用ZSet的“score”存储超时时间戳,通过定时任务扫描score≤当前时间的元素(即过期订单)。
- 消息队列延迟消息:如RabbitMQ(延迟交换机)、RocketMQ(定时消息)、Kafka(通过时间轮实现),发送消息时指定延迟时间,到期后消费。
以RabbitMQ为例的实现:
- 订单创建后,发送一条延迟15分钟的消息(消息体为订单ID)到延迟交换机。
- 消息到期后,被“订单取消消费者”接收。
- 消费者执行取消逻辑:检查订单状态(若已支付则忽略)→ 更新状态 → 释放资源。
优点:
- 时效性好:延迟时间精确到秒级(取决于中间件精度)。
- 性能优:异步处理,不阻塞主流程;消息队列支持分布式,可水平扩展。
- 可靠性高:消息持久化后,服务重启不丢失(避免漏取消)。
缺点:需维护中间件(如RabbitMQ),实现稍复杂;需处理消息重复消费(保证幂等性)。
方案3:Redis过期回调(轻量级方案)
原理:利用Redis的
key过期事件
,订单创建时在Redis中设置一个key(如order:timeout:1001
),过期时间为超时时间(如15分钟);当key过期时,Redis触发回调通知业务系统,执行取消逻辑。实现步骤:
- 开启Redis的key过期通知(需在redis.conf中配置
notify-keyspace-events Ex
)。- 订单创建时,执行
SET order:timeout:{orderId} 1 EX 900
(过期时间15分钟)。- 业务系统启动一个Redis订阅者,订阅
__keyevent@0__:expired
频道,接收过期事件。- 收到事件后,解析出orderId,执行取消逻辑。
优点:轻量级(无需消息队列),适合中小规模、对时效性要求不极致的场景。
缺点:
- Redis过期事件是“异步通知”,存在延迟(尤其是内存紧张时,Redis可能延迟清理过期key)。
- 不保证100%送达(若订阅者离线,过期事件会丢失)。
三、推荐方案:消息队列延迟消息(高可靠、高并发场景)
在中大规模业务中(日均订单>10万),推荐使用消息队列的延迟消息方案,结合以下设计保证可靠性:
1. 核心流程
订单创建 → 校验参数 → 保存订单(状态:待支付)→ 发送延迟15分钟的“取消订单”消息 → 返回给用户↓
(15分钟后)消息到期 → 消费者接收消息 → 检查订单状态(是否仍为待支付)→ 是→执行取消逻辑(更新状态+释放资源)→ 通知用户→ 否→忽略(如用户已支付)
2. 关键设计细节
(1)防止重复取消(幂等性)
- 状态机控制:订单状态流转需严格校验,只有“待支付”状态可被取消(避免已支付/已取消的订单被重复处理)。
例:更新订单时加条件:
UPDATE `order` SET status = 'CANCELED', cancel_time = NOW()
WHERE id = {orderId} AND status = 'PENDING_PAY';
- 幂等标识:给每条延迟消息增加唯一ID(如
order:cancel:1001
),消费时先检查Redis中是否已处理,避免重复执行:
// 消费前检查
if (redis.setIfAbsent("order:cancel:processed:" + orderId, "1", 24, HOURS)) {// 未处理过,执行取消逻辑
} else {// 已处理,直接返回
}
(2)资源释放的一致性
取消订单时需释放关联资源(如库存、优惠券),需保证这些操作的原子性:
- 本地事务:若资源与订单在同一数据库,用
@Transactional
包裹订单更新和资源释放(如扣减的库存回滚)。- 分布式事务:若资源在不同服务(如库存服务、优惠券服务),用TCC或可靠消息最终一致性方案:
- 例:调用库存服务的“恢复库存”接口,若失败则重试(配合重试队列),确保最终释放。
(3)用户主动操作的拦截
若用户在超时前完成支付,需及时取消延迟消息,避免后续误取消:
- 方案:支付成功后,向消息队列发送“取消延迟消息”的指令(如RabbitMQ可通过
channel.basicCancel()
取消消费,或标记消息为“无效”)。- 兜底:即使延迟消息未被取消,消费时通过“状态检查”(订单已支付)也会忽略,保证最终正确性。
(4)失败重试机制
若取消逻辑执行失败(如数据库宕机),消息队列需支持重试:
- 配置消息重试次数(如3次),失败后放入“死信队列”,由人工介入处理(避免订单永久未取消)。
3. 性能优化
- 批量处理:对低优先级的订单(如非秒杀场景),可批量发送延迟消息,减少网络交互。
- 分区隔离:将订单按用户ID或订单ID哈希分片,消息队列按分区消费,避免单消费者压力过大。
- 异步通知:用户通知(短信/推送)通过异步线程池处理,不阻塞订单取消的主流程。
四、总结
- 中小规模/简单场景:优先用“定时任务扫描”(实现简单)或“Redis过期回调”(轻量)。
- 中大规模/高可靠场景:必须用“消息队列延迟消息”,结合状态机、幂等设计、重试机制保证可靠性。
- 核心原则:“最终一致性”优先(允许短暂延迟,但不能漏取消),不阻塞主流程(订单创建、支付等核心操作必须快速响应)。