☰
Redis过期事件监听实战:keyspace notifications与Spring Boot集成
2026/10/11 12:49:53 网站建设 项目流程

不知道你在业务里有没有碰到过这种需求:一个订单超时未支付,半个小时后要自动关闭;一个用户上传了文件,生成分享链接七天内有效;一个验证码五分钟内只能使用一次。这些看起来不相关的场景,落到技术上都有一个共性:需要在一个 key 过期之后立刻知道这件事,然后做出后续处理。这个需求,在 Redis 生态里最常见的方案就是 keyspace notifications,也就是让 Redis 在 key 过期时推送一条过期事件,我们再用一个消息监听器接收并处理。

我最早接触这个玩法,是做一个会员权益到期提醒系统。权益明细存在 Redis 里,过期时间是 Redis 的 TTL,但用户点击“续费”按钮时,后端需要知道“这个权益是不是已经过期了”。一开始我用定时任务每五分钟扫一次,把过期数据捞出来清算。数据量上来之后,发现两个问题:第一是扫表本身浪费数据库资源,第二是五分钟的窗口很尴尬,用户续费时感受到的权益状态是“好像还能用,但后台已经判死”。后来我改成 Redis 过期事件监听,每个权益 key 到点被删除时,Redis 主动告诉我“哪个 key 过期了”,我再去做状态迁移。效果很直接,扫描任务砍掉了,权益变更的感知窗口也缩小到了秒级。

这篇内容,我就把整个实现思路、底层机制和生产环境中踩过的坑一起梳理一遍。不管你是刚接触 Redis 的初学者,还是已经在业务里尝试过过期监听但遇到各种诡异现象的人,都可以照着这里面的步骤和排查思路去落地。

1. 为什么需要监听 Redis key 过期事件:业务场景与方案对比

1.1 这些业务场景都在等同一个信号

先列举几个我实际接触过的场景,你会发现它们本质上都是“某个 key 生命周期的终点通知”。

第一个是订单未支付自动关闭。电商平台很典型:下单后给了 30 分钟支付时间,超过时间未支付,订单状态要变成“已关闭”,如果涉及库存预占,还要释放库存。理论上可以在下单时把订单号作为 Redis key,TTL 设置为 30 分钟,过期事件触发后再去关单。这样一来,用户不需要反复请求,服务端也不需要轮询所有未支付订单。

第二个是限流与防刷的窗口重置。比如某个接口针对用户维度做 60 秒内最多 100 次访问限制,用INCR+EXPIRE实现计数器,当 key 过期时意味着一个统计窗口结束。如果我们需要在窗口结束时做一些清理或归档,就可以监听这个 key 的过期事件。现实中更常见的是重置计数逻辑直接在访问时判断,但如果你想知道“谁在哪个窗口被限流过”,事件监听能帮你沉淀数据。

第三个是验证码、短链接、临时授权 token 的到期失效。这些场景不一定需要立即清理,但如果你希望主动回收资源,或者记录“是否在有效期内被使用”,过期监听就派上用场了。比如一个短链接 key 过期后,我可以把对应的访问统计落库,然后更新状态为“已过期”。

这些案例的共同点很清晰:有一个明确的“未来时间点”,到点之后需要执行一个业务动作。与其靠外部定时任务反复检查,不如让 Redis 在删除 key 的时候主动广播事件,消息监听器接住这个事件去执行后续逻辑。

1.2 轮询、延时队列、过期监听:我为什么选了第三种

在动手写代码之前,需要先做方案选型。我在项目里见过三种典型做法,简单对比一下:

方案实现方式优点缺点
定时轮询用定时任务扫 Redis 或数据库实现简单,不依赖额外组件延迟高,任务频次不好定,数据量大时浪费资源
延时消息队列RocketMQ / RabbitMQ 延迟消息、时间轮延迟可控,消息可持久化,支持重试引入额外中间件,需要运维成本,业务链路侵入大
Redis 过期事件监听keyspace notifications + 消息监听器延迟较低,零额外组件,代码直观事件不保证可靠,有延迟,需要业务兜底

