电商订单过期处理面试题复盘
这是我 2024 年遇到的一道电商面试题,题目大概是:
假如在某个时刻,也就是某一秒内,有数以千计的商品订单同时过期,数据库应该怎么处理?
这道题表面问的是“订单过期”,实际考察的是高并发场景下怎么避免数据库被瞬时写流量打穿。
一个比较稳妥的回答是:不要让数据库自己去扛所有过期判断,也不要用定时任务在同一秒扫大量订单。订单创建时写入延时队列,到期后由消费者接收消息,再做批量更新和后续事件分发。
订单过期一般不是一个难逻辑。比如订单超过 30 分钟未支付,就把状态从 WAIT_PAY 改成 CLOSED。
真正的问题在于:如果某个秒级时间点有大量订单同时过期,系统会出现几个压力点:
- 数据库短时间内出现大量
update。 - 如果使用定时任务扫描,查询范围可能很大。
- 单条订单逐个更新会造成大量数据库往返。
- 过期后还要释放库存、通知用户、同步缓存,容易把主流程拖慢。
- 消息重复、消费失败、订单已支付等情况都需要正确处理。
所以这道题不能只回答“定时任务扫一下订单表”。那样在低并发下能跑,但在电商场景里不够稳。
我当时记录的主思路是四步:
订单创建 -> 写入延时队列 -> 到期消费 -> 批量关闭订单 -> 发布后续业务事件可以画成这样:
创建订单 | | 写订单表 v发送延时消息:orderId + expireTime | | 到期后投递 v订单过期消费者 | | 聚合一小批订单 v批量更新订单状态 | | 更新成功后发布事件 v释放库存 / 退款 / 通知用户 / 同步 ES 或缓存核心目标是:把同一秒的大量过期订单,拆成可控的批量写入和异步事件处理。
订单创建时写入延时队列
Section titled “订单创建时写入延时队列”订单创建成功后,系统先写入订单表,然后把订单 ID 和过期时间写入延时队列。
消息里通常包含:
{ "orderId": "202401010001", "orderType": "NORMAL", "expireTime": "2024-01-01T10:30:00+08:00"}延时时间可以这样计算:
TTL = expireTime - now()也就是说,如果订单 30 分钟后过期,就发送一条 30 分钟后投递的延时消息。
这里可以选不同的实现:
| 方案 | 说明 |
|---|---|
| RabbitMQ TTL + 死信队列 | 到期后消息进入死信队列,由消费者处理 |
| RocketMQ 延时消息 | 原生支持延时消息,适合常见延时等级 |
| Redis ZSet | 用过期时间作为 score,消费者按时间拉取 |
| 时间轮 | 适合大量延时任务调度 |
面试时不用把某个 MQ 的细节讲太深,重点是说明:订单创建时就把“未来要关闭订单”这件事交给延时机制,而不是等数据库定时扫描。
到期后进入消费者
Section titled “到期后进入消费者”当延时时间到达,消息会被投递到订单过期消费者。
消费者拿到消息后,不能直接无脑关闭订单,而是要先做状态校验:
收到 orderId |查询订单当前状态 |如果已支付:忽略如果已关闭:忽略如果待支付且已超时:关闭原因很简单:延时消息到达时,订单可能已经被用户支付了。
所以关闭订单必须是有条件的。不能只根据“消息到了”就关闭。
消费端做批量处理
Section titled “消费端做批量处理”如果每条消息都单独更新一次数据库,几千条过期订单就会变成几千次 update。
更好的做法是:消费者先把消息暂存到一个批量处理队列里,然后按数量或时间窗口触发批量更新。
常见触发条件:
- 累积到
100-500条订单。 - 或者等待
100ms左右。 - 两个条件满足任意一个就执行批量更新。
可以理解为一个小型缓冲区:
消息 1 -> buffer消息 2 -> buffer消息 3 -> buffer...达到 300 条,批量 update这样做的好处是明显的:
- 减少数据库连接和网络往返。
- 降低数据库瞬时写压力。
- 消费者可以通过批次大小控制吞吐。
- 后续可以水平扩展多个消费者。
批量更新要保证幂等
Section titled “批量更新要保证幂等”批量更新订单状态时,一定要带上状态条件。
比如只关闭仍然处于待支付状态的订单:
UPDATE ordersSET status = 'CLOSED', close_reason = 'TIMEOUT', closed_at = NOW()WHERE id IN (...) AND status = 'WAIT_PAY' AND expire_time <= NOW();这里的 status = 'WAIT_PAY' 很关键。
它能保证:
- 已支付订单不会被误关闭。
- 已关闭订单重复消费也不会重复更新。
- 延时消息重复投递时,更新操作仍然安全。
这就是幂等。
面试里可以直接说:消息队列天然可能重复投递,所以订单关闭逻辑必须是幂等的。
更新后发布业务事件
Section titled “更新后发布业务事件”订单状态批量更新完成后,不要在同一个流程里把所有后续业务都做完。
应该发布一个订单关闭事件:
{ "eventType": "ORDER_CLOSED", "reason": "TIMEOUT", "orderIds": ["202401010001", "202401010002"]}然后由不同消费者处理后续业务:
- 释放库存。
- 发起退款。
- 通知用户。
- 同步订单状态到 ES。
- 删除或刷新缓存。
- 写入业务日志。
这样可以把“关闭订单”和“关闭订单后要做什么”拆开。
关闭订单是核心链路,必须尽快完成;释放库存、通知用户、同步缓存这些动作可以异步扩展。
为什么不用定时任务直接扫表
Section titled “为什么不用定时任务直接扫表”定时任务不是不能用,但它不适合直接承担高并发订单过期主链路。
如果用定时任务每秒扫一次订单表,大概会遇到几个问题:
- 查询条件依赖
expire_time,数据量大时压力明显。 - 某一秒过期订单过多时,任务执行时间不可控。
- 任务失败后需要补偿。
- 多实例部署时要处理分布式锁和重复扫描。
- 单表订单量大时,还要考虑分库分表后的扫描范围。
定时任务更适合做兜底补偿,比如每隔一段时间扫描仍然处于 WAIT_PAY 且已经超时的订单,补偿可能丢失的延时消息。
所以更完整的方案是:
延时队列处理主流程定时任务做兜底补偿还要注意削峰
Section titled “还要注意削峰”题目里说某一秒有数以千计订单过期,这其实就是一个瞬时流量峰值。
除了批量更新,还可以做一些削峰处理:
- 多消费者并发消费,但限制每个消费者批量写入频率。
- 将订单按业务线、商户、分库分表键分片处理。
- 批量更新失败时拆分批次重试。
- 对数据库连接池和 MQ 消费速度设置上限。
- 监控积压量,必要时临时扩容消费者。
关键思想是:MQ 可以积压,消费者可以慢慢处理,但数据库不能被突然打爆。
面试时可以这样回答
Section titled “面试时可以这样回答”这道题可以按下面这段话回答:
我不会让数据库在某一秒直接承受大量订单过期更新。订单创建成功后,会把订单 ID 和过期时间写入延时队列,TTL 设置为
expireTime - now()。消息到期后投递给订单过期消费者,消费者先校验订单是否仍是待支付状态,再把消息放入批量缓冲区,按固定数量或固定时间窗口统一批量更新订单状态。更新时 SQL 会带上status = WAIT_PAY和过期时间条件,保证幂等,避免误关已支付订单。订单关闭成功后再发布订单关闭事件,由库存、退款、通知、缓存同步等消费者异步处理。定时任务只作为兜底补偿,不作为主流程。
这段回答基本覆盖了面试官想听的几个关键词:
- 延时队列。
- 批量处理。
- 幂等。
- 削峰。
- 事件驱动。
- 兜底补偿。
这道题的重点不是“怎么把订单改成过期”,而是怎么在大量订单同时过期时保护数据库。
比较合理的设计是:
订单创建时写延时队列到期后消费者接收消息消费者聚合订单并批量更新更新时保证幂等关闭后发布业务事件定时任务做兜底补偿电商系统里很多问题都不是单点逻辑难,而是峰值、幂等、补偿、解耦这些工程问题难。这个题目正好把这些点串到了一起。