Skip to content

🎤 面试官:用户下单后不付款,订单一直挂着怎么办?

: 这太常见了,我们管这叫“垃圾订单”或者“占库存不买”。

比如你选了个限量款球鞋,加购、下单,然后就挂那儿不付钱,一挂几小时,别人想买也买不了——这不就乱套了嘛。

所以我们必须有个机制:用户不下单就付钱,我们就自动把这个订单关了,把库存还回去,让别人能买。


🎤 面试官:那你们是怎么判断“超时”的?用什么技术实现?

: 我们是这么做的:

  1. 下单时写个“过期时间”: 比如用户下单,我们默认给他 30分钟 付款时间,就在订单表里记一个字段:

    sql
    expire_time = NOW() + 30分钟
  2. 后台跑个定时任务: 用 XXL-JOB,每隔 1 分钟查一次:

    sql
    SELECT * FROM t_order 
    WHERE status = '待支付' AND expire_time < NOW()

    把所有“超时未支付”的订单捞出来,批量关闭。

  3. 关闭订单 + 释放库存

    • 更新订单状态为“已关闭”;
    • 调库存服务,把之前锁的库存“解冻”;
    • 如果用了优惠券,也把券还回去;
    • 最后发个消息告诉用户:“亲,订单超时未支付,已自动关闭”。

这样整个流程就闭环了。


🎤 面试官:那如果定时任务卡了,比如延迟了几分钟,会不会出问题?

: 会啊,所以我们不只靠定时任务,还加了第二道保险用户端主动提醒

比如:

  • 用户下单后,App 会弹个倒计时:“还剩29分30秒,请尽快付款”;
  • 到最后5分钟,发个推送:“再不付就没了!”;
  • 他一打开页面,我们后端也立刻检查一下这个订单是不是已经超时了,如果是,直接提示“订单已失效”。

所以就算定时任务慢了几分钟,用户自己一刷新,我们也立刻处理,不会真让用户“付了款却发现订单没了”。


🎤 面试官:有没有可能用户付了款,但系统没收到回调,结果订单被关了?

: 这是最怕的情况!用户钱都付了,你还把订单关了,人家肯定炸了。

我们是怎么防的?

  1. 支付回调优先于超时关闭: 支付系统一回调说“用户付钱了”,我们立刻把订单改成“已支付”,状态变了,超时任务就不管它了;

  2. 加锁防并发: 在处理支付回调 or 超时关闭时,都先查数据库加行锁:

    sql
    SELECT * FROM t_order WHERE id = ? FOR UPDATE

    防止“一边在关订单,一边在处理付款”同时发生;

  3. 补偿机制: 如果真出了问题(比如网络抖了,回调丢了),我们每天有个对账任务,去支付系统拉一遍“今天所有支付成功的订单”,发现我们这边没更新的,就手动补成“已支付”。

所以说:定时关单可以慢点,但支付回调必须快且准


🎤 面试官:超时时间是固定的吗?能不能改?

: 不是固定的,我们支持动态设置

比如:

  • 普通商品:30分钟;
  • 秒杀商品:10分钟(抢的人多,不能占太久);
  • 大件家具 or 定制商品:可能给24小时(用户要考虑);

这个时间是在下单时由业务决定的,前端传过来,我们校验一下合理性就存进去。

甚至有些活动页还会显示:“您有15分钟专属锁定时间”,倒计时一走,库存就放出去。


🎤 面试官:关闭订单后,库存是直接加回去吗?

: 是,但不是“直接加”,而是走解冻流程

因为我们下单时是“冻结库存”,不是直接扣减。

比如你买1个手机,可用库存从100变成99,冻结库存从0变成1。

超时关闭后,我们就调库存服务一个接口:

Java
stockService.cancelFreeze(productId, 1);

它会把冻结库存减1,可用库存加1。

这样就保证了:不会多还、也不会少还


🎤 面试官:你们这个定时任务多久跑一次?会不会压力大?

: 我们设的是每分钟跑一次,每次最多处理 500 条超时订单。

为啥是500? 因为我们做过压测,一次处理太多,数据库压力大,怕把主流程卡住。

如果某次有2000个订单超时,那也没事,分4次慢慢关,反正也就晚个几分钟,用户感知不强。

而且我们还加了分片处理: 比如把订单按 shop_id % 4 分成4片,4个服务实例各处理一片,提速又减压。

🎤 面试官:除了定时任务,你们有没有用过其他方式处理超时订单?比如 MQ?

