Redis过期事件监听实战:keyspace notifications与Spring Boot集成

发布时间:2026/10/11 12:50:10
Redis过期事件监听实战:keyspace notifications与Spring Boot集成 不知道你在业务里有没有碰到过这种需求一个订单超时未支付半个小时后要自动关闭一个用户上传了文件生成分享链接七天内有效一个验证码五分钟内只能使用一次。这些看起来不相关的场景落到技术上都有一个共性需要在一个 key 过期之后立刻知道这件事然后做出后续处理。这个需求在 Redis 生态里最常见的方案就是 keyspace notifications也就是让 Redis 在 key 过期时推送一条过期事件我们再用一个消息监听器接收并处理。我最早接触这个玩法是做一个会员权益到期提醒系统。权益明细存在 Redis 里过期时间是 Redis 的 TTL但用户点击“续费”按钮时后端需要知道“这个权益是不是已经过期了”。一开始我用定时任务每五分钟扫一次把过期数据捞出来清算。数据量上来之后发现两个问题第一是扫表本身浪费数据库资源第二是五分钟的窗口很尴尬用户续费时感受到的权益状态是“好像还能用但后台已经判死”。后来我改成 Redis 过期事件监听每个权益 key 到点被删除时Redis 主动告诉我“哪个 key 过期了”我再去做状态迁移。效果很直接扫描任务砍掉了权益变更的感知窗口也缩小到了秒级。这篇内容我就把整个实现思路、底层机制和生产环境中踩过的坑一起梳理一遍。不管你是刚接触 Redis 的初学者还是已经在业务里尝试过过期监听但遇到各种诡异现象的人都可以照着这里面的步骤和排查思路去落地。1. 为什么需要监听 Redis key 过期事件业务场景与方案对比1.1 这些业务场景都在等同一个信号先列举几个我实际接触过的场景你会发现它们本质上都是“某个 key 生命周期的终点通知”。第一个是订单未支付自动关闭。电商平台很典型下单后给了 30 分钟支付时间超过时间未支付订单状态要变成“已关闭”如果涉及库存预占还要释放库存。理论上可以在下单时把订单号作为 Redis keyTTL 设置为 30 分钟过期事件触发后再去关单。这样一来用户不需要反复请求服务端也不需要轮询所有未支付订单。第二个是限流与防刷的窗口重置。比如某个接口针对用户维度做 60 秒内最多 100 次访问限制用INCREXPIRE实现计数器当 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 事件频道以__keyspacedb__开头E开启 keyevent 事件频道以__keyeventdb__开头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 事件通道命名规则看懂keyevent0:expiredRedis 开启 keyevent 通知后会向固定格式的频道发送消息。常见的两个频道前缀__keyspacedb__:key这是 keyspace 频道表示某个 key 发生了某种操作消息内容是操作类型比如expired、del、set。__keyeventdb__:event这是 keyevent 频道表示发生了某种事件消息内容是具体的 key 名称。举个例子。我在 db0 里执行set user:123 token_v1 EX 5五秒后 key 过期Redis 会向两个频道各发一条消息__keyspace0__:user:123消息内容是expired表达意思是“user:123 这个 key 发生了 expired 事件”。__keyevent0__:expired消息内容是user:123表达意思是“expired 事件涉及到的 key 是 user:123”。我们的监听器关心的是“哪个 key 过期了”所以订阅__keyevent0__:expired更方便收到消息后直接拿到的就是 key 名称。这里有一个特别容易踩的坑如果你连接的 Redis 不是 db0而是 db1 或其他编号那么频道名也要跟着变。比如select 1 set user:123 token_v1 EX 5你去订阅__keyevent0__:expired就什么都收不到必须订阅__keyevent1__: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事件。如果只订阅了__keyevent0__: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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency这个依赖会自动引入 Lettuce 连接池相关组件。如果你的项目里没有额外配置连接工厂直接用 Spring Boot 默认的RedisConnectionFactory就可以。Redis 服务端的配置在连接之前先确认两件事当前实例的notify-keyspace-events是否包含Ex。连的是不是业务实际使用的 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(__keyevent0__:expired)); return container; } }这里用PatternTopic而不是固定的ChannelTopic是因为__keyevent0__: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打交道可以这么写。先定义一个 BeanConfiguration 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 __keyevent0__:expired另开一个窗口执行redis-cli set click:test 1 EX 3等三秒如果订阅窗口打印出类似pmessage __keyevent0__:expired click:test的内容说明服务端事件机制是通的。如果这一步没输出但是CONFIG GET确实已经开了那大概率是配置改了还没生效或者改错了实例。第三步验证应用连接。这个步骤容易忽略常见情况是项目里配置了多个 Redis 数据源监听器连的实例和业务写入的实例不是同一个。比如业务代码写入了 db0但监听容器连接的是 db1。这种错误看日志很难发现因为连接本身是成功的。我的排查方法是在监听器的onMessage里打印 channel同时用 Redis 客户端精简订阅同一个频道对比pmessage的 db 编号就能定位。如果手动订阅没问题、应用监听器还是收不到那就检查监听器的 PatternTopic 是不是写成了__keyevent0__:expired而实际 Redis 密码验证后连接到了别的 db。更进一步可以打开 Spring 日志把日志级别调到 DEBUG观察连接工厂建立订阅连接的过程。通常能看到Subscribing to channel: __keyevent0__: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 编号不匹配核对__keyeventdb__:expired中的 db 编号事件有延迟不是准点惰性删除和定期删除机制导致接受延迟或引入定时任务兜底事件被多次处理多实例同时消费幂等判断 分布式锁key 消失但没收到过期事件内存淘汰触发了 evicted 事件检查淘汰策略按需订阅 evicted 事件监听器处理异常且消息丢失pub/sub 即发即弃处理逻辑加 try/catch失败时走重试队列订阅连接断开后恢复慢网络抖动或主从切换监控监听连接状态日志告警核心业务加兜底这六个问题基本覆盖了我见过的绝大多数“过期监听不好使”的场景。最后想分享一个我自己的使用心得在真正上线之前写一个小脚本批量造一批不同 TTL 的 key然后开着监听是不是都能收到。这个“造数据—看监听—对账”的流程能帮你快速发现配置问题也能让你对整套机制的延迟和可靠性有一个直观感受。等真正跑一段时间后你会发现它最大的价值不是“精确到毫秒”而是帮你省掉了大量无意义的轮询请求让业务逻辑对资源生命周期的感知变得主动起来。