🎤 面试官:用户下单后不付款,订单一直挂着怎么办?
我: 这太常见了,我们管这叫“垃圾订单”或者“占库存不买”。
比如你选了个限量款球鞋,加购、下单,然后就挂那儿不付钱,一挂几小时,别人想买也买不了——这不就乱套了嘛。
所以我们必须有个机制:用户不下单就付钱,我们就自动把这个订单关了,把库存还回去,让别人能买。
🎤 面试官:那你们是怎么判断“超时”的?用什么技术实现?
我: 我们是这么做的:
下单时写个“过期时间”: 比如用户下单,我们默认给他 30分钟 付款时间,就在订单表里记一个字段:
sqlexpire_time = NOW() + 30分钟后台跑个定时任务: 用 XXL-JOB,每隔 1 分钟查一次:
sqlSELECT * FROM t_order WHERE status = '待支付' AND expire_time < NOW()把所有“超时未支付”的订单捞出来,批量关闭。
关闭订单 + 释放库存:
- 更新订单状态为“已关闭”;
- 调库存服务,把之前锁的库存“解冻”;
- 如果用了优惠券,也把券还回去;
- 最后发个消息告诉用户:“亲,订单超时未支付,已自动关闭”。
这样整个流程就闭环了。
🎤 面试官:那如果定时任务卡了,比如延迟了几分钟,会不会出问题?
我: 会啊,所以我们不只靠定时任务,还加了第二道保险:用户端主动提醒。
比如:
- 用户下单后,App 会弹个倒计时:“还剩29分30秒,请尽快付款”;
- 到最后5分钟,发个推送:“再不付就没了!”;
- 他一打开页面,我们后端也立刻检查一下这个订单是不是已经超时了,如果是,直接提示“订单已失效”。
所以就算定时任务慢了几分钟,用户自己一刷新,我们也立刻处理,不会真让用户“付了款却发现订单没了”。
🎤 面试官:有没有可能用户付了款,但系统没收到回调,结果订单被关了?
我: 这是最怕的情况!用户钱都付了,你还把订单关了,人家肯定炸了。
我们是怎么防的?
支付回调优先于超时关闭: 支付系统一回调说“用户付钱了”,我们立刻把订单改成“已支付”,状态变了,超时任务就不管它了;
加锁防并发: 在处理支付回调 or 超时关闭时,都先查数据库加行锁:
sqlSELECT * FROM t_order WHERE id = ? FOR UPDATE防止“一边在关订单,一边在处理付款”同时发生;
补偿机制: 如果真出了问题(比如网络抖了,回调丢了),我们每天有个对账任务,去支付系统拉一遍“今天所有支付成功的订单”,发现我们这边没更新的,就手动补成“已支付”。
所以说:定时关单可以慢点,但支付回调必须快且准。
🎤 面试官:超时时间是固定的吗?能不能改?
我: 不是固定的,我们支持动态设置。
比如:
- 普通商品:30分钟;
- 秒杀商品:10分钟(抢的人多,不能占太久);
- 大件家具 or 定制商品:可能给24小时(用户要考虑);
这个时间是在下单时由业务决定的,前端传过来,我们校验一下合理性就存进去。
甚至有些活动页还会显示:“您有15分钟专属锁定时间”,倒计时一走,库存就放出去。
🎤 面试官:关闭订单后,库存是直接加回去吗?
我: 是,但不是“直接加”,而是走解冻流程。
因为我们下单时是“冻结库存”,不是直接扣减。
比如你买1个手机,可用库存从100变成99,冻结库存从0变成1。
超时关闭后,我们就调库存服务一个接口:
JavastockService.cancelFreeze(productId, 1);它会把冻结库存减1,可用库存加1。
这样就保证了:不会多还、也不会少还。
🎤 面试官:你们这个定时任务多久跑一次?会不会压力大?
我: 我们设的是每分钟跑一次,每次最多处理 500 条超时订单。
为啥是500? 因为我们做过压测,一次处理太多,数据库压力大,怕把主流程卡住。
如果某次有2000个订单超时,那也没事,分4次慢慢关,反正也就晚个几分钟,用户感知不强。
而且我们还加了分片处理: 比如把订单按
shop_id % 4分成4片,4个服务实例各处理一片,提速又减压。
🎤 面试官:除了定时任务,你们有没有用过其他方式处理超时订单?比如 MQ?
我: 有!我们后来升级了方案,不再用定时任务去“扫库”了,而是改用 RocketMQ 的延迟队列,更高效、更实时。
为啥要改? 因为定时任务有个毛病:
- 比如你设每分钟跑一次,那最坏情况要等 59 秒才处理;
- 而且很多订单还没超时,你也得每次都去查一遍,白白浪费数据库资源。
所以我们换了个思路:订单一下单,我就提前发个“超时关单”的消息,让它在 30 分钟后自动来敲门。
🎤 面试官:具体是怎么做的?能说说流程吗?
我: 流程特别简单,就三步:
✅ 第一步:下单成功后,立刻发一条“延迟消息”
// 构造消息
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 自动把这条消息推给我们写的消费者:
@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 本身不支持“取消消息”,怎么办? 我们用了个“对冲法”:
支付成功后,我们不是去删消息(删不了),而是:
java// 发一条“取消关单”的消息,也是同一个 topic rocketMQTemplate.sendMessage("order_timeout_topic", JSON.toJSONString(new CancelOrderTimeoutEvent(orderId)));消费者收到消息时,先判断类型:
javaif (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 上,我们集群撑得住。
🎤 面试官:那有没有什么缺点?
我: 有,两个小坑:
- 延迟级别固定: RocketMQ 的延迟是预设的,不能精确到“23分15秒”,只能选 30 分 or 1 小时。 —— 我们一般就用 30 分钟,能接受。
- 消息堆积风险: 如果消费者挂了,几万个延迟消息堆在 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. 消费位点监控
我们还写了个小脚本,定时查这个消费组的
consumeOffset和brokerOffset:
- 如果两者差值越来越大,说明消费不过来;
- 差值超过阈值,就告警。
就像看快递站的包裹:进来的越来越多,发出去的却没动,那肯定出问题了。
✅ 二、限流处理:消息涌进来时,怎么不让系统崩?
这才是关键! 光知道“挂了”没用,得防止“恢复即爆炸”。
我们用了三种方式控制流量:
🔹 1. 消费者内部限流:自己控制消费速度
我们在消费者代码里加了个“每秒最多处理 N 条”的逻辑:
@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 消费者本身有线程池,我们可以调小它:
# application.yml
rocketmq:
consumer:
consume-thread-min: 4
consume-thread-max: 8 # 不设太大,防压垮默认是几十个线程一起消费,我们改成最多 8 个,降低并发冲击。
🔹 3. 加 Redis 做“滑动窗口”限流(可选)
如果怕突发流量太大,还可以用 Redis 记录处理次数,比如“每分钟最多处理 500 条”:
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 条自动暂停;
- 看系统稳了,再处理下一批。
就像水库泄洪,不能一下子开闸,得慢慢放。