: 有!我们后来升级了方案,不再用定时任务去“扫库”了,而是改用 RocketMQ 的延迟队列,更高效、更实时。

为啥要改? 因为定时任务有个毛病:

  • 比如你设每分钟跑一次,那最坏情况要等 59 秒才处理;
  • 而且很多订单还没超时,你也得每次都去查一遍,白白浪费数据库资源。

所以我们换了个思路:订单一下单,我就提前发个“超时关单”的消息,让它在 30 分钟后自动来敲门


🎤 面试官:具体是怎么做的?能说说流程吗?

: 流程特别简单,就三步:

✅ 第一步:下单成功后,立刻发一条“延迟消息”

java
// 构造消息
String msgBody = JSON.toJSONString(new OrderTimeoutEvent(orderId));

// 发送延迟消息,Level 5 = 30分钟(RocketMQ预设级别)
SendResult result = rocketMQTemplate.sendMessageInDelay("order_timeout_topic", 
                                                       msgBody, 
                                                       5); // 5代表30分钟

注意:RocketMQ 的延迟是分级的,比如:

  • Level 1: 1s
  • Level 2: 5s
  • Level 3: 10s
  • ...
  • Level 5: 30min
  • Level 6: 1h

我们一般用 Level 5(30分钟) or Level 6(1小时),看业务需求。


✅ 第二步:30分钟后,消息自动送达消费者

到时间了,RocketMQ 自动把这条消息推给我们写的消费者:

java
@RocketMQMessageListener(topic = "order_timeout", consumerGroup = "order-group")
public class OrderTimeoutConsumer implements RocketMQListener<String> {

    @Override
    public void onMessage(String message) {
        OrderTimeoutEvent event = JSON.parseObject(message, OrderTimeoutEvent.class);
        Long orderId = event.getOrderId();

        // 查订单是否还“待支付”
        Order order = orderMapper.selectById(orderId);
        if (OrderStatus.WAIT_PAY.equals(order.getStatus())) {
            // 关闭订单 + 释放库存
            orderService.closeOrder(orderId, "timeout");
        }
        // 如果已经支付了,那就什么都不做
    }
}

这个消费者就像个“闹钟”,30分钟后准时响一次。


✅ 第三步:如果用户中途付款了,怎么办?别把已支付的订单关了!

这是关键! 我们不能让“关单消息”把已支付的订单给关了。

我们的做法是:在支付成功时,取消这条延迟消息

但 RocketMQ 本身不支持“取消消息”,怎么办? 我们用了个“对冲法”:

  1. 支付成功后,我们不是去删消息(删不了),而是:

    java
    // 发一条“取消关单”的消息,也是同一个 topic
    rocketMQTemplate.sendMessage("order_timeout_topic", 
                                JSON.toJSONString(new CancelOrderTimeoutEvent(orderId)));
  2. 消费者收到消息时,先判断类型:

    java
    if (event instanceof CancelOrderTimeoutEvent) {
        // 收到“取消”指令,把这个订单标记为“无需处理”
        redisTemplate.set("order_timeout_canceled:" + orderId, "1", Duration.ofHours(1));
    } else if (event instanceof OrderTimeoutEvent) {
        // 收到“关单”指令,先查 Redis 是否被取消过
        String canceled = redisTemplate.get("order_timeout_canceled:" + orderId);
        if ("1".equals(canceled)) {
            log.info("订单{}已支付,跳过关单", orderId);
            return;
        }
        // 否则正常处理关单
        orderService.closeOrder(orderId, "timeout");
    }

简单说:用 Redis 当个小本本,记一下“这个订单不用关了”


🎤 面试官:这个方案比定时任务好在哪?

: 好处特别明显:

对比项定时任务方案MQ延迟队列方案
实时性最多延迟59秒到点就处理,误差小
数据库压力每分钟全表扫完全不扫库
扩展性订单越多越慢消息自动分片,扛得住
资源浪费处理大量未超时订单只处理真正超时的
代码逻辑复杂,要分页查、加锁简单,收到消息就处理

尤其是大促时候,一秒几万个订单,用定时任务扫库,DB 直接被打爆; 用延迟队列,压力都在 MQ 上,我们集群撑得住。


🎤 面试官:那有没有什么缺点?

: 有,两个小坑:

  1. 延迟级别固定: RocketMQ 的延迟是预设的,不能精确到“23分15秒”,只能选 30 分 or 1 小时。 —— 我们一般就用 30 分钟,能接受。
  2. 消息堆积风险: 如果消费者挂了,几万个延迟消息堆在 MQ 里,恢复时会“炸”一下,瞬间涌进来。 —— 所以我们加了:
    • 消费者健康检查;
    • 限流处理(每秒最多处理 100 条);
    • 告警通知,运维第一时间介入。

