先说一个很多开发者都会碰到、也是订单系统里绕不开的需求:SpringBoot 商城项目里,用户下单后 30 分钟内没支付,系统要自动把订单改成已取消,同时把锁定的库存释放掉。不少人第一反应是写个定时器,每分钟扫一下订单表,扫到超时未支付就更新状态。这个思路没错,但真正跑到生产环境,会撞上超卖、重复取消、数据库锁表、消息丢了一堆坑。
我前前后后做过几个订单相关的系统,今天就把这个“订单 30 分钟自动取消”的完整思路、主流实现方案、以及真实项目里会踩到的坑一次性讲清楚。不是只丢一堆“高大上”方案,而是会告诉你每种方案到底适合什么场景、代码该注意什么、出了问题怎么排查。如果你手头正好在做电商、外卖、预约这类带支付超时逻辑的系统,这篇应该能让你少走不少弯路。
1. 需求分析与方案选型思路
1.1 先搞清楚“30分钟自动取消”到底在干什么
从技术角度看,这个需求本质上是“延迟任务”:一个事件需要在指定时间之后被触发。用户下单时生成一笔待支付订单,如果 30 分钟后仍未支付,就需要触发取消逻辑。注意,这里不只是把订单状态改成“已取消”这么简单,涉及的动作通常包括:
- 修改订单状态为“已取消”或者“超时关闭”;
- 释放下单时锁定的库存;
- 退回使用的优惠券、积分;
- 如果是对接第三方支付,可能还要调用支付平台接口关闭订单,避免用户之后还能付款;
- 给用户发一条通知,告诉他订单已超时关闭。
所以,在选方案之前,先要把业务动作理清楚。如果只是“取消订单状态”,那怎么做都行;但一旦牵涉到库存释放、优惠券退回,就涉及分布式环境下的一致性问题,方案的可靠性要求会高很多。
另外要注意“30分钟”这个数字的真实含义。绝大多数业务场景并不要求精确到秒,晚处理个几秒甚至一两分钟,用户感知不强。也就是说,“30分钟自动取消”通常允许一定的延迟,只要在业务可接受的时间范围内触发即可。这给了我们很大的选型空间:完全可以用简单的定时任务,而不必非得引入复杂的消息队列。
1.2 主流的 4 种实现方案横向对比
我把实际项目里比较常见的方案整理成了一张表,方便你对照着判断:
| 方案 | 实现思路 | 实时性 | 可靠性 | 复杂度 | 适合场景 |
|---|---|---|---|---|---|
| 定时任务扫表 | Spring 定时任务定期扫描“待支付且超时”的订单并批量取消 | 分钟级,取决于扫描频率 | 较高,数据库持久化不易丢 | 低 | 中小规模订单量,日常万单以下最稳 |
| Redis 过期键监听 | 下单时写一个带过期时间的 Redis key,key 过期时通过事件通知业务取消订单 | 取决于 Redis 过期触发机制,高负载下延迟可能很大 | 一般,Redis 重启或事件积压会丢 | 中等 | 对实时性要求不高,且有额外兜底 |
| Redis 延迟队列 | 用 ZSet + 轮询,score 存过期时间戳,到期后拉取处理 | 秒级,可控性较强 | 较高,但 Redis 数据非 100% 持久 | 中高 | 已经有 Redis,想提升实时性 |
| 消息中间件延迟消息 | RabbitMQ 延迟插件 / RocketMQ 定时消息 / 死信队列 | 秒级到分钟级 | 高,依赖于 MQ 的可靠性保障 | 较高 | 大流量、团队有 MQ 基础设施 |
这张表里我最想强调的是:不要一上来就选最复杂的消息中间件方案。订单量没有大到一定程度时,定时任务扫表完全够用,而且数据库天然具备持久化能力,出问题也容易排查。
1.3 选型的关键判断标准
我的判断标准其实很简单,就三条:
第一,业务允许的误差时间是多少。如果订单超过 30 分钟后才被取消,用户会不会投诉?如果晚几分钟无所谓,定时任务扫表完全可行;如果业务要求非常准时的“30 分 00 秒”触发,那就要考虑 Redis 延迟队列或 MQ。
第二,团队已经具备哪些基础设施。如果项目里已经有 RabbitMQ 或 RocketMQ,那直接用延迟消息不增加太多成本;如果连 Redis 都没用上,只是为了这个需求硬塞一个中间件进来,运维成本不划算。
第三,数据一致性要求有多高。说白了,取消订单最怕的就是“订单状态改成了已取消,但库存没释放”或者“库存释放了,但订单又支付成功了”。数据库扫描方案因为可以直接事务控制,反而更容易做好一致性;而消息中间件方案还需要额外考虑消费失败、消息积压、重复消费的问题。
我先把这个选型思路讲清楚,下面再逐个方案拆开说。
2. 定时任务扫表:最简单,也最容易踩坑
2.1 SpringBoot 定时任务基础搭建
先来一个最直接的实现。SpringBoot 项目里要开启定时任务,只需要在启动类上加上@EnableScheduling,然后写一个带有@Scheduled注解的方法即可。比如:
@SpringBootApplication @EnableScheduling public class OrderApplication { public static void main(String[] args) { SpringApplication.run(OrderApplication.class, args); } }订单取消服务可以这样写:
@Service public class OrderAutoCancelService { private static final Logger log = LoggerFactory.getLogger(OrderAutoCancelService.class); /** * 每5分钟扫描一次,取消超过30分钟仍未支付的订单 */ @Scheduled(cron = "0 */5 * * * ?") public void cancelExpiredOrders() { log.info("开始扫描超时未支付订单"); // 1. 查询超时订单 // 2. 批量取消 // 3. 释放库存等后续动作 log.info("扫描结束"); } }cron表达式我一般写成每隔 5 分钟执行一次,具体频率要看业务容忍度。如果希望更实时,可以改成每分钟执行一次,但要注意对数据库的压力。
2.2 核心 SQL 和代码逻辑:别一次性扫全表
很多人第一版代码会这样写:查询所有创建时间小于“当前时间减30分钟”且状态为待支付的订单,然后循环逐条更新。这在数据量小的时候没问题,但订单表一大,每次全表范围扫描会把数据库拖垮。
我推荐的做法是“时间窗口 + 分批处理”。例如当前时间是 14:00,需要取消的是 13:30 之前创建的待支付订单,但不要一次查出所有 13:30 之前的数据,而是按 5 分钟一个窗口去扫,每次只处理一批。
先看核心 SQL。假设订单表是orders,状态字段status中 1 表示待支付,2 表示已取消:
SELECT id FROM orders WHERE status = 1 AND create_time < #{cutoffTime} ORDER BY id LIMIT #{batchSize}然后用这批 id 去做状态更新:
UPDATE orders SET status = 2, cancel_time = NOW() WHERE status = 1 AND id IN (#{ids})注意更新条件里必须带上status = 1。这一步是幂等的关键。如果订单刚好被用户支付了,状态已经变成“已支付”,那这个 UPDATE 受影响行数就是 0,不会把已支付订单改成已取消。
在代码里,我习惯用类似这样的方式循环处理:
LocalDateTime cutoffTime = LocalDateTime.now().minusMinutes(30); int batchSize = 200; while (true) { List<Long> batchIds = orderMapper.selectExpiredIds(OrderStatus.PENDING_PAY, cutoffTime, batchSize); if (batchIds.isEmpty()) { break; } int updated = orderMapper.cancelByIds(batchIds, OrderStatus.PENDING_PAY, LocalDateTime.now()); if (updated > 0) { // 释放库存、退优惠券等,建议放到独立的可靠事件里处理 realseStockAndCoupon(batchIds); } if (batchIds.size() < batchSize) { break; } }这里有几个细节需要强调:
LIMIT分批必须锁住边界,避免一次加载太多对象导致内存溢出;- 每次更新后都要判断实际更新行数,因为可能有部分订单在查询与更新之间被用户支付掉了;
- 库存释放操作不要和订单更新放在同一个事务里,否则事务时间过长会锁表。
2.3 联合索引和查询性能优化
如果订单量大,扫描时一定要建索引。不要只给create_time建索引,也不要只给status建索引,要建联合索引:
ALTER TABLE orders ADD INDEX idx_status_create_time (status, create_time);为什么是(status, create_time)而不是(create_time, status)?因为查询条件里status = 1是等值匹配,create_time < ?是范围匹配。联合索引最左前缀原则下,(status, create_time)能走索引并且对create_time做范围扫描,效率更高。
还有一种做法:不用<,而是固定扫一个时间区间。比如每次在定时任务里取“当前时间减 30 分钟”作为时间上限,再取“当前时间减 35 分钟”作为时间下限,只处理这个区间内的订单。这样每次范围都很小,对数据库更友好。代码可以这样改:
LocalDateTime now = LocalDateTime.now(); LocalDateTime end = now.minusMinutes(30); LocalDateTime start = end.minusMinutes(5); List<Long> batchIds = orderMapper.selectExpiredIdsInRange( OrderStatus.PENDING_PAY, start, end, batchSize);这种写法适合扫描频率固定、订单创建时间均匀的场景。如果订单创建有明显高峰,时间窗口不要设得太死,否则可能漏掉一些时序上的“毛刺”,可以考虑宽一点的窗口配合 SQL 的BETWEEN ... AND ...。
2.4 集群部署时必须加分布式锁
很多项目一上生产就是多实例部署,同一个定时任务会同时跑在多台机器上。如果没有锁,两个实例同时扫描到同一批超时订单,可能出现重复取消。虽然status = 1条件能保证最终只有一个 UPDATE 成功,但另一台机器明明更新行数为 0,却还是去释放库存,就会出大事。
解决方案简单粗暴:引入分布式锁。常见的思路有:
- 使用 Redis 的
SETNX加锁,但要注意设置锁过期时间,防止实例宕机后锁不释放; - 使用数据库悲观锁,例如
SELECT ... FOR UPDATE,但依赖数据库连接; - 使用成熟组件 ShedLock,它本身支持基于 JDBC 或 Redis 的锁。
我比较推荐 ShedLock,配置很轻量。依赖加入后,只需要在方法上加注解:
@Scheduled(cron = "0 */5 * * * ?") @SchedulerLock(name = "cancelExpiredOrders", lockAtMostFor = "5m", lockAtLeastFor = "1m") public void cancelExpiredOrders() { // ... }lockAtMostFor一定要设置成比任务最大执行时间稍长的值,否则实例宕机时锁永远不释放;lockAtLeastFor则是为了避免任务执行太快导致锁刚被释放就被另一个实例抢到。
2.5 定时扫表方案的优缺点总结
这个方案最核心的优点是:实现简单、不依赖额外组件、数据库天然持久化、出问题好排查。缺点也很明显:实时性受扫描频率限制,订单量极大时扫描对数据库有压力。
虽然听起来不够“高大上”,但我真心建议中小项目先从这个方案做起。等订单量上来了,再在此基础上叠加其他方案也不迟。
3. Redis 延迟方案:实时性好,但坑也多
3.1 基于 Redis 过期键监听的实现原理
用户下单后,往 Redis 写一个带过期时间的 key:
redisTemplate.opsForValue().set( "order:will_cancel:" + orderId, orderId, 30, TimeUnit.MINUTES );然后监听 Redis 的过期事件,当 key 过期时,通过事件通知业务层取消订单。Spring Data Redis 提供了比较方便的事件监听机制,可以先注册一个RedisMessageListenerContainer,然后监听__keyevent@*__:expired这个主题:
@Configuration public class RedisExpiredListenerConfig { @Bean public RedisMessageListenerContainer redisMessageListenerContainer( RedisConnectionFactory connectionFactory) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); container.addMessageListener( expiredListener(), new PatternTopic("__keyevent@*__:expired")); return container; } @Bean public MessageListener expiredListener() { return (message, pattern) -> { String key = message.toString(); // 如果 key 符合“order:will_cancel:”前缀,解析订单号并取消 }; } }同时,为了让过期事件能发出来,Redis 服务端需要开启键空间通知。在redis.conf中配置:
notify-keyspace-events ExE表示 key 事件,x表示过期事件。
3.2 过期键监听最大的坑:延迟不可控
用这个方案之前,必须先清楚一个事实:Redis 的过期事件并不是 key 在理论过期时间的那一刻精准触发的。Redis 删除过期 key 有两种机制:惰性删除和定期删除。惰性删除是指有请求访问该 key 时才判断是否过期;定期删除是每隔一段时间抽查一批。这意味着,如果一个过期 key 一直没有被访问,可能很久都不会被主动删除,事件也就迟迟不来。
在实际生产环境,高负载下 Redis 的过期事件延迟几分钟甚至更久都很常见。如果你的业务能接受“超过 30 分钟 + 几分钟”才取消订单,那这个方案没问题;但如果业务认为过期 1 分钟就是事故,那就不能用它作为主方案。
另外,Redis 过期事件是一次性的,如果消费者处理失败,这个事件就丢了。所以如果需要可靠取消,必须另做兜底。我在实际项目里只把它当作“辅助手段”,核心逻辑仍然是数据库扫表。
3.3 更可控的玩法:Sorted Set + 轮询延迟队列
如果你想要比定时任务扫表更实时,又不希望依赖额外消息中间件,可以考虑用 Redis 的 ZSet 做一个延迟队列。思路很简单:
- 订单创建时,将订单号作为 member,过期时间戳作为 score 写入一个有序集合;
- 后台用一个线程循环执行
ZRANGEBYSCORE delay_queue 0 now LIMIT 0 100,取出到期的订单; - 处理之前先执行
ZREM,谁成功删除,谁就负责处理这条订单。
代码框架大概是:
public void addDelayOrder(Long orderId, long expireAt) { redisTemplate.opsForZSet().add("delay:auto_cancel", orderId.toString(), expireAt); } public void processDelayOrders() { while (true) { Set<String> ids = redisTemplate.opsForZSet() .rangeByScore("delay:auto_cancel", 0, System.currentTimeMillis(), 0, 100); if (ids.isEmpty()) { sleep(500); continue; } for (String id : ids) { Long removed = redisTemplate.opsForZSet().remove("delay:auto_cancel", id); if (removed != null && removed > 0) { // 抢到这条订单,执行取消逻辑 cancelOrder(Long.valueOf(id)); } } } }这里ZREM的返回值很关键:如果多个节点同时处理同一批到期订单,只有一个节点能成功删除,另一个节点的返回值为 0,就知道不是自己的责任,直接跳过。相当于用 Redis 做了一个天然互斥的“抢单”机制。
但这个方案也不是没有坑:
- 如果
cancelOrder抛异常,订单数据已经不在 ZSet 里了,需要有一个重试队列或兜底扫描; - Redis 实例宕机或主从切换时,ZSet 数据可能丢失;
- 单个线程可用 ZSet 不够;多线程消费时要注意边界偏移。
所以,使用 Redis 延迟队列的时候,我依然会保留一个数据库定时任务做最终兜底。Redis 只是用来加速触发,数据库才是最终判官。
3.4 Redis 方案的实际应用建议
综合来看,如果你的团队已经重度依赖 Redis,且不想引入消息队列,可以考虑“Redis ZSet 延迟队列 + 定时扫表兜底”的组合。实时性比纯扫表高,可靠性由数据库兜底,复杂度也不算高。
但我不太建议把过期键监听作为唯一方案,原因前面已经说了:延迟不可控且事件不可靠。如果你真的要用,也一定要想清楚失败补偿怎么做。
4. 消息中间件延迟消息:高并发下的正式方案
4.1 RabbitMQ 的延迟交换机实现
如果项目里已经有 RabbitMQ,实现延迟消息最自然的方式是使用x-delayed-message插件。它会提供一种专门的交换机类型,发送消息时可以指定延迟时间,消息到了延迟时间后才会路由到队列。
SpringBoot 里声明延迟交换机、队列和绑定关系时,代码大概是这样:
@Configuration public class RabbitDelayedConfig { @Bean public CustomExchange delayedExchange() { Map<String, Object> args = new HashMap<>(); args.put("x-delayed-type", "direct"); return new CustomExchange("order.delayed.exchange", "x-delayed-message", true, false, args); } @Bean public Queue orderDelayQueue() { return new Queue("order.delay.queue", true); } @Bean public Binding binding() { return BindingBuilder.bind(orderDelayQueue()) .to(delayedExchange()) .with("order.cancel.delay") .noargs(); } }发送消息时,在 MessagePostProcessor 里设置消息头的x-delay为 30 分钟对应的毫秒数:
rabbitTemplate.convertAndSend("order.delayed.exchange", "order.cancel.delay", orderId, message -> { message.getMessageProperties().setDelay(30 * 60 * 1000); return message; });消费者里就是普通的@RabbitListener:
@RabbitListener(queues = "order.delay.queue") public void onOrderCancelDelay(Long orderId) { cancelOrder(orderId); }这个方案实时性不错,延迟时间可以精确到毫秒。但前提是 RabbitMQ 部署了对应插件。如果不想用插件,也可以用 TTL + 死信队列模拟延迟消息,但会有“队首阻塞”问题:队列里前一条消息的 TTL 未到,即使后面的消息 TTL 更短,也不会被消费。所以能用插件的情况下尽量用插件。
4.2 RocketMQ 的定时消息
RocketMQ 本身支持延迟消息,发送时可以指定延迟等级:
Message message = new Message("ORDER_CANCEL_TOPIC", orderId.getBytes()); message.setDelayTimeLevel(18);RocketMQ 默认的延迟等级一般是1s 5s 10s 30s 1m 2m ... 30m ...,不同版本配置略有差异。如果你想延迟 30 分钟,需要确认自己版本的等级对应关系。
用 RocketMQ 的优点是:消息可靠、高吞吐、自带重试机制;缺点是:如果是自建 RocketMQ,运维成本比 RabbitMQ 高;而且默认延迟等级是固定的,如果需求是 35 分钟,可能要通过修改 Broker 配置或者自定义delayTimeLevel来实现。
4.3 消息方案的核心:如何保证不重复取消、不丢失
用消息中间件,最大的担忧是消息丢失和重复消费。
消息丢失通常发生在生产者发送失败或消费者处理失败之后没有重试。解决办法是:发送前先把业务数据落库,发送时确认 Broker 返回成功;消费失败时不要直接吞掉异常,交给 MQ 的重试机制,或者进入死信队列人工处理。
重复消费是另一个大问题。例如消费者处理超时导致 MQ 重新投递,同一个订单被处理两次。这时候就回到我前面强调的“幂等设计”:
- 先查询订单当前状态;
- 只有“待支付”状态才执行取消;
- 更新订单状态时必须带上
status = 待支付条件,用受影响行数判断是否成功。
我把取消订单的核心逻辑提炼成了一段伪代码:
public boolean cancelOrder(Long orderId) { Order order = orderMapper.selectById(orderId); if (order == null || order.getStatus() != OrderStatus.PENDING_PAY) { return false; } int updated = orderMapper.updateStatusWithCondition( orderId, OrderStatus.PENDING_PAY, OrderStatus.CANCELED, LocalDateTime.now()); if (updated == 0) { // 说明没有更新成功,可能已经被支付或取消,直接返回 return false; } // 到这里才是真正“抢到”取消资格,可以异步释放库存等 releaseStock(order.getUserId(), order.getItems()); return true; }这段逻辑在定时扫表方案、Redis 延迟队列方案和 MQ 方案里都适用。先保证数据库状态流转正确,再去处理外围动作。
4.4 消息方案的价值和成本
消息中间件方案最大的价值是解耦和削峰。订单入口不关心取消逻辑什么时候执行,只需要发一条延迟消息;取消逻辑由 MQ 消费者异步处理,不会阻塞主流程。而且 MQ 本身具备消息堆积和重试能力,系统扩展性更好。
但对我来说,它永远不能完全替代数据库兜底。为什么?因为消息中间件也是有概率出问题的,比如 RabbitMQ 节点宕机、RocketMQ Broker 集群故障、消息积压太久。最保险的做法是“MQ 负责按时提醒,数据库定时任务负责最终保障”。
5. 实操中我踩过的坑,以及排查方法
5.1 时间基准不统一导致提前取消或漏取消
有一个特别容易忽略的问题:订单表里的create_time是数据库写入的时间,而 Java 定时任务里用的是应用服务器的时间。如果应用服务器和数据库服务器时间相差几十秒甚至几分钟,就会出现边界尴尬。
比如数据库时间比应用服务器时间慢了一分钟,应用以为已经过了 30 分钟,实际上数据库里订单才创建了 29 分钟,这时候就可能提前取消掉一个刚下单还没支付完的用户订单。
我的规避办法比较简单:尽量让所有服务器统一用 NTP 时钟同步;如果做不到,SQL 里直接用数据库时间作为判断基准:
UPDATE orders SET status = 2, cancel_time = NOW() WHERE status = 1 AND create_time < DATE_SUB(NOW(), INTERVAL 30 MINUTE)这样判断标准完全落在数据库,不会受应用服务器时钟影响。
5.2 取消和支付同时发生,订单状态被改错
这是最经典的并发问题。用户刚好在超时边缘发起支付,支付回调线程和定时取消线程同时读到“待支付”状态,然后一个去更新为“已支付”,一个去更新为“已取消”。如果不加控制,有可能支付成功了订单却被取消,或者订单取消了但支付回调又把状态改成已支付。
解决方案从两层入手:
第一层,业务操作前先查一次订单状态,只在“待支付”时继续操作;第二层,更新语句一定要加状态条件。更严格一点,可以在事务里用SELECT ... FOR UPDATE锁定订单行,保证同一笔订单的支付和取消串行化。
如果是支付回调,也要在回调处理逻辑里先锁订单状态,再执行后续操作:
@Transactional public void handlePayCallback(Long orderId) { Order order = orderMapper.selectByIdForUpdate(orderId); if (order.getStatus() == OrderStatus.PENDING_PAY) { order.setStatus(OrderStatus.PAID); orderMapper.updateById(order); } }定时取消逻辑同样先锁订单再判断:
@Transactional public void cancelOrder(Long orderId) { Order order = orderMapper.selectByIdForUpdate(orderId); if (order.getStatus() == OrderStatus.PENDING_PAY) { order.setStatus(OrderStatus.CANCELED); order.setCancelTime(LocalDateTime.now()); orderMapper.updateById(order); } }有了行锁,再配合状态判断,基本能把并发问题堵住。
5.3 订单状态更新成功,库存却释放失败,导致超卖
假设订单取消事务提交成功,但后续释放库存的接口调用失败,库存就没有回到可用库存里,下次用户下单就可能买不到货,或者后台库存显示不足。反过来,如果在释放库存成功之后订单取消事务才提交,万一事务回滚,库存就多出来一份。
我踩过最惨的一个坑:直接在取消订单方法里调用库存服务,依赖分布式事务去保证一致性。结果库存服务偶尔超时,整个取消逻辑频繁回滚,订单一直处于待支付状态,用户投诉一个接一个。
后来我改成了“事件表 + 异步重试”的方式。流程如下:
- 在订单取消事务里,写一条“库存释放任务”到本地事件表,和订单状态变更同库同事务提交;
- 事务提交后,异步任务扫描事件表,调用库存服务;
- 调用成功后将事件标记为完成;
- 调用失败则保留重试状态,由重试任务不断重试,直到成功。
这套方案不需要可靠消息中间件,也能做到最终一致。核心思想是:订单状态先确定下来,外围动作异步慢慢追。
5.4 定时任务扫描慢,导致任务堆积
@Scheduled默认是单线程串行执行,如果上一次扫描还没结束,下一次扫描会排队等待。订单量上来后,扫描一次可能要几十秒,扫描频率越高,任务堆积越严重,最后可能一个任务跑了 20 分钟,完全失去意义。
我的处理方式有两个。一是调大单次扫描的批处理效率,比如减少无谓的重复查询、批量更新、合理设置 batchSize;二是给定时任务配置独立线程池:
@Bean("orderCancelTaskExecutor") public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); scheduler.setPoolSize(2); scheduler.setThreadNamePrefix("order-cancel-scheduler-"); return scheduler; }然后在@Scheduled对应的方法里通过@Async走异步执行。但要注意:一旦用多线程并发执行任务,分布式锁必须跟上,否则两个线程同时处理同一批订单,又会出现重复取消。
5.5 Redis key 过期监听不生效,查查这几个配置
如果你用了 Redis 过期键监听却一直收不到事件,先按顺序排查:
notify-keyspace-events是否正确配置为Ex;- Redis 版本是否支持 keyspace notifications;
- Spring Data Redis 的
PatternTopic是否和 Redis 库号匹配,比如你使用的是 db 0,那监听__keyevent@0__:expired才准确; - 是否只是测试环境连了本地 Redis,而生产 Redis 没有配置。
另外,要注意 key 本身的过期时间必须是“没有更新操作”的前提。如果某个 key 在过期前被重新设置了值,那么 Redis 会以最后一次设置的过期时间为准,这个行为很容易被忽略。
6. 个人实战经验总结
如果非要给一个“最稳”的组合,我会说:数据库定时任务扫表打底,Redis ZSet 延迟队列或 MQ 负责提供实时性。
具体到不同的项目阶段,我是这样建议的:
- 日订单量几千、万单水平,直接用定时任务扫表,加联合索引,加分布式锁,先保证不超卖、不重复取消;
- 订单量涨上来且已有 Redis,上一个 Redis ZSet 延迟队列,把触发时间精确到秒,后台线程去消费,定时任务仍然每 5 分钟兜底一次;
- 如果团队已经不差钱且运维能力足够,上 RabbitMQ 延迟消息或 RocketMQ 定时消息,同时一定保留数据库扫表兜底。
我做过好几个订单系统,说句真心话,从来没有哪种方案是银弹。有一回我把 Redis 过期键监听当成主方案,生产环境高峰期事件延迟了十几分钟,业务方直接炸毛。后来加了一个每 5 分钟扫表的兜底,这个问题才彻底消停。所以现在我对所有延迟任务的习惯都是统一的:任何中间件方案都只是“加速器”,数据库级别的兜底才是真正的安全网。
如果你正在写订单 30 分钟自动取消,我建议第一版就老老实实用定时任务扫表。等跑通了,再根据实际瓶颈考虑要不要引入更“高级”的方案。先把状态机想清楚,把幂等做好,比用什么技术方案都重要。