我这边项目基础设施里没有强依赖 MQ,如果只是为了“过期后做点什么”就引入延迟队列,对团队来说性价比不高。定时轮询又太粗糙,尤其是需要精确到秒级别的状态流转,轮询间隔设到每秒一次成本和复杂度都不低。所以综合下来,Redis 过期事件监听成了首选。

不过要强调一点:这不是“免费的午餐”。Redis 的过期事件基于 pub/sub 发布订阅实现,而 pub/sub 本身是不持久化的,消息发出去如果消费者不在线,也就丢了。所以我在项目里把过期监听定位成“信号通知器”,而不是“任务执行保证器”。它会告诉我某个 key 过期了,但真正的最终一致性,我还需要靠数据库状态流转、定时任务兜底来保证。这个思路贯穿整篇文章,后面会反复用到。

1.3 一句话说清过期监听到底在做什么

把概念简化一下:Redis 内部有一套事件通知机制,当某个 key 因为过期而被删除时,如果开启了对应配置,Redis 就会往一个特定的频道发一条消息。这个消息的内容就是过期的 key 名称。我们的消息监听器做的事情就是订阅那个频道、收到消息、解析出 key,然后执行对应业务逻辑。

这里面真正需要注意的不是“怎么订阅”,而是“事件什么时候发、发到哪个频道、消息长什么样、改了什么配置才会发”。这些细节如果没吃透,写出来的监听器要么完全收不到消息,要么在生产环境里出现离奇的延迟和重复消费。所以下一章我们先从底层机制讲起。

2. keyspace notifications 机制解析:事件从产生到被订阅

2.1 先打开开关:notify-keyspace-events 配置项逐字母拆解

Redis 默认没有开启 keyspace notifications,因为开启后会有额外的内存和 CPU 开销。要使用的话,首先打开这个开关:

redis-cli config set notify-keyspace-events Ex

这个Ex两个字符看起来简短,含义却很重要。E表示开启 keyevent 事件通知,也就是“以事件类型为维度的通知”;x表示事件类型是“过期事件”。两者搭配在一起,才会在 key 过期时发出一条事件消息。如果你只设置了E没设置x,或者只设置了x没设置E,都不会达到期望效果。

为了更好地理解,我把常见的配置字符列出来:

字符含义
K开启 keyspace 事件,频道以__keyspace@<db>__开头
E开启 keyevent 事件,频道以__keyevent@<db>__开头
g通用命令事件,比如 DEL、EXPIRE、RENAME
$字符串命令事件
l列表命令事件
s集合命令事件
h哈希命令事件
z有序集合命令事件
x过期事件,即 key 因为 TTL 到期被删除时触发
e驱逐事件,即 key 因为内存淘汰被删除时触发
A表示g$lshzxe的别名,等价于开启除 K/E 之外的所有事件类型

生产环境里,我建议显式设置为Ex,不要图省事直接写KEA。KEA会把所有命令、所有事件类型全部广播出去,对 Redis 的网络和 CPU 开销都会增加,而且很多事件你根本用不上,还要在监听器里做大量无效过滤。

如果配置写在 redis.conf 里,同样是在最后加一行:

notify-keyspace-events Ex

改完配置文件之后需要重启 Redis。如果你暂时不想重启,可以用CONFIG SET在线调整,但这个配置不会持久化,重启后会被配置文件覆盖。所以生产环境里要记得两边都改。

2.2 事件通道命名规则:看懂keyevent@0:expired

Redis 开启 keyevent 通知后,会向固定格式的频道发送消息。常见的两个频道前缀:

  • __keyspace@<db>__:<key>:这是 keyspace 频道,表示某个 key 发生了某种操作,消息内容是操作类型,比如expired、del、set。
  • __keyevent@<db>__:<event>:这是 keyevent 频道,表示发生了某种事件,消息内容是具体的 key 名称。

举个例子。我在 db0 里执行:

set user:123 token_v1 EX 5

