跳转到内容

消息队列

标签「消息队列」下的 1 篇文章

电商订单过期处理面试题复盘

这是我 2024 年遇到的一道电商面试题,题目大概是:

假如在某个时刻,也就是某一秒内,有数以千计的商品订单同时过期,数据库应该怎么处理?

这道题表面问的是“订单过期”,实际考察的是高并发场景下怎么避免数据库被瞬时写流量打穿。

一个比较稳妥的回答是:不要让数据库自己去扛所有过期判断,也不要用定时任务在同一秒扫大量订单。订单创建时写入延时队列,到期后由消费者接收消息,再做批量更新和后续事件分发。

订单过期一般不是一个难逻辑。比如订单超过 30 分钟未支付,就把状态从 WAIT_PAY 改成 CLOSED

真正的问题在于:如果某个秒级时间点有大量订单同时过期,系统会出现几个压力点:

  • 数据库短时间内出现大量 update
  • 如果使用定时任务扫描,查询范围可能很大。
  • 单条订单逐个更新会造成大量数据库往返。
  • 过期后还要释放库存、通知用户、同步缓存,容易把主流程拖慢。
  • 消息重复、消费失败、订单已支付等情况都需要正确处理。

所以这道题不能只回答“定时任务扫一下订单表”。那样在低并发下能跑,但在电商场景里不够稳。

我当时记录的主思路是四步:

订单创建 -> 写入延时队列 -> 到期消费 -> 批量关闭订单 -> 发布后续业务事件

可以画成这样:

创建订单
|
| 写订单表
v
发送延时消息:orderId + expireTime
|
| 到期后投递
v
订单过期消费者
|
| 聚合一小批订单
v
批量更新订单状态
|
| 更新成功后发布事件
v
释放库存 / 退款 / 通知用户 / 同步 ES 或缓存

核心目标是:把同一秒的大量过期订单,拆成可控的批量写入和异步事件处理。

订单创建成功后,系统先写入订单表,然后把订单 ID 和过期时间写入延时队列。

消息里通常包含:

order-expire-message.json
{
"orderId": "202401010001",
"orderType": "NORMAL",
"expireTime": "2024-01-01T10:30:00+08:00"
}

延时时间可以这样计算:

TTL = expireTime - now()

也就是说,如果订单 30 分钟后过期,就发送一条 30 分钟后投递的延时消息。

这里可以选不同的实现:

方案说明
RabbitMQ TTL + 死信队列到期后消息进入死信队列,由消费者处理
RocketMQ 延时消息原生支持延时消息,适合常见延时等级
Redis ZSet用过期时间作为 score,消费者按时间拉取
时间轮适合大量延时任务调度

面试时不用把某个 MQ 的细节讲太深,重点是说明:订单创建时就把“未来要关闭订单”这件事交给延时机制,而不是等数据库定时扫描。

当延时时间到达,消息会被投递到订单过期消费者。

消费者拿到消息后,不能直接无脑关闭订单,而是要先做状态校验:

收到 orderId
|
查询订单当前状态
|
如果已支付:忽略
如果已关闭:忽略
如果待支付且已超时:关闭

原因很简单:延时消息到达时,订单可能已经被用户支付了。

所以关闭订单必须是有条件的。不能只根据“消息到了”就关闭。

如果每条消息都单独更新一次数据库,几千条过期订单就会变成几千次 update

更好的做法是:消费者先把消息暂存到一个批量处理队列里,然后按数量或时间窗口触发批量更新。

常见触发条件:

  • 累积到 100-500 条订单。
  • 或者等待 100ms 左右。
  • 两个条件满足任意一个就执行批量更新。

可以理解为一个小型缓冲区:

消息 1 -> buffer
消息 2 -> buffer
消息 3 -> buffer
...
达到 300 条,批量 update

这样做的好处是明显的:

  • 减少数据库连接和网络往返。
  • 降低数据库瞬时写压力。
  • 消费者可以通过批次大小控制吞吐。
  • 后续可以水平扩展多个消费者。