🎤 面试官:你说消费者挂了会消息堆积,恢复时可能“炸”,那你们是怎么做健康检查和限流的?

: 是这样的,我们吃过亏。

有一次运维升级 MQ 消费者服务,没注意,停了 10 分钟。 结果大促期间,3 万个“超时关单”消息全堆在 RocketMQ 里。 服务一重启,这些消息“哗”一下全涌进来,每秒几千条,CPU 直接 100%,数据库也被打懵了,连正常订单都处理不了。

从那以后,我们就加了两道保险:健康检查 + 限流处理


✅ 一、健康检查:怎么知道消费者“活着”?

我们是这么做的:

1. 让 MQ 自带的监控报警

RocketMQ 控制台能看到:

  • 消费组的 消息堆积数量(Consumer Lag);
  • 消费者的 消费速度(TPS);

我们设了个规则:

  • 如果某个 topic 的堆积数 > 5000 条,就发企业微信告警给运维;
  • 如果连续 5 分钟消费速度为 0,也告警。

这样不用写代码,就能第一时间知道“消费者挂了”。

2. 自己加个“心跳接口”

我们给消费者服务加了个健康检查接口:

java
@GetMapping("/health")
public String health() {
    return "OK";
}

然后用 Prometheus + Grafana 或者公司内部的监控系统,每隔 10 秒访问一次。

如果连续 3 次访问失败,就判定服务挂了,自动发告警:“订单超时消费者已失联,请立即处理!”

3. 消费位点监控

我们还写了个小脚本,定时查这个消费组的 consumeOffsetbrokerOffset

  • 如果两者差值越来越大,说明消费不过来;
  • 差值超过阈值,就告警。

就像看快递站的包裹:进来的越来越多,发出去的却没动,那肯定出问题了。


✅ 二、限流处理:消息涌进来时,怎么不让系统崩?

这才是关键! 光知道“挂了”没用,得防止“恢复即爆炸”。

我们用了三种方式控制流量:


🔹 1. 消费者内部限流:自己控制消费速度

我们在消费者代码里加了个“每秒最多处理 N 条”的逻辑:

java
@RocketMQMessageListener(topic = "order_timeout", consumerGroup = "order-group")
public class OrderTimeoutConsumer implements RocketMQListener<String> {

    // 用 Semaphore 控制并发数
    private final Semaphore semaphore = new Semaphore(20); // 最多20个线程同时处理

    @Override
    public void onMessage(String message) {
        try {
            // 获取许可,控制并发
            if (!semaphore.tryAcquire(3, TimeUnit.SECONDS)) {
                log.warn("处理能力已达上限,跳过消息");
                return;
            }

            // 正常处理关单逻辑
            processTimeoutOrder(message);

        } catch (Exception e) {
            log.error("处理超时订单失败", e);
        } finally {
            semaphore.release();
        }
    }
}

这样即使一下子来 1 万条消息,我们也慢慢吃,每秒最多处理 20 条,数据库压力可控。


🔹 2. RocketMQ 消费线程池限流

RocketMQ 消费者本身有线程池,我们可以调小它:

yaml
# application.yml
rocketmq:
  consumer:
    consume-thread-min: 4
    consume-thread-max: 8  # 不设太大,防压垮

默认是几十个线程一起消费,我们改成最多 8 个,降低并发冲击。


🔹 3. 加 Redis 做“滑动窗口”限流(可选)

如果怕突发流量太大,还可以用 Redis 记录处理次数,比如“每分钟最多处理 500 条”:

java
String key = "order_timeout:processed_count";
Long count = redisTemplate.incr(key);
if (count == 1) {
    redisTemplate.expire(key, 60, TimeUnit.SECONDS);
}
if (count > 500) {
    log.warn("本分钟已处理500条,暂时跳过");
    return; // 可以稍后重试
}

这招在极端情况下用,一般 Semaphore + 线程池就够了。


✅ 三、额外兜底:消息可以“分批放行”吗?

可以!我们还加了个“手动放流”功能。

比如运维发现堆积了 2 万条消息,不敢一下子放开,怕崩。

我们就做了个后台管理页面:

  • 输入“本次最多处理 1000 条”;
  • 点“开始处理”;
  • 处理完 1000 条自动暂停;
  • 看系统稳了,再处理下一批。

就像水库泄洪,不能一下子开闸,得慢慢放。