五秒后 key 过期,Redis 会向两个频道各发一条消息:

  • __keyspace@0__:user:123,消息内容是expired,表达意思是“user:123 这个 key 发生了 expired 事件”。
  • __keyevent@0__:expired,消息内容是user:123,表达意思是“expired 事件涉及到的 key 是 user:123”。

我们的监听器关心的是“哪个 key 过期了”,所以订阅__keyevent@0__:expired更方便,收到消息后直接拿到的就是 key 名称。

这里有一个特别容易踩的坑:如果你连接的 Redis 不是 db0,而是 db1 或其他编号,那么频道名也要跟着变。比如:

select 1 set user:123 token_v1 EX 5

你去订阅__keyevent@0__:expired就什么都收不到,必须订阅__keyevent@1__:expired。在 Spring Data Redis 里配置 PatternTopic 时,一定要把这个编号写对。

另外,如果你用的是集群模式,不同 slot 分布在多个节点上,每个节点只会发布它自己上面 key 的事件。你要么在每个节点上都开启订阅,要么通过代理层面去聚合,这一点后面我会在可靠性章节细说。

2.3 过期不等于准时:惰性删除、定期删除与内存淘汰的真实影响

这一节是整篇文章里我认为最容易被忽视、也是最容易在生产环境翻车的部分。

很多人以为EXPIRE设置 5 秒,Redis 就会在 5 秒的那一刻立刻发出过期事件。实际不是这样。Redis 对过期 key 的删除分为两种方式:

第一种是惰性删除。当一个 key 到了过期时间,如果没有请求访问它,Redis 不会立刻把它删除。只有当某个请求真正去读取这个 key 时,Redis 发现已经过期,才会在执行命令前把它删除,然后广播过期事件。也就是说,事件触发的时间取决于“有没有人去碰这个 key”。

第二种是定期删除。Redis 会以一定频率、随机抽取一部分设置了过期时间的 key 进行检查,如果发现已经过期,就主动删除并广播事件。这个频率默认是每秒执行固定次数,每次会抽查一定数量的 key。所以即便一个 key 没有被访问,它也可能被定期清理线程扫描到。

这两种机制叠加的结果是:key 的 TTL 到达之后,事件不一定会立即发出,可能有零点几秒到几秒的延迟,极端情况下甚至更久。如果你在业务里期望“30 分钟必须精确到秒关单”,那 Redis 过期监听单靠一个机制是做不到的。

还有一种常见误解:以为 key 没了就会发expired事件。实际上如果 Redis 因为内存淘汰策略删除了 key,比如maxmemory-policy设置为allkeys-lru,内存不够时淘汰掉某个 key,它触发的是evicted事件,不是expired事件。如果只订阅了__keyevent@0__:expired,这些被淘汰的 key 你完全感知不到。所以遇到“key 消失但没有过期事件”时,除了检查配置,还应该看淘汰策略。

明白了这些,你就能理解为什么我说“监听器只能当信号通知,不能当精确调度器”。后面章节里所有代码和排查思路,都是在这个前提下设计的。

3. Spring Boot 中搭建 Redis 过期事件消息监听器

3.1 准备阶段:依赖、Redis 版本与基础配置

在代码层面,我使用的是 Spring Boot + Spring Data Redis。Redis 版本建议至少 2.8,因为 keyspace notifications 是 2.8 开始引入的。如果条件允许,尽量用 4.0 以上版本,对过期扫描和事件发布的健壮性更好。我自己目前主力环境是 Redis 6.x 和 7.x,没遇到兼容问题。

Maven 依赖只需要一个:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>

这个依赖会自动引入 Lettuce 连接池相关组件。如果你的项目里没有额外配置连接工厂,直接用 Spring Boot 默认的RedisConnectionFactory就可以。

Redis 服务端的配置,在连接之前先确认两件事:

  1. 当前实例的notify-keyspace-events是否包含Ex。
  2. 连的是不是业务实际使用的 db。

可以用命令行快速验证:

redis-cli config get notify-keyspace-events

如果输出是空字符串,说明没开启。先在线开启:

config set notify-keyspace-events Ex

