
如果你负责的后端系统里有这么一类需求订单创建后30分钟不付款就要自动取消、缓存里的临时数据过期后要异步重建、某个一次性授权令牌到期后要把状态翻回去……那你迟早会碰到一个等一个key过期的事情。Redis消息监听器更正式的名称是键空间通知Keyspace Notification就是专门干这件事的它能在key因为TTL到期而被删除的那一刻通过pub/sub把消息推给你让你不用再开个定时任务去一遍遍问它过期了吗。这篇文章会从使用场景讲起把键空间通知的工作原理拆开再给出Spring Boot里完整的落地代码最后重点分享我在生产环境实测中踩过的几个坑。因为网上讲Redis监听key过期事件的教程很多但大多只教你怎么配置、怎么写监听类真正到了线上会遇到的事件延迟、消息丢失、集群断流这类问题很少有人系统说清楚。如果你正准备把过期事件接入自己的项目这篇文章应该能帮你少走不少弯路。1. 为什么我会把等一个key过期当成正事来做1.1 实际需求长什么样订单超时关闭与授权到期先说一个我真实做过的需求。电商下单流程里用户创建了订单但迟迟不支付系统需要在30分钟后自动把订单置为已取消释放库存。最朴素的实现方式是写一个定时任务每分钟扫一次订单表把超时的订单拎出来处理。问题是订单量一旦上来每分钟全表扫描一遍的代价越来越大而且扫的时候你会发现大部分订单根本没超时——这就是典型的空转。后来我把方案改成了Redis。下单时往Redis里写一个key比如order:20250101001值为占位符TTL设成1800秒。同时用Redis的过期事件监听机制订阅key过期这个动作。当订单key到了30分钟被删除时监听器收到消息进库里把订单状态改成取消。这样定时任务消失了系统只在真正有订单超时时才干活实时性还比每分钟扫表高得多。类似的场景还有很多验证码5分钟后失效、临时授权链接24小时后过期、用户Token刷新后的旧Token清理、缓存预热的过期后重建……这些需求本质一致——你关心的是某一个key的生命周期结束而不是某段时间点上去检查谁死了。1.2 轮询、时间轮、消息队列为什么Redis过期事件能排上号做这类延迟触发需求市面上其实有好几条路可以走。我逐个排过给你说说我当时的心态。定时任务轮询数据库。实现最简单但延迟取决于轮询频率而且随着表数据量增大每次扫描都是负担。你如果只有几千个订单还能忍几十万上百万的时候就会非常难受。进程内的时间轮或DelayQueue。延迟可以非常精确但它是内存级的进程一重启所有等待中的任务都丢了还得自己做持久化而且多实例部署时还要解决同一个key别被两个实例各处理一遍的分布式协调问题。引入消息队列RabbitMQ延迟消息、RocketMQ定时消息。可靠性和精确度都不错但代价是你得为这个需求多维护一套中间件对于很多内部小项目来说不值得。Redis key过期事件。轻量、实时性够用、不引入新组件还顺手把同一个key只处理一次这个分布式问题给解决了——key只在一个Redis实例上事件只发布一份。当然后面会讲到它也有很明显的可靠性短板不是什么场景都能接。但如果你评估之后确认业务能容忍偶尔丢一两条延迟通知Redis过期事件就是性价比极高的方案。2. Redis键空间通知的工作机制key消失之后发生了什么事2.1 一个开关引发的广播notify-keyspace-events配置拆解Redis的键空间通知默认是关闭的因为开启后每次对key的操作都可能产生额外的事件推送对CPU、内存、网络都有开销而且有些事件比如所有写命令量大到吓人。所以你想用这个功能第一件事就是显式打开开关。配置项只有一个notify-keyspace-events。它的值是一个由若干字符组成的字符串每个字符代表一类事件。这里最关键的两个字符E开启keyevent通知发布到__keyeventdb__开头的频道消息体是哪个key发生了什么事。K开启keyspace通知发布到__keyspacedb__开头的频道消息体是这个key上发生了什么命令。x开启过期事件即key因为TTL到期被删除时产生通知。e开启驱逐事件即key因为maxmemory内存淘汰策略被删除时产生通知。我们监听key过期最常见的配置就是Ex。E代表走keyevent频道我只关心key名字x代表过期这个具体动作。如果你在redis.conf里写就是一行notify-keyspace-events Ex如果不想重启Redis也能在运行时直接改127.0.0.1:6379 CONFIG SET notify-keyspace-events Ex这个命令是立即生效的对线上影响很小挺适合临时验证。但要注意CONFIG命令一般生产环境会做权限管控而且重启后会丢失所以正式环境我建议直接写进配置文件。2.2 事件长什么样keyevent0:expired 频道与消息体打开开关之后你可以先用redis-cli订阅频道验证一下。开一个终端127.0.0.1:6379 PSUBSCRIBE __keyevent0__:expired Reading messages... (press Ctrl-C to quit) 1) psubscribe 2) __keyevent0__:expired 3) (integer) 1再开另一个终端设置一个5秒过期的key127.0.0.1:6379 SET test:key 1 EX 5 OK等几秒订阅终端会收到这么一串1) pmessage 2) __keyevent0__:expired 3) __keyevent0__:expired 4) test:key这个过程中有两件事特别值得记住。第一频道名里的0是数据库编号说明我在db 0里设置了key。如果你的业务用到了SELECT 1或其他数据库订阅的频道要换成对应的编号比如__keyevent1__:expired。我用Spring Boot的时候连接默认走db 0所以这个问题容易忽略。第二也更重要——消息体就是过期的key名本身。它是一条普通的pub/sub消息发布方在发布那一刻已经把key删掉了你无法在监听器里再去GET这个key拿value。这意味着你的业务信息如果存在value里等收到事件时已经拿不到了后面落地代码的时候要专门处理这一点。2.3 惰性删除和定期删除过期时间到了事件却不一定是准点的这一节是后面踩坑的伏笔。Redis的key过期删除并不是时间一到立即把它切掉实际用的是两种机制配合一是定期删除。Redis默认每100ms执行一次过期key扫描每次随机抽取一部分带TTL的key检查是否过期过期就删。注意随机抽查这四个字——它不保证某一秒内把所有该过期的key全部找出来。二是惰性删除。当客户端访问某个key时Redis会先检查它是否已过期如果过期就先删除再处理。也就是说一个key如果一直没人访问它可能不会立刻被物理删除而是要等定期扫描扫到它或者等下一次有人碰它。过期事件是由删除这个动作触发的。所以一个key的TTL归零了通知却不一定是零延迟到达它取决于Redis什么时候发现这个key该删了。这个机制在key量小的时候感受不明显一旦大量key同一时刻到期事件会出现明显的延迟和拖尾。这个坑我放到第四章细讲你现在先有个印象。3. Spring Boot把Redis消息监听器真正跑起来3.1 最简配置redis.conf、依赖、连接工厂项目里接入其实不复杂。首先确保Redis服务端打开了键空间通知前面已经说过redis.conf里加notify-keyspace-events ExSpring Boot这边用spring-boot-starter-data-redis它里面带的LettuceConnectionFactory、StringRedisTemplate已经够用。pom里加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency接着定义一个容器配置。RedisMessageListenerContainer是Spring Data Redis里专门负责管理订阅生命周期的核心类它会维护和Redis的连接把收到的消息分发到各个监听器上Configuration public class RedisListenerConfig { Bean public RedisMessageListenerContainer redisMessageListenerContainer( RedisConnectionFactory connectionFactory) { RedisMessageListenerContainer container new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); return container; } }这里有个小细节这个容器只有一个连接管理线程所有监听器共享。如果你的监听器种类很多、消息量很大可以考虑给容器设置一个TaskExecutor线程池后面讲到阻塞问题会再提。3.2 两种落地写法MessageListener与KeyExpirationEventMessageListenerSpring Data Redis提供了两种方式让我实现过期监听的业务逻辑。第一种是直接实现MessageListener接口自己注册到容器上Component public class OrderExpiredListener implements MessageListener { Override public void onMessage(Message message, byte[] pattern) { String key new String(message.getBody()); // 这里拿到的key就是过期的key名比如 order:20250101001 if (key.startsWith(order:)) { String orderId key.substring(order:.length()); cancelOrder(orderId); } } }不过MessageListener不会自动订阅任何频道你得手动告诉容器我监听的Pattern是什么。常见做法是单独写一个监听器注册类Component public class RedisListenerRegistrar { EventListener(ApplicationReadyEvent.class) public void register(RedisMessageListenerContainer container, OrderExpiredListener listener) { container.addMessageListener(listener, new PatternTopic(__keyevent0__:expired)); } }第二种方式更省事直接继承Spring为过期事件提供的KeyExpirationEventMessageListener。它在构造时会自动注册__keyevent*__:expired这一组Pattern也就是监听所有数据库的key过期事件你只需要在onMessage里写业务逻辑Component public class RedisKeyExpiredListener extends KeyExpirationEventMessageListener { public RedisKeyExpiredListener(RedisMessageListenerContainer listenerContainer) { super(listenerContainer); } Override public void onMessage(Message message, byte[] pattern) { String key message.toString(); // 处理业务注意这里也是拿不到value的 handleExpiredKey(key); } }我自己的项目里更常用第二种少写一行注册代码而且自动兼容多个db。但要注意如果你的Redis实例里有其他系统也在用__keyevent*__:expired会把你根本不想关心的key过期消息也送过来所以onMessage里第一件事就是做好前缀过滤处理前先判断这个key是不是你的业务key。3.3 业务信息必须放在key里因为事件来的那一刻没人能拿到value这节要讲一个最容易被新手忽视的设计约束。前面说过过期事件发生时key已经被删除所以监听器里执行stringRedisTemplate.get(key)是拿不到任何值的。你不要指望先收到key再回去查value——查了个寂寞是常态因为value已经跟着key一起没了。正确做法是把你要用的业务标识全部编码进key结构里。比如做订单超时取消订单号: order_20250101001 Redis key: order:20250101001 value: 任意占位符比如 1 TTL: 1800秒监听器收到order:20250101001解析出订单号然后去业务数据库查订单详情、改状态。如果业务需要额外上下文比如用户ID、优惠券ID就把key设计成order:{orderId}:{userId}这种复合结构或者直接用分隔符拼进去反正key本身就是一个可以自由设计的字符串。提示千万别把核心业务数据只放在value里。Redis key过期通知只告诉你某个名字的key没了它不会帮你保存value更不会等你去读。把key当成你的消息载体这是这个方案能够成立的根基。4. 生产环境实测我被过期事件坑过的几个瞬间4.1 大面积key同时过期事件拖尾差点让我背锅第一次上线的时候我以为设置了TTLkey到了时间就会被秒删监听器应该准点收到消息。结果线上某一批定时任务生成的大量key集中在同一秒过期监听器收到的消息却在接下来的几十秒甚至一分多钟里稀稀拉拉地到达。原因就是前面提过的定期删除机制。100ms一次的随机采样遇到上万、上十万个key同时到期根本不可能在一两次扫描里全删完。Redis会分批把它们删掉每删一个产生一个过期事件所以事件是一条一条挤牙膏一样吐出来的。这个现象最直接的影响是如果你的业务对超时时间要求很严格比如订单必须在下单后30分钟整点关闭那么过期事件方案在key并发量大的情况下可能延迟几秒到几十秒。下单高峰期这个问题尤其明显。我当时做的缓解措施有两层。第一把大批key的过期时间做了小范围扰动比如原本统一TTL 1800秒改成1800±随机几秒让它们不要在同一个瞬间扎堆过期第二和产品确认了业务容忍度——订单关闭本来就不是必须精确到秒的操作延迟一会儿人工也能接受。这两件事做到位问题就基本化解了。4.2 Redis重启后那一批悄无声息的key有一次我模拟Redis故障重启重启之后发现有相当一部分应该触发但还没触发的key过期事件再也没有出现过。业务那边对应的订单就干等在那儿没人把它关闭。原因是Redis在持久化重启过程中的特殊行为。Redis加载RDB或AOF文件时会重新构建每个key的剩余TTL但在这个加载阶段它不会发布任何键空间通知。更具体点说如果某个key在你的Redis停机期间本应过期重启加载文件后它的TTL可能已经归零Redis会直接把它清理掉但清理的时机在通知系统看来是重新上线后删除而官方机制为了保证加载过程的一致性并没有为这批秒删key补发事件。这事的教训是残酷的key过期事件天然缺了补偿这一环。Redis一重启那些本该到期的key和它们的事件就一起消失了。如果你的系统依赖这个事件去做状态流转就得做好兜底——比如监听器收到事件后把业务状态写进数据库并打上已处理标记然后还有一个定时任务扫描那些下单很久了但状态一直没动的订单防止Redis重启导致漏单。这个兜底任务频率可以很低比如半小时跑一次只查异常状态成本很低但能兜住大问题。4.3 监听器下线的那几分钟消息去了哪里还有一次是应用发版。我们有一个实例是订单过期监听器的唯一消费者发布脚本把它下线重启。在这个短暂的空窗期里Redis照常发布所有过期消息但没人接收消息在pub/sub通道里直接丢失了。等实例重新启动这些消息不会补发该取消的订单就错过了。这正是pub/sub模型的天性它是广播不是队列。消息发出去的时候没有任何消费者这条消息就等同于没存在过。你没办法设置消息积压也没法在消费端拉取历史消息。这一点和Kafka、RocketMQ的消费组模型有本质区别和Redis自己的List/Stream队列也不一样。所以只要你的监听器可能短暂不可用就不能把过期事件当作唯一的数据来源。我后来给关键业务做的补偿是监听器收到事件后先把订单号写入一个Redis List当缓冲区再异步从List里捞出来做真正的业务处理。这样即使处理逻辑瞬间失败数据还在List里有人能重试。4.4 集群和主从切换下的订阅断流如果你用的是Redis Cluster这个坑要提前知道。Cluster模式下key按hash槽分布在不同节点上过期事件由key所在的那个主节点发布。一个客户端只连某个节点就只能收到那个节点上的部分事件。Spring Boot默认连的是配置里的单个地址生产上通常会接Sentinel或Cluster。如果你只用了一台Sentinel或Cluster节点的地址那么Redis Connection会跟着节点归属走但你监听的事件范围天然受限。要比较完整地收到全集群的事件要么所有业务实例分别连不同节点分摊订阅要么引入代理层统一转发不管哪种都要自己做一些设计和验证。主从切换的情况更麻烦。Redis主节点挂了Sentinel把某个从节点提升为新主节点此时所有客户端要重新建立订阅连接。这个切换过程里旧主节点上有哪些key正好过期、新主节点会不会重新发布充满了不确定性。我遇到的情况是切换后有一部分事件确实没补发。如果你的业务对事件完整性要求很高集群模式下要考虑给每个主节点都建立监听或者干脆评估下这个方案是否还适合你的场景。4.5 内存淘汰驱逐的key请订阅evicted而不是expired最后一个坑很隐蔽。我一度以为key被Redis删掉都会走expired事件直到有一天发现部分key没有TTL但我的监听器收到了事件——仔细排查才发现那根本不是过期而是Redis内存使用量超过了maxmemory触发了allkeys-lru之类淘汰策略把这些key驱逐了。淘汰事件和过期事件在Redis里是两回事x代表过期删除e代表驱逐删除。你订阅的是Ex那就只包含过期不包含驱逐。如果你业务上确实需要关心key被踢出内存这件事要把配置改成Ee或同时订阅两类notify-keyspace-events Exe但提醒一句驱逐事件只有在maxmemory被触发时才有量小还好说量大时说明你Redis内存已经告急首先要解决的其实是内存容量问题而不是去监听“谁被踢了”。5. 可靠性这笔账什么时候能放心用什么时候得换方案5.1 四种方案放在一起对比做过上面这些排查之后我对Redis过期事件的定位变得非常清晰了。它是一个轻量、好用、但可靠性一般的方案。具体到选型我习惯拿这四行来判断方案实时性消息可靠性实现成本适合场景Redis key过期事件秒级会有延迟低监听器离线即丢重启补发不了低内部辅助、非关键通知、可接受偶发丢失Redis ZSet 定时扫描取决于扫描频率中数据持久化在Redis里可重扫中中小规模延时任务进程内延迟队列/时间轮毫秒级低进程重启丢中单机场景、无需跨实例消息队列延时消息/死信队列毫秒到秒级高有ack、有重投高资金、订单等强一致核心链路就订单超时取消这个例子来说订单状态本身就是有业务单据的即便Redis事件丢了也能靠兜底任务补偿所以用Redis过期事件完全说得过去。但如果你要做的是用户付款后十分钟内必须发货晚一分钟就要赔偿那绝不能把身家性命压在pub/sub上直接用带ack机制的消息队列更稳妥。5.2 我的取舍原则与补偿手段我现在的选择标准大致是这样第一看业务能不能接受偶尔漏一个。能接受就用Redis过期事件简单高效不能接受直接换延迟队列不要在Redis上做各种花式补偿来给自己找麻烦。第二只要用了Redis过期事件就默认配套一个兜底机制。最轻量的兜底就是前面提到的监听器收到事件后把业务ID写进Redis List或数据库表后面再异步消费也可以加一个低频率定时任务扫描应该完成但没完成的业务记录。兜底任务不需要频繁但必须有它决定了这个方案的可靠性底线。第三监听器回调里千万不要直接做耗时的外部调用。这个容器靠一个线程分发消息如果你在onMessage里做HTTP请求或复杂数据库事务后面进来的消息全部排队延迟会进一步放大。我一般的做法是回调里只做轻量解析和入队真正的业务处理放到自己的线程池或独立消费者里去。最后再说一个我踩过之后很受用的细节key的命名一定要带业务前缀。你监听的是全库的过期事件同一个Redis里可能有别的团队也在用没有前缀过滤你必须一眼能分辨这个key是不是自己的。习惯上我喜欢用这种格式业务模块:业务类型:业务ID比如order:timeout:20250101001。解析的时候固定按前缀分流既不漏、也不串后面不管加多少种业务监听器都不会变成一个面条工程。如果你现在正准备在做类似的功能我个人更建议先把第四章那几类问题在测试环境里挨个复现一遍批量过期、重启Redis、停掉监听器。每个坑都亲自踩一次你对这个方案的边界会有非常直观的理解。Redis消息监听器不是万能的但在合适的场景下它确实是让你少写一堆定时任务的好东西。