day04
场景题:电商平台订单过期未支付过期如何实现自动关单
订单自动关单流程图(Redisson 延迟队列版)
用户下单成功
│
▼
订单状态 = 待支付
│
▼
Redisson 延迟队列.offer(订单ID, 30分钟)
│
▼
返回用户:请在30分钟内完成支付
│
├──────────────┐
│ │
▼ ▼
用户支付成功\] \[30分钟到期,Redisson自动弹出订单ID
│ │
▼ ▼
更新订单状态 = 已支付\] \[消费线程监听弹出订单ID
│ │
│ ▼
│ [查询订单状态]
│ │
│ ├─[状态=待支付]──►[执行关单业务]
│ │ │
│ │ ▼
│ │ [释放库存]
│ │ │
│ │ ▼
│ │ [更新订单状态=已关闭]
│ │ │
│ │ ▼
│ │ [发送关单MQ/通知]
│ │
│ └─[状态≠待支付]──►[忽略,结束]
│
▼
流程结束

订单自动关单实现代码(Redisson 延迟队列版)
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.23.4</version>
</dependency>
@SpringBootApplication
public class RedissonDelayApp {
public static void main(String[] args) {
SpringApplication.run(RedissonDelayApp.class, args);
}
@Bean
public RedissonClient redisson() {
// 单节点、哨兵、集群都支持,这里演示单节点
Config cfg = new Config();
cfg.useSingleServer().setAddress("redis://127.0.0.1:6379");
return Redisson.create(cfg);
}
}
/**
* 生产端:订单落库后把订单ID塞进延迟队列
*/
@Service
class OrderService {
@Resource
private RedissonClient redisson;
private static final String DELAY_QUEUE = "orderDelayQueue";
@Transactional
public Long createOrder(CreateOrderCommand cmd) {
// 1. 落库
Order order = new Order();
order.setStatus(OrderStatus.UNPAID);
orderMapper.insert(order);
// 2. 计算延迟时间(毫秒)
long delay = 30 * 60 * 1000;
// 3. 获取延迟队列并投递
RDelayedQueue<Long> delayedQueue = redisson.getDelayedQueue(DELAY_QUEUE);
delayedQueue.offer(order.getId(), delay, TimeUnit.MILLISECONDS);
return order.getId();
}
}
/**
* 消费端:单线程即可,Redisson 会保证弹出顺序
*/
@Component
class CloseOrderWorker {
@Resource
private RedissonClient redisson;
@Resource
private OrderService orderService;
@PostConstruct
public void start() {
RDelayedQueue<Long> delayedQueue = redisson.getDelayedQueue("orderDelayQueue");
// 无限阻塞 take(),重启后自动续消费
new Thread(() -> {
while (true) {
try {
Long orderId = delayedQueue.take();
orderService.closeIfUnpaid(orderId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}, "close-order-worker").start();
}
/**
* 幂等关单:只让一条线程真正关闭订单
* @return 是否成功关闭
*/
@Transactional(rollbackFor = Exception.class)
public boolean closeIfUnpaid(Long orderId) {
// 1. 加行锁并判断状态
Order order = orderMapper.selectByIdForUpdate(orderId);
if (order == null || order.getStatus() != OrderStatus.UNPAID) {
return false; // 已支付/已关闭/不存在 → 直接丢弃
}
// 2. 再次 CAS 更新,双重保险
int rows = orderMapper.updateStatus(
orderId,
OrderStatus.UNPAID, // 期望旧值
OrderStatus.CLOSED); // 新值
if (rows == 0) { // 并发线程已抢先
return false;
}
// 3. 发送领域事件(事务提交后)
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
eventBus.post(new OrderClosedEvent(orderId));
}
});
return true;
}
}
面试一句话总结
Redisson 延迟队列 = RDelayedQueue 封装 Redis zset + lua 脚本,30 min 后弹出来,重启不丢,代码量最少,中小系统首选
关键点解释
-
行锁范围
FOR UPDATE只锁主键行,不会锁表 ,并发度最高;必须走唯一索引/主键,否则 Next-Key Lock 会扩大范围导致性能骤降。
-
事务边界
整个方法用
@Transactional包起来,保证-
锁在事务提交时才释放;
-
事件
afterCommit()只在数据库修改成功后再投递,防止"消息出去了,库没改成"。
-
-
双重校验
先
for update再update where status=UNPAID:-
第一道门:行锁挡住并发;
-
第二道门:
rows==0兜底,避免"幻读"导致重复关单。
-
-
性能
单条记录加锁耗时 < 1 ms(SSD,索引命中),1 个实例 1 线程可轻松扛 1k+ TPS ;
若多实例部署,Redis 延迟队列天然竞争,不会有两个线程同时处理同一订单。
-
失败策略
返回
false即表示"已有人处理",线程直接丢弃,无需重试 ;如果更新成功但后续业务异常(如发 MQ 失败),事务回滚,订单状态回滚为
UNPAID,消息不会出去,可依靠延迟队列再次到期重推。
四、面试一句话版本(背下来)
"消费线程先 select ... for update 对订单加行锁,发现状态不是待支付直接返回;
再执行 update ... where status=待支付 把状态改成已关闭,影响行数=0 说明被别的线程抢先,立即丢弃 ;
整个方法在本地事务里,提交后才发关单事件,既防并发又保证消息不重不漏。"
【star 法则 + 总-分-总结构】回答模板
(时长≈2-3 分钟,可随问随拆)
-
总述
"我们采用'Redisson 延迟队列 + 定时任务兜底'两级方案,兼顾了实时性、可靠性与实现成本,线上 8 个月稳定运行,订单量 120w/天,关单延迟 p99 在 1.2 秒以内。"
-
分步拆解
"思路分四步:选方案、防重、保最终一致、兜底。"
① 选方案------为什么不是定时扫、RocketMQ?
-
定时扫:db 压力指数级上涨,且分钟级延迟不可接受。
-
RocketMQ:延迟消息只支持 18 个固定等级,无法做到任意 30 min;且当时团队无 mq 运维经验。
-
Redisson 延迟队列:基于 redis zset + lua 脚本,支持任意延迟、高性能、重启不丢,接入成本 1 小时,所以优选。
② 防重------幂等关单
"消费线程拿到订单号后,先 select for update 判状态=待支付,再 update 状态=已关闭 where 状态=待支付;影响行数=0 直接丢弃,避免并发重复关单。"
③ 最终一致------库存与营销资源回收
"关单成功后发一条'OrderClose'mq,下游库存、优惠券、积分各自监听并做反向回滚,消息采用'at least once + 幂等表'保证不脏写。"
④ 兜底------定时任务补偿
"每天凌晨跑一次 3 分钟批,扫描前一日'待支付且 create_time < now-35min'的订单;这批是极端情况(redis 宕机、消费线程 full-gc)漏掉的,量很小,日常 50 单以内。"
-
结果 & 数据
"上线后关单延迟从原来定时任务的 5~10 分钟降到 1 秒级;大促峰值 6k 单/秒,redis cpu 峰值 12%,无重关、无漏关;曾模拟 redis 宕机 15 分钟,兜底任务全部补偿完毕,数据零差错。"
-
亮点补充(面试官爱问的"坑")
-
jvm 重启不丢消息:Redisson 的 queue 持久化到 redis,线程重启后自动续消费。
-
redis 主从切换:用 redisson 的哨兵模式,主从切换 3 秒完成,消费线程重连后自动续跑。
-
平滑扩容:延迟队列是 redis 单 key,后期通过'订单号取模分 8 个 bucket'水平拆分,性能线性提升。
【一句话收束】
"总结就是:用 Redisson 延迟队列扛实时流量,定时任务做最后一道闸门,两者互补,让订单关单既快又稳。
二、整体逻辑细节(逐层拆解)
-
触发时机
@PostConstruct 保证应用启动完成且单例 Bean 初始化后立刻执行,无需人工触发。
-
延迟队列原理
Redisson 的 RDelayedQueue 基于 Redis zset + 定时任务:
-
调用
delayedQueue.offer(orderId, 30, TimeUnit.MINUTES)时,订单 ID 被放入 zset,score=当前时间+延迟毫秒。 -
Redisson 内部每 1 s 扫描 zset,把到期的元素移动到真正的阻塞列表(list),
take()实际上是从该 list 做BLPOP,因此重启后依旧能续上。
-
-
消费模型
单线程无限阻塞 take():
-
简单,顺序消费,避免并发关单。
-
若订单量巨大,可拆多个队列 + 线程池,但同订单必须路由到同一队列以保证顺序。
-
-
幂等设计
-
数据库层:
status=UNPAID才更新,其余状态直接 return; -
事件层:
OrderClosedEvent由下游消费者自己做幂等(例如用订单 ID 做唯一索引或乐观锁)。
-
-
事务与一致性
-
仅更新订单状态使用本地事务(@Transactional)。
-
库存、优惠券回滚通过事件总线异步解耦 ,实现最终一致性;若下游失败可重试或报警人工处理。
-
-
可观测性
-
在线程名
close-order-worker中打日志/埋点,方便追踪。 -
对
InterruptedException正确响应中断,保证优雅关机。
-
三、面试"高浓度"回答模板 Q:讲讲你们怎么实现"订单超时未支付自动关闭"的?
A:
-
延迟队列选型
用 Redisson 的 RDelayedQueue,基于 Redis zset,支持集群、重启后自动续消费,比 JDK DelayQueue 和 RabbitMQ TTL 更轻量。
-
触发与消费
在 Spring Bean 的
@PostConstruct方法里启动单线程,无限take(),保证应用启动就开始工作;线程名定制方便排查。 -
关单逻辑
拿到订单 ID 后先查库,状态必须是 UNPAID 才更新为 CLOSED ,其他情况直接返回,实现幂等;更新使用本地事务,成功后发布领域事件
OrderClosedEvent。 -
下游解耦
库存、优惠券通过监听事件异步回滚,失败记录重试表,保证最终一致性。
-
高可用与扩展
如果订单量上涨,只需横向扩展应用实例,Redisson 的队列支持多实例竞争消费;同时保证同订单路由到同一实例即可避免并发关单。
-
监控
对延迟队列长度、关单成功率、事件消费延迟都做了 Prometheus 打点,超阈值立即告警。
一句话总结(背下来)
"我们用 Redisson 延迟队列实现订单 30 分钟关单,@PostConstruct 启动单线程阻塞 take(),关单前幂等判断状态,事务更新后发事件解耦回滚库存优惠券,重启可续消费,全程可观测。"
RabbitMQ 死信队列
┌──────────────┐
│ 用户下单成功 │
└──────┬───────┘
▼
┌──────────────┐
│ 订单状态=待支付│
└──────┬───────┘
▼
┌────────────────────┐
│ 发送「正常消息」 │
│ Exchange: order.normal│
│ RoutingKey: 30min │
│ TTL=30min(消息级) │
└──────┬──────────────┘
▼
┌────────────────────┐
│ 队列 order.wait_pay │
│ x-dead-letter-exchange│
│ = order.dlx │
│ x-dead-letter-routing-key│
│ = close │
└──────┬──────────────┘
▼
┌────────────────────┐
│ 30min 后消息过期 │
│ 自动变成「死信」 │
└──────┬──────────────┘
▼
┌────────────────────┐
│ 死信交换机 order.dlx │
│ 路由到「死信队列」 │
└──────┬──────────────┘
▼
┌────────────────────┐
│ 死信队列 order.close │
│ 消费者:关单服务 │
└──────┬──────────────┘
▼
┌────────────────────┐
│ 拉取订单ID │
└──────┬──────────────┘
▼
┌────────────────────┐
│ 幂等查询订单状态 │
└──────┬──────────────┘
┌─────────────────┼─────────────────┐
│状态=已支付 │状态=待支付 │其他
▼ ▼ ▼
【直接ACK】 ┌──────────────┐ 【直接ACK】
│ 执行关单逻辑 │
└──────┬───────┘
▼
┌──────────────┐
│ 关闭订单 │
└──────┬───────┘
▼
┌──────────────┐
│ 释放库存/优惠券│
│ 发OrderClosed事件│
└──────┬───────┘
▼
┌──────────────┐
│ ACK消息 │
└──────────────┘
────────────── 额外机制 ──────────────
-
队列阻塞兜底:死信队列采用「单订单粒度」+ 并行消费,防止队头长 TTL 阻塞后续短 TTL。
-
兜底扫描:每日凌晨批扫「待支付且 create_time < now-35min」做补偿,量极小。
-
幂等保证:关单 UPDATE 加状态条件 + 唯一索引,重复消息安全。
-
高可用:RabbitMQ 镜像队列 + 消费端手动 ACK + 重试指数退避。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency>@Configuration
public class RabbitDelayConfig {/* --------------- 1. 死信交换机 & 死信队列 --------------- */ @Bean DirectExchange dlxExchange() { return new DirectExchange("order.dlx"); } @Bean Queue closeQueue() { return QueueBuilder.durable("order.close").build(); } @Bean Binding closeBind() { return BindingBuilder.bind(closeQueue()).to(dlxExchange()).with("close"); } /* --------------- 2. 等待队列(带 DLX 和 TTL)--------------- */ @Bean Queue waitQueue() { return QueueBuilder.durable("order.wait_pay") // 消息 30min 后过期 => 进入死信 .ttl(30 * 60 * 1000) .deadLetterExchange("order.dlx") .deadLetterRoutingKey("close") .build(); } @Bean DirectExchange normalExchange() { return new DirectExchange("order.normal"); } @Bean Binding waitBind() { return BindingBuilder.bind(waitQueue()).to(normalExchange()).with("30min"); }}
@Service
class OrderService {
@Resource
private RabbitTemplate rabbitTemplate;public void createOrder(Long orderId) { // 落库逻辑省略 rabbitTemplate.convertAndSend("order.normal", "30min", orderId); }@Component
public class CloseOrderListener {@RabbitListener(queues = "order.close") public void closeOrder(Long orderId) { // 同样幂等关单 Order order = orderMapper.selectById(orderId); if (order != null && order.getStatus() == OrderStatus.UNPAID) { order.setStatus(OrderStatus.CLOSED); orderMapper.updateById(order); } }}
}
面试一句话总结
RabbitMQ 死信队列 = 消息级 TTL + DLX ,实现简单;但「队列先进先出」可能导致长 TTL 阻塞短 TTL,需分区或并行消费 缓解
RocketMQ 延迟消息
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-client-java</artifactId>
<version>5.1.4</version>
</dependency>
public class RocketMQDelayProducer {
private static final String NAMESRV = "127.0.0.1:9876";
private static final String TOPIC = "OrderCloseDelay";
public static void main(String[] args) throws Exception {
DefaultMQProducer producer = new DefaultMQProducer("delay-producer-group");
producer.setNamesrvAddr(NAMESRV);
producer.start();
long orderId = 123456L;
Message msg = new Message(TOPIC,
"",
String.valueOf(orderId).getBytes(StandardCharsets.UTF_8));
/* 核心:RocketMQ 只支持 18 个固定延迟等级
* level=1 表示 1s,level=16 表示 30min,详见 broker 配置 messageDelayLevel */
msg.setDelayTimeLevel(16);
SendResult sendResult = producer.send(msg);
System.out.println("延迟消息已发送:" + sendResult);
producer.shutdown();
}
@Component
public class RocketMQDelayConsumer {
@PostConstruct
public void start() throws Exception {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("delay-consumer-group");
consumer.setNamesrvAddr("127.0.0.1:9876");
consumer.subscribe("OrderCloseDelay", "*");
// 到点后消息对消费者可见,并行消费
consumer.registerMessageListener((MessageListenerConcurrently) (msgs, context) -> {
for (MessageExt msg : msgs) {
long orderId = Long.parseLong(new String(msg.getBody()));
closeOrder(orderId);
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
});
consumer.start();
}
private void closeOrder(long orderId) {
// 同样「先查再关」保证幂等
Order order = orderMapper.selectById(orderId);
if (order != null && order.getStatus() == OrderStatus.UNPAID) {
order.setStatus(OrderStatus.CLOSED);
orderMapper.updateById(order);
// 发布下游事件
}
}
}
}
面试一句话总结
RocketMQ 延迟消息 吞吐量最高、支持万亿级 ,但只能选固定 18 个等级;用事务消息可保证"订单落库 & 消息"原子,大促高并发场景标配