面试题---------------场景+算法

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 后弹出来,重启不丢,代码量最少,中小系统首选

关键点解释

  1. 行锁范围
    FOR UPDATE 只锁主键行,不会锁表 ,并发度最高;

    必须走唯一索引/主键,否则 Next-Key Lock 会扩大范围导致性能骤降。

  2. 事务边界

    整个方法用 @Transactional 包起来,保证

    • 锁在事务提交时才释放;

    • 事件 afterCommit() 只在数据库修改成功后再投递,防止"消息出去了,库没改成"。

  3. 双重校验

    for updateupdate where status=UNPAID

    • 第一道门:行锁挡住并发;

    • 第二道门:rows==0 兜底,避免"幻读"导致重复关单。

  4. 性能

    单条记录加锁耗时 < 1 ms(SSD,索引命中),1 个实例 1 线程可轻松扛 1k+ TPS

    若多实例部署,Redis 延迟队列天然竞争,不会有两个线程同时处理同一订单

  5. 失败策略

    返回 false 即表示"已有人处理",线程直接丢弃,无需重试

    如果更新成功但后续业务异常(如发 MQ 失败),事务回滚,订单状态回滚为 UNPAID消息不会出去,可依靠延迟队列再次到期重推。


四、面试一句话版本(背下来)

"消费线程先 select ... for update 对订单加行锁,发现状态不是待支付直接返回;

再执行 update ... where status=待支付 把状态改成已关闭,影响行数=0 说明被别的线程抢先,立即丢弃

整个方法在本地事务里,提交后才发关单事件,既防并发又保证消息不重不漏。"

【star 法则 + 总-分-总结构】回答模板

(时长≈2-3 分钟,可随问随拆)

  1. 总述

    "我们采用'Redisson 延迟队列 + 定时任务兜底'两级方案,兼顾了实时性、可靠性与实现成本,线上 8 个月稳定运行,订单量 120w/天,关单延迟 p99 在 1.2 秒以内。"

  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 单以内。"

  1. 结果 & 数据

    "上线后关单延迟从原来定时任务的 5~10 分钟降到 1 秒级;大促峰值 6k 单/秒,redis cpu 峰值 12%,无重关、无漏关;曾模拟 redis 宕机 15 分钟,兜底任务全部补偿完毕,数据零差错。"

  2. 亮点补充(面试官爱问的"坑")

  • jvm 重启不丢消息:Redisson 的 queue 持久化到 redis,线程重启后自动续消费。

  • redis 主从切换:用 redisson 的哨兵模式,主从切换 3 秒完成,消费线程重连后自动续跑。

  • 平滑扩容:延迟队列是 redis 单 key,后期通过'订单号取模分 8 个 bucket'水平拆分,性能线性提升。


【一句话收束】

"总结就是:用 Redisson 延迟队列扛实时流量,定时任务做最后一道闸门,两者互补,让订单关单既快又稳。

二、整体逻辑细节(逐层拆解)

  1. 触发时机

    @PostConstruct 保证应用启动完成且单例 Bean 初始化后立刻执行,无需人工触发。

  2. 延迟队列原理

    Redisson 的 RDelayedQueue 基于 Redis zset + 定时任务:

    • 调用 delayedQueue.offer(orderId, 30, TimeUnit.MINUTES) 时,订单 ID 被放入 zset,score=当前时间+延迟毫秒。

    • Redisson 内部每 1 s 扫描 zset,把到期的元素移动到真正的阻塞列表(list),take() 实际上是从该 list 做 BLPOP,因此重启后依旧能续上

  3. 消费模型

    单线程无限阻塞 take():

    • 简单,顺序消费,避免并发关单。

    • 若订单量巨大,可拆多个队列 + 线程池,但同订单必须路由到同一队列以保证顺序。

  4. 幂等设计

    • 数据库层:status=UNPAID 才更新,其余状态直接 return;

    • 事件层:OrderClosedEvent 由下游消费者自己做幂等(例如用订单 ID 做唯一索引或乐观锁)。

  5. 事务与一致性

    • 仅更新订单状态使用本地事务(@Transactional)。

    • 库存、优惠券回滚通过事件总线异步解耦 ,实现最终一致性;若下游失败可重试或报警人工处理。

  6. 可观测性

    • 在线程名 close-order-worker 中打日志/埋点,方便追踪。

    • InterruptedException 正确响应中断,保证优雅关机。


三、面试"高浓度"回答模板 Q:讲讲你们怎么实现"订单超时未支付自动关闭"的?

A:

  1. 延迟队列选型

    用 Redisson 的 RDelayedQueue,基于 Redis zset,支持集群、重启后自动续消费,比 JDK DelayQueue 和 RabbitMQ TTL 更轻量。

  2. 触发与消费

    在 Spring Bean 的 @PostConstruct 方法里启动单线程,无限 take(),保证应用启动就开始工作;线程名定制方便排查。

  3. 关单逻辑

    拿到订单 ID 后先查库,状态必须是 UNPAID 才更新为 CLOSED ,其他情况直接返回,实现幂等;更新使用本地事务,成功后发布领域事件 OrderClosedEvent

  4. 下游解耦

    库存、优惠券通过监听事件异步回滚,失败记录重试表,保证最终一致性。

  5. 高可用与扩展

    如果订单量上涨,只需横向扩展应用实例,Redisson 的队列支持多实例竞争消费;同时保证同订单路由到同一实例即可避免并发关单。

  6. 监控

    对延迟队列长度、关单成功率、事件消费延迟都做了 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消息 │

└──────────────┘

────────────── 额外机制 ──────────────

  1. 队列阻塞兜底:死信队列采用「单订单粒度」+ 并行消费,防止队头长 TTL 阻塞后续短 TTL。

  2. 兜底扫描:每日凌晨批扫「待支付且 create_time < now-35min」做补偿,量极小。

  3. 幂等保证:关单 UPDATE 加状态条件 + 唯一索引,重复消息安全。

  4. 高可用: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 个等级;用事务消息可保证"订单落库 & 消息"原子,大促高并发场景标配

相关推荐
chbmvdd1 小时前
week5题解
数据结构·c++·算法
用户12039112947261 小时前
面试官最爱问的字符串反转:7种JavaScript实现方法详解
算法·面试
客梦1 小时前
Java 学生管理系统
java·笔记
e***0961 小时前
SpringBoot下获取resources目录下文件的常用方法
java·spring boot·后端
vir021 小时前
小齐的技能团队(dp)
数据结构·c++·算法·图论
q***14641 小时前
JavaWeb项目打包、部署至Tomcat并启动的全程指南(图文详解)
java·tomcat
從南走到北2 小时前
JAVA同城信息付费系统家政服务房屋租赁房屋买卖房屋装修信息发布平台小程序APP公众号源码
java·开发语言·小程序
TechMasterPlus2 小时前
java:单例模式
java·开发语言·单例模式
Star在努力2 小时前
C语言复习八(2025.11.18)
c语言·算法·排序算法