订单一到时间没支付就要自动关掉,缓存失效以后要通知下游重新拉数据,分布式锁断了要赶紧释放——这类“等到某个 key 过期之后再做点事”的需求,我在业务里反复遇到过。最早做订单超时自动关闭的时候,我用过定时任务扫表,后来数据量上来以后,扫表间隔、数据库压力都挺让人头疼。再后来换成了 Redis 的 key 过期事件监听,配合 Spring Boot 里现成的消息监听器,代码量少了一截,实时性也好了不少。
先说句实话,这个方案本质上不是“可靠延迟队列”。Redis 的 key 过期事件走的是 keyspace notifications 机制,底层是 pub/sub 广播,它最擅长的是“过期以后提醒系统顺手处理一下”,而不是“精确到毫秒、一条都不能丢的定时任务”。如果你正打算在项目里用这个功能,我建议先想清楚业务对实时性和可靠性的要求,再决定要不要上。这篇文章我按 Spring Boot 项目的实践来写,从开启事件通知、写监听器、到生产环境的坑,一条线捋下来。
1. 先搞清楚:为什么要监听 key 过期事件
1.1 哪些业务场景真正需要它
用过这个功能的人,需求通常就集中在这么几类,我按常见程度排一下。
第一类是订单超时关闭,也是最经典的场景。用户下单以后,我们往 Redis 里写一个带过期时间的 key,比如order:expire:{orderId},TTL 设为 30 分钟。等 key 过期,监听器收到消息,拿着订单号去把订单置为“已取消”,同时把库存补回去。整个过程不需要定时任务周期性扫表,事件到的时候业务刚好该执行,实时性比 5 分钟扫一次表好很多。
第二类是缓存失效后的异步通知。比如本地缓存或者 Redis 缓存里有一份热点数据,key 过期后希望通知后台重新从数据库加载,然后把新数据推给各业务方。这种情况下监听 key 过期事件能比定时刷新更精准,至少不会为了“怕过期”而提前很久刷数据。
第三类是分布式锁的辅助处理。锁的 key 设置了过期时间,万一持有锁的线程挂了,锁到期后可以监听这个 key 的过期事件,做个告警或者补偿清理。这类需求其实不算多,因为分布式锁一般靠锁本身的超时机制就够了,但偶尔会有人在锁的过期事件上挂一些监控逻辑。
第四类是临时状态机推进,比如用户申请了某个凭证,48 小时内不激活就自动作废;或者一个限流窗口结束之后需要重置计数。这类需求用过期事件都能做,但要注意,处理完以后不要过度依赖事件的结果,因为 Redis 的 pub/sub 并不保证消息一定送达。
1.2 原生 pub/sub 和 Spring 监听器怎么选
Redis 从 2.8 开始支持 keyspace notifications,原理其实不复杂:当某个 key 发生指定操作(比如过期、删除、修改)时,Redis 会向一个特定频道发布一条消息。消息分两套频道体系:
__keyspace@<db>__:<key>频道,发布的是“这个 key 发生了什么”,消息体是事件类型;__keyevent@<db>__:<key>频道,发布的是“这个事件发生在哪个 key 上”,消息体是 key 名。
监听 key 过期事件,我们关心的是第二类,也就是某个 db 里发生了 expired 事件。说完原理你就明白了,其实不用非得用 Spring 的监听器不可,自己用 Jedis 或者 Lettuce 写个订阅,专门订阅__keyevent@0__:expired频道,一样能收到消息。
但大部分 Java 项目里还是用 Spring 封装好的方案,区别主要在这几点:
| 对比项 | 原生 pub/sub 订阅 | Spring Data Redis 监听器 |
|---|---|---|
| 代码量 | 自己管理连接、订阅线程、重连逻辑 | 声明 Bean 即可,容器自动管理生命周期 |
| 频道订阅 | 要自己写频道名和匹配规则 | 继承类内置__keyevent@*__:expired的 PatternTopic |
| 与业务整合 | 收到消息后要自己反序列化、分发 | 可以直接注入 Service,和 Spring 容器无缝集成 |
| 可维护性 | 适合极简场景 | 适合大多数业务项目,后续加逻辑方便 |
我的建议是,如果项目里已经有 Spring Data Redis,直接用监听器方案;如果只是一个非常轻量的脚本或者不用 Spring 的项目,自己订阅原生频道也完全可以。两者底层没有本质区别,Spring 只是把重复的订阅、线程调度、连接管理封装好了。
2. 第一道开关:让 Redis 把过期事件发出来
2.1 notify-keyspace-events 参数拆解
费了半天劲把监听器写好了,结果消息一直不来,这是新手最容易卡住的地方。原因八成是 Redis 默认根本没开过期事件通知。
Redis 实例上有个配置项叫notify-keyspace-events,默认是空字符串,表示不发送任何 keyspace 通知。它的值由一组字符组合而成,分别控制不同的事件类型:
K:发送 keyspace 事件,也就是往__keyspace@<db>__:<key>频道发消息;E:发送 keyevent 事件,也就是往__keyevent@<db>__:<key>频道发消息;g:DEL、EXPIRE、RENAME 这类通用命令产生的事件;$、l、s、h、z:分别对应字符串、列表、集合、哈希、有序集合命令产生的事件;x:key 过期事件,也就是 key 到期被删除时触发;e:key 被驱逐事件,也就是内存淘汰时触发;A:等于g$lshzxe的简写。
这里最常用的组合是Ex,意思是:在 keyevent 频道发布过期事件。因为我们监听器订阅的是过期事件,所以至少要包含E和x这两个字符。如果你想看到所有 key 的变化,可以配KEA,但在高并发业务里事件量会非常大,没必要的话不要配这么宽。
还有个容易踩的坑:有人只加了E忘了加x,结果频道是订阅上了,但过期事件根本没被发出来。也有人只加了x不加E,事件发了但发到了 keyspace 频道,Spring 自带监听器期待的是 keyevent 频道,一样收不到。所以记住,常规做法就是配成Ex。
2.2 三种部署方式下的配置与验证
不同部署方式,开启配置的方法不太一样,我分别说下。
第一种是 Linux 上直接部署的 Redis 实例,直接编辑 redis.conf,找到notify-keyspace-events这一行,改成:
notify-keyspace-events Ex然后重启 Redis,或者用CONFIG SET热生效:
redis-cli CONFIG SET notify-keyspace-events Ex注意CONFIG SET是临时生效,Redis 重启后就没了,生产环境最好还是改配置文件。用CONFIG SET临时验证倒是很方便。
第二种是 Docker 部署的 Redis。如果你挂载了自定义配置文件,直接改宿主机上的 redis.conf 然后重启容器。如果用的是官方镜像默认配置,可以这样临时开启:
docker exec -it redis容器名 redis-cli CONFIG SET notify-keyspace-events Ex同样,容器重启后临时配置会丢,所以正经做法是启动容器的时候把配置文件挂载进去,比如:
docker run -d --name redis-server \ -v /path/to/redis.conf:/usr/local/etc/redis/redis.conf \ redis:7 redis-server /usr/local/etc/redis/redis.conf第三种是云厂商提供的托管 Redis,一般控制台里没有直接暴露 keyspace notifications 的开关,但基本上都支持通过命令开启。不过有些云数据库会限制CONFIG命令,这种情况需要提工单或者找技术支持确认。
开启之后怎么验证?最简单的方式是开两个终端,一个订阅频道:
redis-cli psubscribe '__keyevent@0__:expired'另一个终端随便设置一个带过期时间的 key:
redis-cli SET test:key hello EX 5等 5 秒左右,第一个终端如果收到了类似__keyevent@0__:expired的消息,说明配置生效了,这时候再去排查应用端的监听器才靠谱。不然应用代码写得再对,收不到消息也是白搭。
3. Spring Boot 里把监听器跑起来
3.1 依赖与基础配置
如果你的项目已经用过 Redis 做缓存,那依赖大概率已经有了。如果从零开始,加这些:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>Spring Boot 2.x 时代默认的客户端是 Lettuce,到了 Spring Boot 3.x 也还是 Lettuce。如果用到了连接池,还需要额外引入:
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>application.yml 里做常规配置:
spring: data: redis: host: 127.0.0.1 port: 6379 password: timeout: 5s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 4这里不涉及太特殊的配置,核心是把连接工厂准备好,后面监听容器要用。
3.2 注册监听容器与监听器
Spring Data Redis 里负责订阅和分发消息的组件是RedisMessageListenerContainer。我们需要先把它注册成 Bean,然后监听器才能挂到它上面。
基本配置类长这样:
@Configuration public class RedisListenerConfig { @Bean public RedisMessageListenerContainer redisMessageListenerContainer( RedisConnectionFactory connectionFactory) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); // 按实际经验,建议给容器设置一个业务线程池 ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(16); executor.setQueueCapacity(500); executor.setThreadNamePrefix("redis-event-"); executor.initialize(); container.setTaskExecutor(executor); return container; } }容器准备好以后,过期事件监听器通过继承KeyExpirationEventMessageListener来实现,这是 Spring Data Redis 提供的现成抽象类。它的构造方法需要传入RedisMessageListenerContainer,并且在构造时会自动订阅__keyevent@*__:expired这个 PatternTopic。
@Component public class RedisKeyExpireListener extends KeyExpirationEventMessageListener { private static final Logger log = LoggerFactory.getLogger(RedisKeyExpireListener.class); public RedisKeyExpireListener(RedisMessageListenerContainer listenerContainer) { super(listenerContainer); } @Override public void onMessage(Message message, byte[] pattern) { String expiredKey = new String(message.getBody(), StandardCharsets.UTF_8); log.info("收到过期 key:{}", expiredKey); // 这里写业务逻辑 } }这段代码在 Spring Data Redis 2.x 里没问题。如果你用的是 Spring Data Redis 3.x(也就是 Spring Boot 3.x),抽象类里的方法签名有变化,不再是重写onMessage,而是要重写doHandleMessage:
@Override protected void doHandleMessage(Message message) throws Exception { String expiredKey = new String(message.getBody(), StandardCharsets.UTF_8); log.info("收到过期 key:{}", expiredKey); // 这里写业务逻辑 }不确定自己项目是哪个版本的话,直接在 IDE 里看父类的方法签名就行,重写哪个方法以 IDE 提示为准。这里我不展开源码,但要注意,网上很多文章写的是onMessage,在新版本里会失效,我自己第一次升级 Spring Boot 3 的时候就因为这个排查了小半天。
3.3 业务代码与消息解析
消息体本身很简单,就是过期的 key 名字符串。实际问题在于业务比较多的时候,一个监听器里会收到各种业务模块的过期 key,这时候要么加前缀判断,要么把业务处理逻辑拆出去。
我常用的做法是在监听器里做一层轻量分发。比如 key 名带了业务前缀:
@Component public class RedisKeyExpireListener extends KeyExpirationEventMessageListener { private static final String ORDER_EXPIRE_PREFIX = "order:expire:"; private static final String COUPON_EXPIRE_PREFIX = "coupon:expire:"; private final OrderService orderService; private final CouponService couponService; public RedisKeyExpireListener(RedisMessageListenerContainer container, OrderService orderService, CouponService couponService) { super(container); this.orderService = orderService; this.couponService = couponService; } @Override protected void doHandleMessage(Message message) { String expiredKey = new String(message.getBody(), StandardCharsets.UTF_8); if (expiredKey.startsWith(ORDER_EXPIRE_PREFIX)) { String orderId = expiredKey.substring(ORDER_EXPIRE_PREFIX.length()); orderService.closeExpiredOrder(orderId); } else if (expiredKey.startsWith(COUPON_EXPIRE_PREFIX)) { String couponId = expiredKey.substring(COUPON_EXPIRE_PREFIX.length()); couponService.invalidateCoupon(couponId); } } }订单超时关闭这个例子里,key 的 value 其实没什么用,订单号已经在 key 名里了。如果你把业务编号存在 value 里,那过期以后是读不回来的,Redis 已经把数据删了。后面我会单独讲这个坑。
3.4 底层流转:一条过期消息是怎么被收到的
把代码写通以后,值得花两分钟把底层流转理一遍。因为后面遇到问题排查的时候,心里有这条链路会快很多。
流程大概是这样的:
- 业务代码调用
RedisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES),给 key 设置了过期时间; - Redis 实例在 key 到期后被删除(惰性删除或主动删除),触发 expired 事件;
- Redis 根据
notify-keyspace-events Ex配置,向__keyevent@0__:expired频道发布一条消息,消息体就是 key 名; - 应用里的
RedisMessageListenerContainer持有着对__keyevent@*__:expired的订阅,收到消息后转交给监听器的回调方法; - 监听器里解析 key 名,执行对应业务逻辑。
有几个点要特别注意。第一,key 过期事件不是在 TTL 归零的瞬间触发的,而是 key 真正被删除的时候才触发。Redis 有两种删除策略,一种是惰性删除,也就是 key 被访问时发现过期才删;另一种是主动删除,后台每 100 毫秒抽样检查并删除一部分过期 key。所以从 TTL 到期到事件发出,中间可能有零到几百毫秒的延迟,在业务里可以接受,但如果要求极其精确的定时触发,这个方案不合适。
第二,Redis 的 pub/sub 是即发即弃的。监听器不在线的那段时间,消息不会补发。这一点在第四章我会详细说,因为它决定了整个方案的可靠性边界。
4. 生产环境避坑指南与排查实录
4.1 消息丢了怎么办(pub/sub 无 ACK 的补偿方案)
这条是我最想强调的。Redis 的 pub/sub 机制没有持久化,也没有消费者确认机制。订阅者掉线、应用重启、Redis 主从切换,任何一个环节出问题,消息就悄悄没了。而且监听器这边不会知道有消息被错过,因为 Redis 端根本不记录谁订阅过、谁没收到。
做订单超时功能的时候,我一度以为有了监听器就万事大吉了,结果上线后出现过一次应用发版重启,期间刚好有个订单的 key 过期,重启后那个订单就一直挂在“待支付”状态没人管。从那以后我养成了一个习惯:把监听器当“加速信号”用,而不是当“唯一触发器”。
常规做法是这样的:
- 业务单据先落库,比如订单表里存
create_time和status,这个不能省; - Redis 里的过期 key 只是一个提醒:时间到了,去处理一下;
- 监听器收到消息后立即处理业务,让它负责时效性;
- 应用启动时或者每隔一段时间,扫描库里那些“应该过期但还没处理”的数据,跑一次兜底逻辑。
用订单场景举例,启动时兜底逻辑可能是:
@Service public class OrderExpireCompensator { @Scheduled(initialDelay = 10000, fixedDelay = 60000) public void compensateExpiredOrders() { // 查询 status='CREATED' 且 create_time 超过 30 分钟的订单 List<Order> expiredOrders = orderDao.selectCreatedBefore(new Date(System.currentTimeMillis() - 30 * 60 * 1000)); for (Order order : expiredOrders) { orderService.closeExpiredOrder(order.getId()); } } }这么做看似重复,但实际效果很好。Redis 事件负责干掉绝大多数“准点到达”的过期订单,兜底任务负责清理漏网的,两边一配合,可靠性就上来了。
4.2 key 名带了乱码前缀的序列化问题
第二个高频问题,是监听器收到的 key 名不是预期的一串字符串,而是一堆带着\xAC\xED\x00\x05t\x00之类前缀的乱码。
出现这个现象的原因,是 Spring Data Redis 默认的RedisTemplate用的是 JdkSerializationRedisSerializer,key 会先经过 Java 序列化再写入 Redis。序列化后的字节数组里包含了 Java 类信息,所以打印出来是乱码。
这个问题在监听器里特别容易暴露,因为你打印new String(message.getBody())时会把那些不可读的字节也显示出来。
解决办法有两个思路。第一个思路是全局统一用字符串序列化器,在配置 RedisTemplate 的时候让 key 用StringRedisSerializer:
@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); template.afterPropertiesSet(); return template; }key 以明文写入 Redis,过期事件里拿到的消息体就是干净的 key 名,监听器里直接new String(message.getBody(), StandardCharsets.UTF_8)就行。
第二个思路是监听器里用同一个序列化器反序列化,但说实话没必要,项目里 key 用字符串是更自然的选择。我见过有人为了适配监听器专门改了全局 RedisTemplate,反而是最省事的方案。
4.3 onMessage 里做重活导致消息堆积
你以为监听器方法里调用一个远程服务、查一次数据库、再调一次外部接口,好像没什么大不了。但要知道,如果同一时间有成百上千个 key 同时过期,而监听器里每个处理逻辑都要花几百毫秒,容器线程池很容易被打满,后面的消息排队越久,积压越严重。
我给消息容器设置线程池的时候,起初把核心线程数调得很大,结果发现业务下游扛不住突发流量,数据库连接池也报警。后来改了策略,监听器里只做快速分发,真正的重活丢给独立的业务线程池:
private final ExecutorService bizExecutor = new ThreadPoolExecutor( 8, 32, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(2000), new ThreadFactoryBuilder().setNameFormat("expire-handler-%d").build() ); @Override protected void doHandleMessage(Message message) { String expiredKey = new String(message.getBody(), StandardCharsets.UTF_8); bizExecutor.execute(() -> { try { orderService.closeExpiredOrder(parseOrderId(expiredKey)); } catch (Exception e) { log.error("处理过期订单失败, key={}", expiredKey, e); } }); }这样一来,监听器回调本身几乎不会阻塞,容器线程始终保持空闲,真正耗时的操作在业务线程池里排队执行。队列满的时候可以配合拒绝策略做告警,至少要知道系统已经在超负荷了。
4.4 拿不到过期前的 value 怎么办
前面提过,key 过期后被 Redis 删除,通过 GET 是读不到原值的。这是很多人栽跟头的地方:设置 key 的时候把业务数据整个塞进了 value,等着过期监听器里去取,结果发现取了个空。
实际项目中,过期的 key 只是一个“信号”。信号到了,业务数据应该来自数据库,或者来自另一个不会过期的持久化 key。比如下单的时候:
- 订单详情正常存到数据库;
- Redis 写一个
order:expire:{orderId},value 就写 orderId,TTL 设 30 分钟; - 监听器收到
order:expire:123456,解析出订单号 123456,去数据库查订单详情,判断状态,执行关单。
不要把业务实体完整地塞进一个过期的 key 里,然后再指望监听器能把它原样拿出来。凡是你处理业务时需要的关键字段,要么在 key 名里带上,要么在数据库里留一份。这个设计做对了,后面能省很多事。
4.5 多实例重复消费的幂等处理
Redis 的 pub/sub 语义是:所有订阅了同一个频道的客户端,都会收到同一份消息。如果你的应用部署了多个实例,而每个实例都有这个监听器,那么同一个 key 过期时,每个实例都会触发一次处理逻辑。
多实例在订单关闭这种场景下会有明显问题,比如两个实例同时对同一订单执行关单逻辑,可能造成重复扣库存、重复推送通知,或者因为状态判断的并发问题产生脏数据。
常规解法是做幂等。轻量方案是在 Redis 里抢一把短锁:
String lockKey = "lock:order:" + orderId; Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(30)); if (Boolean.TRUE.equals(locked)) { try { orderService.closeExpiredOrder(orderId); } finally { redisTemplate.delete(lockKey); } }这个方案简单有效,但要注意锁的超时时间必须大于业务处理时间,否则业务还在跑锁就释放了,依然可能重复执行。更稳妥的方案是数据库层面兜底:关单 SQL 里加上状态条件,比如UPDATE orders SET status='CANCELLED' WHERE id=? AND status='CREATED',更新影响行数为 0 说明已经被其他实例处理了,直接忽略。
4.6 集群、哨兵与容器化场景的兼容问题
Redis Cluster 模式下要特别小心。keyspace notifications 产生的事件只会发布到 key 所在的节点,pub/sub 消息本身不会跨节点转发。应用通过集群客户端连接时,消息只会从当前连接的节点过来,那就可能漏掉其他节点上的 key 过期事件。
我在集群环境里其实不太推荐用这个方案。如果一定要用,需要确保客户端对集群里每个主节点都建立订阅,或者借助一个统一的消息网关做汇聚,实现成本不低。如果业务能接受,我宁愿选择把过期时间计算好,直接扫数据库。
哨兵模式相对好一点,应用连接的是 master,过期事件主要在 master 上产生和发布,正常情况下都能收到。但故障转移期间,旧 master 上还没发出去的事件会丢失,切换完成后新 master 上的订阅要重新建立。这再次说明,兜底逻辑不能省。
容器化场景的坑主要在于配置。很多人在 Docker 容器里用CONFIG SET临时开启了notify-keyspace-events Ex,一切正常,但容器一重启就“失效”。下次重建容器或者扩容,新实例没有这个配置,监听器就哑了。生产环境一定要把配置文件通过挂载卷或者 ConfigMap 管理起来,别依赖临时命令。
5. 落地一年后我保留的最终姿势
这个方案在项目里跑了挺长时间以后,我最终保留的是一套组合拳,写在这里给后面的人参考。
Redis 监听器仍然是我的第一触发源,事件一来,立刻处理,保证时效性。但所有业务逻辑都建立在“数据库有单据”的前提上,Redis 只有提醒作用,没有数据承载作用。每次订单创建,订单主记录先落库,同时往 Redis 写一个过期 key。监听器收到 key 名后去查库、改状态,所有关键操作都放在本地事务里。
兜底定时任务每周都会跑一次,扫描超时未关的订单。它的存在不是为了处理正常流量,而是为了处理那些 Redis 事件没送达的极端情况。这个任务平时基本查不到数据,但关键时刻能救命。
幂等靠数据库状态和 Redis 短锁双重保证。多实例同时收到事件时,只有一个实例能成功更新订单状态,其他实例更新影响行数为 0,直接结束。
如果你现在要新做一个类似“超时自动处理”的需求,我的建议是:先评估是否对可靠性要求极高。如果能容忍少量消息丢失,而且想要快速上线,Redis 过期事件监听器绝对是最省事的方案。如果一条都不能丢,而且要求毫秒级触发,那就别折腾 Redis 了,直接上专业的延迟消息队列,监听器方案在架构设计上就是不适合那个场景的。
最后说个小的实践经验:监听器调试阶段,先把notify-keyspace-events配成Ex,用一个单独的测试 key 走一遍全流程,确认 Redis 端能发出事件、容器能收到事件、业务逻辑能执行,再接入真实业务。很多人一上来就接真实业务,结果配置没开还是序列化不对,排查起来一团乱麻。分步验证,永远比一次性梭哈稳妥。