然后顺手把配置文件里的持久化配置也改掉。在线配置只对当前实例生效,一旦重启就会丢失,这个误区我见过不止一次。

3.2 关键配置类:RedisMessageListenerContainer 容器是怎么把监听器挂上去的

Spring Data Redis 里负责管理监听器的核心容器叫RedisMessageListenerContainer。你可以把它理解成“专门订阅 Redis 频道的常驻线程池”,一个运行中的连接会阻塞监听频道上的消息,收到消息后分发到对应的MessageListener上。

我一般会写这样一个配置类:

@Configuration public class RedisExpireListenerConfig { @Bean public RedisMessageListenerContainer redisMessageListenerContainer( RedisConnectionFactory connectionFactory) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); return container; } }

这里有个细节值得注意:很多初学者在启动项目后发现日志里没有订阅信息,就是因为容器没有真正启动。RedisMessageListenerContainer在 Spring 容器关闭时会主动停止,正常启动后它内部会创建连接并保持订阅。如果你需要调整订阅线程数量,可以参考它提供的配置项,比如setSubscriptionExecutor和setTaskExecutor,不过大多数业务场景用默认配置就够了。

接下来,要把我们的监听器注册进容器,并告诉容器它要订阅哪个频道:

@Configuration public class RedisExpireListenerConfig { @Bean public RedisMessageListenerContainer redisMessageListenerContainer( RedisConnectionFactory connectionFactory, RedisKeyExpireListener listener) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); container.addMessageListener(listener, new PatternTopic("__keyevent@0__:expired")); return container; } }

这里用PatternTopic而不是固定的ChannelTopic,是因为__keyevent@0__:expired本身就是一个明确的频道名,两者在这个场景下都能用。PatternTopic的优势是支持通配符,比如__keyevent@*__:expired可以匹配所有 db 的过期事件。如果你确认业务只用 db0,那就老老实实写死0,避免监听器收到其他库的事件后解析出不该处理的 key。

3.3 核心监听器代码:拿到过期的 key 之后可以做哪些处理

监听器本身实现MessageListener接口即可:

