ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpringBoot集成Redis实现订单超时管理:过期通知与ZSet延迟队列两种方案

2026/10/5 7:22:37 拓冰建站 浏览量
SpringBoot集成Redis实现订单超时管理:过期通知与ZSet延迟队列两种方案 做后台开发这么多年订单超时这种需求我至少接了四五次。下单后15分钟未支付自动关单、优惠券半小时不用自动失效、拼团超时未成团自动退款……几乎每个交易系统都逃不过这一关。我以前也是条件反射式地想到定时任务每5秒扫一次订单表把超时的订单捞出来改状态。后来订单量增长这套方案在数据库层面开始吃不消我才认真研究SpringBoot集成Redis来做订单超时管理。今天把这次改造完整整理出来两种主流实现Key过期通知和ZSet延迟队列的原理、代码、实测结果还有生产环境里踩过的坑能帮一个是一个。1. 定时任务扫库的痛点频率、数据库压力和补偿逻辑1.1 扫描频率和延迟永远在打架先还原一下最常见的扫库写法。项目里加一个Scheduled注解方法配置成每5秒执行一次查询条件大体长这样select id from t_order where status WAIT_PAY and create_time now() - interval 15 minute;这个SQL本身不复杂一开始订单只有几万条时有create_time索引基本没什么压力。但订单量上到百万、五百万以后问题就暴露了你为了把超时误差控制在可接受范围内扫描频率不能太低频率一高每隔5秒就要在整张表里范围扫描近15分钟创建的数据数据库CPU和IO被反复拉高。更尴尬的是扫描频率和超时延迟天生是矛盾的。5秒扫一次一个订单实际超时后可能要等5秒甚至更久才被发现如果觉得这样负载太高改成30秒扫一次用户就会遇到“说好的15分钟结果15分半才关单”的体验问题。1.2 数据库在高峰期有多受伤扫库的第二个痛点是数据库锁和连接资源。每5秒发起一次查询再批量更新一批超时订单这些更新会持有行锁。高并发场景下业务请求更新同一批待支付订单时就会被阻塞主库连接池也会被定时任务长期占用一部分连接。我们线上订单表到500万行时定时任务把主库IO打到接近瓶颈主从延迟翻了好几倍最后DBA直接找上门来要求停掉扫库任务。有人说那我在凌晨低峰期跑不就行了问题是订单超时是用户侧的实时需求不是离线报表任务。凌晨跑意味着白天下的单要第二天才关用户早就投诉了。所以只要订单量还过得去扫库方案早晚要换。1.3 批量处理失败的补偿陷阱还有一层更麻烦的批次补偿。扫库捞出来一批订单ID你要逐个执行回库存、退优惠券、发通知。任何一个订单处理失败你都得考虑下次要不要再扫它一遍。如果不再扫这个订单就漏了如果重新扫就得给订单表加一个“是否已执行关单逻辑”的标记。随着业务状态变多这个定时任务会从一条SQL长成一个内部管理系统。很多团队搞到最后发现自己不是在做一个超时提醒而是被迫写了一套“扫库批处理框架”。这种复杂度是业务强加的不是技术必须承担的。2. Redis管理订单超时的两种核心原理过期通知与ZSet延迟队列2.1 闹钟模式Key过期通知是怎么工作的Redis的Key可以设置TTL到时间后key会被删除。删除的同时Redis会往__keyeventdb__:expired这个频道发送一条消息内容就是被删除的key本身。订阅这个频道收到消息时就知道某个key到点了。这就像睡前定一个闹钟闹钟响了你就知道时间到了。这里有个关键细节必须清楚Redis不是到点立刻删key的。它的删除策略是惰性删除加定期删除。惰性删除是key被访问时才发现它过期了定期删除是每隔一段时间随机抽查一批过期key。所以一个key到了过期时间可能不会立刻被Delete过期事件自然也就会延后。我实测下来延迟从几毫秒到几十秒都有可能。另外过期通知只给你key的名字不给value。所以业务信息最好直接拼在key里比如order:timeout:{orderId}。2.2 时间表模式ZSet延迟队列的玩法ZSet是Redis里一个带有score排序权重的Set集合。每个成员可以理解成带一个数字Redis会自动按这个数字排序。我们完全可以用ZSet做一张“按时间排好序的待办任务表”成员放订单IDscore放超时那一刻的Unix时间戳。消费者线程每隔一小段时间问一次有没有score已经小于等于当前时间的成员有就取出来执行关单。打个比方这更像一张便签纸每个任务后面写着截止时间。消费者每隔一段时间翻一遍把过期的任务挑出来做掉。整个过程不碰数据库所有的排序和查找都在Redis内存里完成成本和订单表总行数没有关系只和“当前堆了多少个超时任务”有关。2.3 和定时任务扫库的本质区别扫库的逻辑是“全表翻一遍问哪些记录到点了”Redis方案是“每条订单建一个独立提醒到点直接取这条”。一个是遍历一个是定位复杂度模型完全不同。对于订单超时这种每个订单只产生一个任务、任务总量可控的场景Redis自然更合适。另外Redis的原子操作对多实例部署特别友好。ZSet的zrem命令天然保证同一时间只有一个实例能移除同一个成员规避了扫库方案里多实例并发重复处理的难题。3. 方案一落地SpringBoot集成Redis实现Key过期监听3.1 基础依赖和连接配置用SpringBoot集成Redis很简单核心是引入spring-boot-starter-data-redis。我这里的示例基于SpringBoot 2.7如果你用的是SpringBoot 3.x需要注意javax改成jakarta但Redis相关代码基本不变。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency配置文件里指定Redis连接spring: redis: host: 127.0.0.1 port: 6379 database: 0 timeout: 3000ms记得确认数据库序号。这里用database: 0后面订阅过期事件时频道名里的db号必须和这里一致。3.2 让Redis把过期事件广播出来Redis默认不开启key过期事件通知需要手动打开。临时生效用命令redis-cli config set notify-keyspace-events Ex想持久化就改redis.confnotify-keyspace-events Ex注意这个配置字符串必须同时包含E和x。E代表开启key事件通知x代表订阅过期事件。网上不少教程只让写x结果事件根本收不到。如果你不小心设了AKE所有key的增删改事件都会通知过来日志量会非常恐怖。验证是否生效redis-cli config get notify-keyspace-events如果返回Ex说明配置成功。托管Redis实例可能禁用了config set那就去云控制台找“键空间通知”之类的开关。3.3 监听器实现KeyExpirationEventMessageListenerSpring Data Redis给我们封装好了监听器基类KeyExpirationEventMessageListener它会自动订阅__keyevent*__:expired频道。我们只需要注入一个RedisMessageListenerContainer的Bean再写一个监听器继承它。Configuration public class RedisListenerConfig { Bean public RedisMessageListenerContainer redisMessageListenerContainer( RedisConnectionFactory connectionFactory) { RedisMessageListenerContainer container new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); return container; } }监听器代码Component public class OrderTimeoutListener extends KeyExpirationEventMessageListener { private static final String TIMEOUT_KEY_PREFIX order:timeout:; private final OrderTimeoutService orderTimeoutService; public OrderTimeoutListener(RedisMessageListenerContainer listenerContainer, OrderTimeoutService orderTimeoutService) { super(listenerContainer); this.orderTimeoutService orderTimeoutService; } Override public void onMessage(Message message, byte[] pattern) { String expiredKey new String(message.getBody(), StandardCharsets.UTF_8); if (!expiredKey.startsWith(TIMEOUT_KEY_PREFIX)) { return; } String orderId expiredKey.substring(TIMEOUT_KEY_PREFIX.length()); orderTimeoutService.closeExpiredOrder(orderId); } }这个监听器会被所有key的过期事件触发所以startsWith过滤必须放在最前面否则你会收到一大堆验证码key、session key的无关消息。3.4 下单时写入超时Key支付成功后记得删除下单时除了把订单状态写入数据库还要往Redis里写一个带过期时间的keyService public class OrderService { private static final String TIMEOUT_KEY_PREFIX order:timeout:; private final OrderMapper orderMapper; private final StringRedisTemplate redisTemplate; public Order createOrder(CreateOrderRequest request) { Order order new Order(); order.setStatus(WAIT_PAY); // 其他业务字段省略 orderMapper.insert(order); String timeoutKey TIMEOUT_KEY_PREFIX order.getId(); redisTemplate.opsForValue().set( timeoutKey, String.valueOf(order.getId()), 15, TimeUnit.MINUTES); return order; } }注意一点过期事件只把key发给你value是拿不到的。所以key里一定要能解析出业务标识。存一个orderId就够了没必要把整张订单JSON塞进去。支付成功后的回调里要主动把这个key删掉public void handlePaySuccess(String orderId) { orderMapper.updateStatusToPaid(orderId); redisTemplate.delete(TIMEOUT_KEY_PREFIX orderId); }如果不删这个key到时间照样过期逾期事件照样触发。虽然后面的幂等判断会拦住关单逻辑但会白白多一次无效调用日志里也会出现“已支付订单被触发超时”的误报。3.5 本地验证流程跑通这套方案很快。先启动Redis并配置notify-keyspace-events再启动SpringBoot应用。然后打开一个redis-cli终端订阅过期事件redis-cli psubscribe __keyevent0__:expired调用创建订单接口测试时可以把过期时间临时改成10秒。10秒后psubscribe终端能看到一个key冒出来应用日志同时打出“订单xxx触发超时开始处理关单逻辑”说明整套链路已经跑通。如果订阅没反应优先排查两点notify-keyspace-events里的Ex是不是都写对了Spring连的db是不是0。4. 方案二落地基于ZSet延迟队列实现订单超时处理4.1 入队把超时时间戳当成score用ZSet做延迟队列不需要开启任何通知机制只需要几个常规Redis命令。核心key用一个固定名称比如order:delay:queue。下单时把订单ID塞进去score用超时时间点的毫秒时间戳String queueKey order:delay:queue; long expireAt System.currentTimeMillis() 15L * 60 * 1000; redisTemplate.opsForZSet().add(queueKey, String.valueOf(order.getId()), expireAt);这里单位一定要统一。Java的System.currentTimeMillis()返回毫秒score也存毫秒时间戳。如果你后面用秒级时间戳取数据时对不上会莫名延迟或提前。4.2 消费者线程轮询、取出、处理ZSet方案需要一个后台线程不停轮询。在Spring里用InitializingBean和DisposableBean管理线程生命周期比较干净Component public class DelayOrderConsumer implements InitializingBean, DisposableBean { private static final String QUEUE_KEY order:delay:queue; private static final long POLL_INTERVAL_MS 1000L; private final StringRedisTemplate redisTemplate; private final OrderTimeoutService orderTimeoutService; private Thread worker; private volatile boolean running true; public DelayOrderConsumer(StringRedisTemplate redisTemplate, OrderTimeoutService orderTimeoutService) { this.redisTemplate redisTemplate; this.orderTimeoutService orderTimeoutService; } Override public void afterPropertiesSet() { worker new Thread(this::consumeLoop, delay-order-consumer); worker.setDaemon(true); worker.start(); } private void consumeLoop() { while (running) { try { SetString orderIds redisTemplate.opsForZSet().rangeByScore( QUEUE_KEY, 0, System.currentTimeMillis(), 0, 1); if (orderIds null || orderIds.isEmpty()) { Thread.sleep(POLL_INTERVAL_MS); continue; } String orderId orderIds.iterator().next(); Long removed redisTemplate.opsForZSet().remove(QUEUE_KEY, orderId); if (removed ! null removed 0) { orderTimeoutService.closeExpiredOrder(orderId); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } catch (Exception e) { try { Thread.sleep(2000L); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); break; } } } } Override public void destroy() { running false; if (worker ! null) { worker.interrupt(); } } }每次只取一条而不是批量取几百条是我刻意做的取舍。一次处理一个订单事务边界很清晰如果批量取出来后应用在中间崩了这一批任务就全丢了。宁可慢一点也不能丢任务。如果你的关单逻辑很快也可以一次取几十条批量处理但必须做好异常兜底。4.3 zrem是整个方案的精髓rangeByScore只是把符合条件的成员“看一眼”并没有把任务移出队列。真正的消费动作是remove也就是zrem。这一步结果非常关键两个实例同时取到同一个订单ID都执行zrem但Redis的zrem是原子操作只会有一个实例返回1另一个返回0。返回1的实例继续执行关单返回0的直接放弃。这样天然避免了多实例部署时的重复消费不需要额外加分布式锁。顺序也必须是先zrem、再处理业务。如果先处理业务再zrem两个实例就可能同时处理同一个订单如果先zrem但业务处理失败任务就丢了需要补偿机制。4.4 处理失败怎么补偿关单逻辑可能因为库存服务不可用、数据库抖动等原因失败。我的做法是在closeExpiredOrder内部捕获异常把订单ID重新塞回order:delay:queue但是score设成当前时间加30秒相当于延迟30秒重试一次。连续失败三次记录告警日志让值班同学介入。支付成功后也要执行一次zrem把订单ID从延迟队列里移除。不然这个订单到了超时时间照样会被消费者取出来虽然幂等判断会拦住但又是一次无谓调用。记住ZSet里只放“待处理超时任务”任务不需要了第一时间删掉。5. 两种方案怎么选延迟精度、部署复杂度和可靠性对比5.1 差异对照结合生产环境实测我把两套方案的关键差异整理成了表格方便你直接对照维度Key过期通知ZSet延迟队列延迟精度受惰性删除/定期删除影响通常秒级甚至几十秒取决于轮询间隔百毫秒级可控多实例消费所有实例都会收到同一事件需要额外幂等zrem原子操作天然只有一个实例成功额外配置需要开启notify-keyspace-events无需特殊配置人工补偿事件丢了很难追查任务在ZSet里只要消费者活着就能继续处理支付成功兜底主动删key否则过期事件照样触发主动zrem否则任务照样被取出代码量很小监听器加两个方法需要维护一个后台线程和重试逻辑集群支持事件只在key所在节点产生订阅困难读写走cluster路由天然跨节点5.2 我的选型建议如果业务场景只是“订单15分钟未支付自动关闭”这一种订单量不大最坏延迟几十秒也能接受用Key过期通知就够了代码量最少维护成本最低。如果订单量比较大超时后还要联动库存、券、通知等多个系统并要求超时触发尽量准点那就选ZSet延迟队列。尤其是多实例部署场景zrem天然防重复这个优势太实用了。要是用Key过期通知多个实例同时收到同一个过期事件你必须依靠数据库条件更新去兜底逻辑会复杂一圈。如果团队里已经有RabbitMQ或RocketMQ直接用它们的延迟消息机制当然更省心。但很多订单系统不想为了一个关单功能再引入整套消息中间件这种情况下Redis ZSet就是性价比最高的替代方案。Redis Streams也能做延迟队列但消费者组和消息确认都要自己封装复杂度反而上去了。6. 生产环境实测后踩过的坑配置、幂等与集群6.1 过期事件的延迟比你想的大我们在一台Redis 5.0.14实例上做过测试同时给100个key设置相同的过期时间事件到达延迟最短是几毫秒最慢的接近30秒。原因是Redis的定期删除是抽样执行太忙的时候排在后面的过期key要等下一轮才会被处理。所以如果产品经理要的是“15分钟后准时关闭”Key过期通知给不了这种承诺。这时候要么选ZSet要么以ZSet为主、Key过期通知为辅做兜底。把“准点”这个预期降下来你才不会在生产环境被突发的延迟吓到。6.2 notify-keyspace-events配置里的几个坑第一个坑是字符串写法。notify-keyspace-events必须同时包含E和x只写x收不到任何事件。第二个坑是db号不一致。Spring Data Redis连接的db是几频道里的编号就是几。比如连的是db 0那就订阅__keyevent0__:expired连的是db 1事件频道就变成__keyevent1__:expired监听器配置不匹配就收不到。第三个坑是托管Redis实例可能禁用了config set你对着服务器敲半天命令其实没生效要去控制台改配置。6.3 所有key过期都会触发前缀过滤必须放在第一行开启key过期通知后Redis里所有带TTL的key都会参与事件通知包括登录验证码、token、各种缓存key。如果监听器不先做前缀过滤日志一天能刷几万条排查问题时什么都看不清。我在代码里加了很明确的防线if (!expiredKey.startsWith(TIMEOUT_KEY_PREFIX)) { return; }这一步不是为了性能而是为了保护排查效率千万不能省。6.4 支付和超时撞车幂等更新是底线支付和超时并发是订单超时管理里最经典的竞态。用户在下单后第14分59秒发起支付支付回调刚好在超时事件触发前后到达。如果关单逻辑直接无条件更新订单状态就可能把已经支付的订单改成已关闭后面还得退款非常难看。正确做法是条件更新update t_order set status CLOSED where id #{orderId} and status WAIT_PAY;受影响行数为1说明这个订单确实还是待支付状态本次关单有效受影响行数为0说明订单已经不是待支付直接忽略。支付回调改成已支付时也可以带上同样的条件判断防止关单之后又来一个支付回调把已关闭订单改成已支付。两条SQL互为兜底比加简单的业务锁优雅得多。6.5 Redis Cluster环境下更推荐ZSetRedis Cluster有个让人头疼的限制key过期事件只在key实际所在的节点上产生。如果你的客户端通过Spring Data Redis只连接了其中一个节点那么其它节点上的过期事件一概收不到。有些团队为了用过期通知专门写了一个订阅所有master节点的客户端维护成本很高得不偿失。ZSet方案完全不受这个限制。所有读写命令都会走cluster路由key在哪个节点命令就被转发到哪个节点天然不会“漏听”。所以只要Redis是Cluster模式我的第一选择一定是ZSet延迟队列。回到开头那个场景。你可能会觉得定时任务扫库不是挺简单吗写起来确实简单但订单量上来之后频率、延迟、数据库压力、批次补偿、多实例锁这些问题会轮番找你。Redis方案没有多复杂它把“到点了该干什么”这个信号从数据库搬到了更轻量、更合适的中间件上原理吃透之后选一种实现再把幂等和补偿做好这个需求就能稳稳交差。