批量更新订单状态时,一定要带上状态条件。

比如只关闭仍然处于待支付状态的订单:

batch-close-orders.sql
UPDATE orders
SET status = 'CLOSED',
close_reason = 'TIMEOUT',
closed_at = NOW()
WHERE id IN (...)
AND status = 'WAIT_PAY'
AND expire_time <= NOW();

这里的 status = 'WAIT_PAY' 很关键。

它能保证:

  • 已支付订单不会被误关闭。
  • 已关闭订单重复消费也不会重复更新。
  • 延时消息重复投递时,更新操作仍然安全。

这就是幂等。

面试里可以直接说:消息队列天然可能重复投递,所以订单关闭逻辑必须是幂等的。

订单状态批量更新完成后,不要在同一个流程里把所有后续业务都做完。

应该发布一个订单关闭事件:

order-closed-event.json
{
"eventType": "ORDER_CLOSED",
"reason": "TIMEOUT",
"orderIds": ["202401010001", "202401010002"]
}

然后由不同消费者处理后续业务:

  • 释放库存。
  • 发起退款。
  • 通知用户。
  • 同步订单状态到 ES。
  • 删除或刷新缓存。
  • 写入业务日志。

这样可以把“关闭订单”和“关闭订单后要做什么”拆开。

关闭订单是核心链路,必须尽快完成;释放库存、通知用户、同步缓存这些动作可以异步扩展。

定时任务不是不能用,但它不适合直接承担高并发订单过期主链路。

如果用定时任务每秒扫一次订单表,大概会遇到几个问题:

  • 查询条件依赖 expire_time,数据量大时压力明显。
  • 某一秒过期订单过多时,任务执行时间不可控。
  • 任务失败后需要补偿。
  • 多实例部署时要处理分布式锁和重复扫描。
  • 单表订单量大时,还要考虑分库分表后的扫描范围。

定时任务更适合做兜底补偿,比如每隔一段时间扫描仍然处于 WAIT_PAY 且已经超时的订单,补偿可能丢失的延时消息。

所以更完整的方案是:

延时队列处理主流程
定时任务做兜底补偿

题目里说某一秒有数以千计订单过期,这其实就是一个瞬时流量峰值。

除了批量更新,还可以做一些削峰处理:

  • 多消费者并发消费,但限制每个消费者批量写入频率。
  • 将订单按业务线、商户、分库分表键分片处理。
  • 批量更新失败时拆分批次重试。
  • 对数据库连接池和 MQ 消费速度设置上限。
  • 监控积压量,必要时临时扩容消费者。

关键思想是:MQ 可以积压,消费者可以慢慢处理,但数据库不能被突然打爆。

这道题可以按下面这段话回答:

我不会让数据库在某一秒直接承受大量订单过期更新。订单创建成功后,会把订单 ID 和过期时间写入延时队列,TTL 设置为 expireTime - now()。消息到期后投递给订单过期消费者,消费者先校验订单是否仍是待支付状态,再把消息放入批量缓冲区,按固定数量或固定时间窗口统一批量更新订单状态。更新时 SQL 会带上 status = WAIT_PAY 和过期时间条件,保证幂等,避免误关已支付订单。订单关闭成功后再发布订单关闭事件,由库存、退款、通知、缓存同步等消费者异步处理。定时任务只作为兜底补偿,不作为主流程。

这段回答基本覆盖了面试官想听的几个关键词:

  • 延时队列。
  • 批量处理。
  • 幂等。
  • 削峰。
  • 事件驱动。
  • 兜底补偿。

这道题的重点不是“怎么把订单改成过期”,而是怎么在大量订单同时过期时保护数据库。

比较合理的设计是:

订单创建时写延时队列
到期后消费者接收消息
消费者聚合订单并批量更新
更新时保证幂等
关闭后发布业务事件
定时任务做兜底补偿

电商系统里很多问题都不是单点逻辑难,而是峰值、幂等、补偿、解耦这些工程问题难。这个题目正好把这些点串到了一起。