@Component public class RedisKeyExpireListener implements MessageListener { private static final Logger log = LoggerFactory.getLogger(RedisKeyExpireListener.class); @Override public void onMessage(Message message, byte[] pattern) { String expiredKey = message.toString(); String channel = new String(message.getChannel()); log.info("收到 Redis 过期事件, channel: {}, key: {}", channel, expiredKey); // 这里根据 key 的前缀或业务规则做分发 if (expiredKey.startsWith("order:")) { handleOrderExpired(expiredKey); } else if (expiredKey.startsWith("share:")) { handleShareExpired(expiredKey); } } private void handleOrderExpired(String orderKey) { // 例如解析出订单号,异步调用订单服务关闭订单 } private void handleShareExpired(String shareKey) { // 例如更新数据库状态,清理文件等 } }

在onMessage方法里,message.toString()拿到的就是过期的 key 名称。这个 key 名称是我们当初写入 Redis 时设置的完整字符串,比如order:20250101120001。你可以按照自己的命名规范,用前缀过滤,也可以直接用字符串截取业务编号。

需要提醒几个点:

第一,onMessage回调虽然运行在独立线程,但不要在里面做耗时操作。如果业务处理需要查数据库、调外部接口,建议丢到线程池或者发 MQ,等真正执行成功后再改业务状态。否则单个回调阻塞太久,可能导致后续事件堆积。

第二,监听器里一定要加 try/catch。pub/sub 消息是即发即弃的,监听器抛出异常并不会让 Redis 重发,只会导致这条消息的处理中断。你可以在 catch 里做专门的重试策略,或者把失败的 key 写入一个延迟队列再处理。

第三,如果你在onMessage里访问message.getChannel(),可以拿到完整的频道名。这在多个 db 共用同一个监听器时很有用,可以判断事件来自 db0 还是 db1。

3.4 Spring Data Redis 的快捷方式:KeyExpiredEvent 还能这样用

Spring Data Redis 其实封装了事件对象的版本。它提供了一个KeyExpirationEventMessageListener,专门监听所有 db 的__keyevent@*__:expired频道,把收到的过期 key 包装成KeyExpiredEvent发布到 Spring 容器里。如果我们不想直接跟MessageListener打交道,可以这么写。

先定义一个 Bean:

@Configuration public class RedisExpireEventConfig { @Bean public KeyExpirationEventMessageListener keyExpirationEventMessageListener( RedisMessageListenerContainer container) { return new KeyExpirationEventMessageListener(container); } }

然后业务代码里用@EventListener接收:

@Component public class RedisKeyExpiredHandler { @EventListener public void handleExpired(KeyExpiredEvent event) { String expiredKey = event.getKeyspace(); // 处理业务 } }

这个方案的好处是代码更少,事件对象已经帮你封装好了。但它有两个我需要提醒的地方:第一,默认订阅的是__keyevent@*__:expired,也就是所有 db 的过期事件都会进来;第二,如果同一个 key 在多个 db 中都存在,你无法只靠getKeyspace()区分来源,需要借助 event 携带的 channel 信息。所以我的个人建议是:业务简单、单库单实例的时候直接用MessageListener更可控;如果你更偏好事件驱动风格,再用KeyExpiredEvent也不迟。

4. 生产使用中的可靠性问题与排查实战

4.1 改了配置还是收不到事件?按这个顺序排查

我在生产环境里头一次上线时,明明配置都改了,也按照网上教程注册了监听器,结果就是收不到任何过期事件。后来一步步排查,才发现是连接错了 db。下面是我的排查顺序,你可以照着操作。

第一步,先确认 Redis 服务端配置:

redis-cli -a 你的密码 config get notify-keyspace-events

注意输出值里必须包含E和x。如果输出是空,或者只有别的事件类型,那问题就很明确了。

第二步,不写任何 Java 代码,先用 redis-cli 手动验证 Redis 会不会发事件。开一个窗口订阅:

redis-cli psubscribe '__keyevent@0__:expired'

另开一个窗口,执行:

redis-cli set click:test 1 EX 3

等三秒,如果订阅窗口打印出类似pmessage __keyevent@0__:expired click:test的内容,说明服务端事件机制是通的。如果这一步没输出,但是CONFIG GET确实已经开了,那大概率是配置改了还没生效,或者改错了实例。

第三步,验证应用连接。这个步骤容易忽略,常见情况是项目里配置了多个 Redis 数据源,监听器连的实例和业务写入的实例不是同一个。比如业务代码写入了 db0,但监听容器连接的是 db1。这种错误看日志很难发现,因为连接本身是成功的。我的排查方法是:在监听器的onMessage里打印 channel,同时用 Redis 客户端精简订阅同一个频道,对比pmessage的 db 编号就能定位。

如果手动订阅没问题、应用监听器还是收不到,那就检查监听器的 PatternTopic 是不是写成了__keyevent@0__:expired,而实际 Redis 密码验证后连接到了别的 db。更进一步,可以打开 Spring 日志,把日志级别调到 DEBUG,观察连接工厂建立订阅连接的过程。通常能看到Subscribing to channel: __keyevent@0__:expired这样一行关键日志。

4.2 多副本部署时的重复消费与幂等处理

很多服务为了提高可用性会做多实例部署。这时候问题来了:Redis 发布订阅会把消息发到所有已订阅的消费者,也就是说,如果你部署了三个实例,同一个 key 的过期事件会被三个实例同时收到,业务处理逻辑也会执行三次。

比如关单操作,如果幂等没做好,就可能出现重复关单、重复发通知、重复释放库存的问题。解决思路和消息队列消费的幂等是一样的:让业务处理变成天然幂等,或者在处理前加防重标记。

我常用的两种方式:

第一种,数据库状态判断。处理关单时,先执行一条 UPDATE,加上条件判断:

UPDATE orders SET status = 'CLOSED' WHERE order_id = ? AND status = 'UNPAID';

如果更新影响行数为 0,说明已经被处理过了,直接忽略。这是最简单的幂等方案,而且在大多数业务里足够可靠。

第二种,用 Redis 分布式锁做互斥。比如:

String lockKey = "lock:expire:" + expiredKey; boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(30)); if (locked) { try { handleBusiness(expiredKey); } finally { stringRedisTemplate.delete(lockKey); } }

注意锁的自动过期时间要设置成一个合理的值,防止处理线程异常退出后锁永久不释放。但这里有个选择题:锁的时间太短,可能业务还没处理完锁就过期了,其他实例又进来;锁的时间太长,如果实例宕机,锁要等很久才能被清理。所以更稳妥的做法是结合数据库的乐观锁判断,把 Redis 锁当作第一道防护,数据库状态作为最终保证。

4.3 集群、哨兵与网络抖动:过期事件真的不会丢吗

直接回答:会丢,而且丢得比你想象中常见。

原因有两层。第一层是 pub/sub 的天然属性:消息发布时如果没有订阅者在线,或者订阅连接网络波动断开,消息就找不回来了。Redis 不会替你缓存这些消息。第二层是 Redis 集群和哨兵模式带来的拓扑变化:在哨兵模式下,客户端连接的主节点发生故障切换,订阅连接会短暂中断,切换期间产生的过期事件同样会丢。

所以我反复强调:不能用过期监听过日子,必须给核心业务配上兜底机制。我自己在订单场景的做法是双通道:正常情况用 Redis 过期监听触发关单;同时数据库里维护了一个expire_check_time字段,后台每隔五分钟扫描一次超过支付时限仍未关闭的订单,做最终扫尾。这样即使 Redis 事件漏了,最晚五分钟后也会被定时任务捞回来。

如果你的业务对事件丢失完全零容忍,那 Redis 过期监听本身就不该作为唯一方案,需要引入专业的延迟消息队列。技术选型上没有银弹,关键是清楚每个方案的边界。

4.4 性能开销与常见问题速查表

开启 keyspace notifications 会带来性能开销,这是很多人没注意到的。每有一个 key 发生过期操作,Redis 就要额外构造事件消息并发布,如果短时间内大量 key 集中过期,会产生一波不小的 CPU 和网络流量。我在压测环境做过粗略对比,同样十万个 key 过期,开启Ex的情况下,事件发布带来的额外耗时大概占整体请求耗时的百分之几。这个量级在大多数场景可以接受,但如果你的 Redis 节点本来负载就很高,就需要评估一下。

另外,订阅事件本身会在 Spring 应用里常驻一条监听连接,这条连接如果被占用或者线程池阻塞,可能导致监听线程内部的消息积压。排查时可以关注应用日志中是否有频繁的TaskRejectedException,如果有,说明线程池打满了。

我把生产环境里高频问题整理成一个速查表,遇到问题可以直接对号入座:

问题现象可能原因解决办法
配置已开,订阅不到事件订阅频道 db 编号不匹配核对__keyevent@<db>__:expired中的 db 编号
事件有延迟,不是准点惰性删除和定期删除机制导致接受延迟或引入定时任务兜底
事件被多次处理多实例同时消费幂等判断 + 分布式锁
key 消失但没收到过期事件内存淘汰触发了 evicted 事件检查淘汰策略,按需订阅 evicted 事件
监听器处理异常且消息丢失pub/sub 即发即弃处理逻辑加 try/catch,失败时走重试队列
订阅连接断开后恢复慢网络抖动或主从切换监控监听连接状态,日志告警,核心业务加兜底

这六个问题基本覆盖了我见过的绝大多数“过期监听不好使”的场景。最后想分享一个我自己的使用心得:在真正上线之前,写一个小脚本,批量造一批不同 TTL 的 key,然后开着监听是不是都能收到。这个“造数据—看监听—对账”的流程,能帮你快速发现配置问题,也能让你对整套机制的延迟和可靠性有一个直观感受。等真正跑一段时间后,你会发现它最大的价值不是“精确到毫秒”,而是帮你省掉了大量无意义的轮询请求,让业务逻辑对资源生命周期的感知变得主动起来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询