Redis订阅丢消息排查:notify-keyspace-events配置与Pub/Sub边界
上周有个电商团队的哥们儿来找我,说他们用 Redis 订阅 key 过期事件做订单超时自动关闭,上线三天,一单都没自动关。代码翻来覆去查了好几遍,逻辑看着没问题,后来我登到他们服务器上敲了一行命令:CONFIG GET notify-keyspace-events,返回是个空字符串。问题当场就清楚了——他们订阅的是 __keyevent@0__:expired,但 Redis 的键事件通知默认压根没开,相当于在一个没通电的插座上等着接电。
这个案例不是我第一次见了。很多团队把“Redis 订阅丢消息”当成一个玄学问题,换客户端、换服务器、加日志,折腾半天,结果根因往往就藏在一两个被忽略的配置里。今天这篇就把“Redis 订阅丢消息”这件事彻底拆开,讲清楚哪个配置忘了开、哪些场景下是 Pub/Sub 本身的能力边界、哪些是架构拓扑挖的坑,最后给一套可以照着操作的排查流程。不管是刚接触 Redis 的新人,还是在排生产事故的老手,这篇文章应该都能帮上忙。
1. 先分清你订阅的到底是消息频道,还是键事件通知
很多人在讨论“Redis 订阅”的时候,把两种完全不同的机制混在一句话里说。这不怪大家,因为官方文档里都管它们叫 Pub/Sub。但排查丢消息的时候,你首先得搞清楚自己用的到底是哪一个,因为它们的配置方式、消息来源、丢失模型,差距非常大。
1.1 Pub/Sub:电台广播模式的实时消息
传统意义上的 Redis Pub/Sub,就是 PUBLISH channel message 和 SUBSCRIBE channel 这套命令。发布者往一个频道里丢消息,所有订阅了这个频道的客户端实时收到。它工作起来就像电台广播,收音机没打开的人,是听不到刚才播的那首歌的。
这个模式最大的特点就是即时、轻量、无状态。发布端发完就完事了,不关心现在有没有人订阅,也不负责把消息存下来等别人来取。如果消息发出去的那一刻,频道里一个订阅者都没有,这条消息就永久消失了。这不是 bug,这是 Pub/Sub 的默认行为,也是很多人骂“Redis 丢消息”的第一大冤案。
1.2 Keyspace Notifications:Redis 主动告诉你键发生了什么
另一种“订阅”指的是 Redis 2.8 开始引入的 Keyspace Notifications(键空间通知)。它的工作方式不一样:当某个键执行了 SET、DEL、EXPIRE,或者因为过期、被内存淘汰而消失时,Redis 会主动往一个内部生成的通道里发一条消息。
这些通道有两种前缀,很容易把新人绕晕:
__keyspace@<db>__:<key>:以键名作为通道名,消息内容是事件名,比如expired、del、set。__keyevent@<db>__:<event>:以事件名作为通道名,消息内容是键名。
比如你订阅了 __keyevent@0__:expired,那么数据库中编号为 0 的库里,任何键发生过期时,你都会收到一条消息,消息体就是那个过期的键名。
这条链路看着不复杂,但有一个致命前置条件:Redis 默认不开启键事件通知。也就是说,notify-keyspace-events 这个配置的默认值是一个空字符串,意味着什么事件都不推送。很多人看教程学会了订阅的命令,却没看到教程里写的“先开启配置”这一步,于是代码写对了,消息却一封都收不到。
1.3 默认不开启是有原因的
有朋友可能会问:既然 Keyspace Notifications 这么好用,为什么 Redis 不默认打开?答案是成本。每次键上发生相关操作,Redis 都要额外判断一下这个操作是否属于可通知的事件类型,然后组装消息、写入内部发布通道,再通过 Pub/Sub 机制推送出去。对于写入量非常大的场景,这个额外的判断和广播开销是不可忽略的。Redis 设计者的思路很明确:这是高级功能,谁需要谁显式打开。
所以你在网上搜“Redis 订阅丢消息”,十有八九最后都指向这一个配置。它不是唯一的坑,但绝对是命中率最高的那一个。
| 对比项 | Pub/Sub | Keyspace Notifications |
|---|---|---|
| 通道来源 | 业务代码通过 PUBLISH 手动指定 |
Redis 内部生成的固定通道 |
| 是否需要配置 | 不需要,即开即用 | 需要 notify-keyspace-events 非空 |
| 消息内容 | 任意业务内容 | 键名或事件名 |
| 订阅方式 | SUBSCRIBE / PSUBSCRIBE |
同样走 SUBSCRIBE,但通道名是约定格式 |
| 默认状态 | 可用 | 默认全部关闭 |
2. notify-keyspace-events:几乎所有“订阅丢消息”的根因都在这
这个配置大概率就是标题里说的“那个配置”。它全名叫 notify-keyspace-events,通过一串字母来控制哪些类型的事件需要被推送。看起来就是一行字符串,但里面每个字母都代表一类操作,组合起来要花点心思。
2.1 事件标志字母表
配置值的格式是一串标志字符,以下是常用的几个:
| 标志 | 含义 | 说明 |
|---|---|---|
| K | keyspace 事件 | 以键名作为通道名推送,消息内容是事件类型 |
| E | keyevent 事件 | 以事件类型作为通道名推送,消息内容是键名 |
| g | 通用命令相关事件 | 包括 DEL、EXPIRE、RENAME、UNLINK 等 |
| $ | 字符串命令相关事件 | SET、SETEX、INCR 等 |
| l | 列表命令相关事件 | LPUSH、RPOP 等 |
| s | 集合命令相关事件 | SADD、SREM 等 |
| h | 哈希命令相关事件 | HSET、HDEL 等 |
| z | 有序集合相关事件 | ZADD、ZREM 等 |
| x | 过期事件 | 键过期被清理时触发 |
| e | 逐出事件 | 内存淘汰策略触发时产生 |
| A | 别名 | 等于 g$lshzx,但不包含 K 和 E |
这里有个非常容易踩的坑:配置字符串里至少要包含一个 K 或 E,否则其他标志都不生效。很多人写的是 notify-keyspace-events "gx",以为这就监听过期事件了,结果 Redis 直接忽略,什么都收不到。K 决定了你要不要 keyspace 维度的事件,E 决定了要不要 keyevent 维度的事件,这两个是总开关,必须先选一个或两个都选。
2.2 高频组合和业务场景
我平时最常用的组合是两个:
Ex:keyevent + 过期事件。订阅__keyevent@0__:expired,用来做延迟标记、缓存过期后的补偿动作。Kx:keyspace + 过期事件。如果业务上需要对某些特定键精确感知“我关注的这个 key 过期了”,用这个组合,订阅__keyspace@0__:<具体的key>,消息内容就是expired字符串。
如果你只想快速试验,用 KEA 把所有事件全打开也行,但生产环境不建议这么干。写入量大的业务里,全开会产生海量事件广播,对带宽和 CPU 都是额外负担。先想清楚你到底需要哪几类事件,按需组合。
另外一个必须提醒的点:过期事件并不是时间精确的。Redis 的过期键清理有两种方式,一种是惰性删除,也就是键被访问时才检查是否过期;另一种是主动删除,后台周期性地抽样清理。TTL 归零之后,要等真正被清理的那一刻才会触发 expired 事件,所以事件到达时间可能比 TTL 到点晚几十秒甚至更多。如果你的业务依赖“到期就一定要触发”,不能只靠这个机制。
2.3 开启配置的两种姿势
第一种方式,修改 redis.conf 配置文件,在文件里加一行:
保存后重启 Redis 生效。这种方式最稳妥,重启后配置还在,适合一次性把服务器配置到位。
第二种方式,线上临时改,不重启:
这个命令立即生效,但只改当前运行的内存配置,不会写进 redis.conf。很多人在测试环境敲了一下 CONFIG SET,发现能收到事件了,就以为万事大吉,结果 Redis 一重启,配置又回到了空字符串,线上继续丢消息。
如果你希望把运行时的配置持久化下来,执行完之后再敲一句 CONFIG REWRITE,Redis 会把你当前的配置改写回配置文件。
还有两个常见环境要单独说明。Docker 方式部署的 Redis,基础镜像自带的配置基本都没有开启这个选项,需要在启动命令里挂载配置文件或加参数;云厂商提供的托管 Redis,管理控制台一般也没有可视化开关,需要你自己想办法执行 CONFIG SET。总之,无论用什么方式部署,验证的第一步永远是先看这个配置值。
2.4 开完配置怎么验证真正生效
配置改完之后,我建议不要急着去跑业务代码,先用 redis-cli 做一次最原始的消息传递验证。开两个终端:
终端 A 订阅过期事件:
终端 B 写入一个带 TTL 的键:
等大约 5 秒,如果配置生效,终端 A 会收到一条 pmessage,内容是 testkey。如果没收到,优先检查以下几点:
- 配置是不是真的写进去了,用
CONFIG GET notify-keyspace-events看一眼当前值。 - 数据库编号是不是对得上。
__keyevent@0__:expired里的 0 是数据库编号,如果你的业务用的是 db 1,通道就要写成__keyevent@1__:expired。 - 事件名有没有写错。事件名都是全小写,比如
expired,写Expired或者expire都收不到。
这一步验证通过之后,再去查你的业务代码,排查方向才不会跑偏。
3. 就算开关打开了,这三类场景照样会让你觉得“又丢了”
配置开了,命令也对,但这不代表绝对不会丢消息。下面这三类场景中,有一些是 Pub/Sub 模型本身的特性,有一些是代码姿势问题,还有一些和连接管理有关。它们看起来都是“订阅丢了消息”,但处理方式完全不一样。
3.1 订阅动作还没就绪,消息就已经发完了
这类问题在应用启动阶段特别常见。很多人写代码的时候,会在某个异步线程里去执行 SUBSCRIBE,然后主线程立即开始处理业务逻辑,业务逻辑里一旦有 PUBLISH 操作,就很容易出现消息发出去了,但订阅线程还没真正建立连接、还没进入监听状态的情况。这中间发布的消息,不会有任何人收到。
另一个高频错误是连接复用。Redis 的 SUBSCRIBE 是一个阻塞式命令,而且一个连接进入订阅模式之后,在退出订阅之前,这个连接基本就不能再干别的事了。有些代码把订阅逻辑和业务逻辑放在同一个连接上执行,先 PUBLISH 再 SUBSCRIBE,后者会一直阻塞住当前线程,甚至直接被框架报错,消息收发当然就乱了。
对这种问题,我的建议很直接:订阅逻辑必须在独立的线程、独立的连接里运行,而且业务启动流程里要有一个“订阅就绪”的确认点,确保对外服务开始处理请求之前,订阅连接已经建立完毕。这不是 Redis 的问题,是代码时序的问题。
3.2 断线重连窗口期,没有任何机制替你缓冲
这是一个经常被误解的点。很多人以为 Redis 有持久化,那订阅消息是不是也应该能重放?不是。Pub/Sub 的发布是 fire-and-forget,消息一旦发布,Redis 不会为它做任何形式的存储,服务端和客户端之间哪怕断线一秒钟,这一秒内发布的消息就彻底没了。
断线的可能原因很多,应用重启、网络波动、Redis 主从切换、云平台机器迁移等等。只要断线窗口存在,这一段时间的消息就会有一个真空期。很多团队在这类故障之后一脸无奈,因为我们确实没法让 Pub/Sub 把丢失的消息补回来。
如果说得直白一点,Pub/Sub 不是一个可靠的消息系统。如果你业务上要求“这条消息必须送达,没送达要有补偿机制”,Pub/Sub 从一开始就不该是你唯一的选择。下一章会细说替代方案,这里先记住结论。
3.3 连接池回收与输出缓冲区超限:订阅连接悄悄不见了
第三个坑藏在连接管理里,而且是最容易让人误判的坑。
先说连接池。很多团队用 Java、Go、Python 的 Redis 客户端时,都是统一走连接池管理。连接池给业务提供的语义是“借出连接、用完归还、重复利用”。但订阅连接是个特殊存在,它必须长期稳定地占用一条专用连接。如果你把 SUBSCRIBE 丢进普通业务连接池里执行,订阅连接可能被池子回收,或者被其他线程借走执行普通命令,整个订阅状态就会乱掉。轻则消息收不到,重则直接把连接搞崩。
还有更隐蔽的。很多可视化 GUI 客户端,为了让你能在界面上同时操作多个命令窗口,背后都是用连接池模拟的。你用它的某个窗口点了“订阅”,界面看起来一切正常,但这条连接可能没有真正保持监听状态,或者被归还到连接池里供其他窗口复用了。结果就是:你在 GUI 里订阅了频道,用 GUI 发布消息,发现订阅窗口收不到,立刻怀疑是 Redis 的问题。我排查过不少这样的案例,最后全都是让大家改用 redis-cli 验证,刚才第 2 章里那一套验证方法,天然就是为了绕开这个坑。
连接管理还有一个容易被忽略的配置:client-output-buffer-limit。Redis 给不同类型的客户端设置了输出缓冲区上限,pubsub 客户的默认值是 32mb 8mb 60,意思是缓冲区的硬限制是 32MB,如果超过软限制 8MB 并且持续 60 秒,Redis 会直接把这个订阅连接断开。假如你的消费者处理消息的速度跟不上服务端推送消息的速度,消息就会在客户端的输出缓冲区里积压,一旦超出限制,订阅连接就被强制断开,后面推送的消息全部丢失,而客户端可能直到下次发送心跳才发现连接已经死了。
判断这个问题出现与否,可以执行 CLIENT LIST,找到对应订阅连接,看 omem 字段,这个值表示该连接的输出缓冲内存占用。如果 omem 长期居高不下,说明消费速度已经跟不上,连接随时可能被 Redis 剪断。
4. Cluster 和主从架构下,订阅关系比你想的更脆弱
如果说前面几章是单机 Redis 的场景,那部署架构一变,问题还会再复杂一层。很多团队在单机上开发测试一切正常,一上生产环境就丢消息,往往就是没考虑集群和主从切换对订阅关系的影响。
4.1 集群里你连的是哪个节点,连接断了就真断了
Redis Cluster 模式下,PUBLISH 默认会通过 cluster bus 在全集群内广播消息,确保订阅了任意一个节点的客户端都能收到消息。这套机制在集群正常的时候是没有问题的,但它依赖的是节点之间的转发链路,以及客户端和节点之间那条活着的 TCP 连接。
真正会出问题的是节点不可用的场景。你维护的订阅连接如果连着一个正在发生故障的节点,比如网络分区了、节点宕机了、正在进行 failover,这条连接会直接断掉。在连接断开、客户端重新连上新节点之前的这段空窗期,所有发布出来的消息都无人接收,也没有任何补发机制。
有一个思考误区是“Redis 高可用,所以消息也不会丢”。实际上 Redis 的 Pub/Sub 高可用,充其量只能保证订阅端在断线后可以自动重新连上去继续收未来的消息,它没法保证断线期间的消息能被补投递。这是模型层面的能力边界,不是配置能弥补的。
4.2 主从切换那一刻,订阅连接被连根拔起
主从切换是丢订阅消息的高发时刻。主节点宕机后,哨兵或集群管理组件会提升一个从节点为新的主节点。旧主节点上所有客户端连接都会断开,其中自然包含订阅连接。如果客户端没有配置完善的自动重连逻辑,哪怕新主节点已经就绪,客户端也会一直傻傻地停在断开状态,直到有下一次请求才报错。
就算客户端重连得很快,从旧主节点断开到重连新主节点成功之间的消息也一样会丢,因为消息本身没有持久化。所以主从切换带来的不仅仅是秒级不可用,还可能是几条关键消息的永久丢失。对强一致、高可靠诉求的业务来说,这不是一个可以接受的行为。
所以我在给团队做技术方案时一贯的态度是:如果业务语义是“必须收到消息”,就不要让 Pub/Sub 承载核心链路,把它替换成 Redis Stream 加消费组,或者上专门的消息队列。Stream 中每条消息都会持久化,消费者处理完需要显式 ACK,断线重连后还能从上次的消费位点继续拉取,这才是“不丢消息”的正解。
4.3 Redis 7.0 的 Sharded Pub/Sub 解决了什么问题
Redis 7.0 引入了一个新东西,叫 Sharded Pub/Sub,命令是 SSUBSCRIBE 和 SPUBLISH。它的思路是让消息按通道的哈希槽路由到集群中的某个分片,只有承载该分片的节点才会收到并转发消息,从而避免普通 PUBLISH 在集群里全量广播带来的放大效应。
但注意,Sharded Pub/Sub 解决的是集群广播的带宽和性能问题,不是可靠投递问题。它依然不具备持久化,没有 ACK,没有重放机制。如果现有环境是普通 Pub/Sub,因为丢消息想换到 Sharded Pub/Sub,那是治标不治本,该丢的照样丢。
5. 五步排查:“丢消息”从玄学变成可验证问题
讲了这么多原因,现在给一套可以直接照做的排查流程。每次遇到“Redis 订阅丢消息”,别慌,按这五步走,基本能定位到七八成的问题。
5.1 第一步:用 redis-cli 做最小化复现
别一上来就翻业务代码,先开两个终端,用 redis-cli 做一次原始的 Pub/Sub 测试。
终端 A:
终端 B:
如果终端 A 收到了 hello,说明 Redis 的 Pub/Sub 基础能力完全正常,问题出在配置、代码或者连接管理上。如果终端 A 没收到,那问题就粗暴了:要么你 Redis 连错了实例,要么网络层有隔离,要么通道名就没对上。
如果要验证的其实是 Keyspace Notifications,就把 A 改成订阅 __keyevent@0__:expired,B 去写一个带 TTL 的键,看 A 能不能收到。
这一步的要点是:绝对不要用 GUI 工具做验证。前面说过,GUI 工具的连接池会给你制造假象,redis-cli 是独立终端、独立连接,没有任何中间干扰。只有当 redis-cli 能稳定复现之后,再去怀疑业务代码。
5.2 第二步:检查订阅配置和通道名是否匹配
最小化复现失败的话,第一站永远是确认 notify-keyspace-events 的当前值:
如果返回空,配置没开,直接参考第 2 章的方案开启后再测。
如果配置没问题,紧接着查通道名。通道名有三个最容易出错的细节:
- 前缀写错:
__keyspace@0__:testkey和__keyevent@0__:expired是两个完全不同的通道方向,前者是“某个键的状态变化”,后者是“某类事件的所有键”,不要搞混。 - 数据库编号写错:
__keyevent@1__:expired和__keyevent@0__:expired听起来很像,实际是两个通道。 - 事件名大小写、复数形式写错:
expired、del、set都是全小写单词。
还可以用 PUBSUB CHANNELS 和 PUBSUB NUMSUB 来看当前实例上到底有没有人订阅了相关通道。发布时如果 NUMSUB 是 0,那消息没人接收就是必然结果,也别怪 Redis。
5.3 第三步:确认连接状态和输出缓冲区
排除配置和通道名问题之后,就要看订阅连接本身是否还活着。
执行 CLIENT LIST,找到自己的订阅连接,确认它的状态。如果连接早就掉了,客户端日志里一般会有断连记录,比如 connection reset、socket closed 之类的,说明断线引起的丢消息已经发生过。
再关注一下 omem 字段。如果订阅连接的输出缓冲区内存很高,说明消息堆积、消费速度跟不上,随时可能被 client-output-buffer-limit 限制强制断开。这时候要优化的不是 Redis 配置,而是消费者的处理速度和线程模型,最好给消费逻辑加上独立的线程池或队列做缓冲。
5.4 第四步:从业务代码回查订阅时机和线程模型
如果你用的框架本身封装了订阅容器,比如 Spring Data Redis 里的 MessageListenerContainer,框架一般会自己维护订阅线程,出问题的概率小一些。但如果你是自己手写客户端做订阅,有几个常见问题需要重点审视:
SUBSCRIBE是不是在业务线程里执行的?如果是,它会把当前线程阻塞住,后面的代码根本走不到。- 订阅连接是不是从连接池里临时借的?如果是,连接用完归还后,订阅状态会发生不可预知的变化。
- 发布和订阅是不是混用了同一个连接?订阅模式下的连接理论上不应该再用来执行
PUBLISH等普通命令。
建议在排查阶段给订阅连接加上独立的建立和重连逻辑,并且在连接建立成功后打印一条明确的日志。有了这条日志,你才能确认“订阅就绪”这一步真的完成了,而不是在启动时序里被跳过了。
5.5 第五步:按业务语义拍板,修配置还是换方案
走到这一步,问题已经定位得差不多了。接下来不是修一个点就行,而是要根据业务对消息可靠性的要求做技术选型。
| 业务场景 | 推荐方案 | 理由 |
|---|---|---|
| 实时广播,可容忍个别消息丢失 | Pub/Sub | 简单、低延迟 |
| 监听键过期、键变化等内部事件 | Keyspace Notifications | 需要开启 notif-keyspace-events |
| 必须不丢消息,需要按顺序消费 | Stream + 消费组 | 持久化 + ACK + 历史消息位点 |
| 多个消费者要分摊消息处理 | Stream + 消费组 | 消费者组内自动负载均衡 |
| 跨系统、高可靠异步解耦 | 独立消息队列,如 RabbitMQ / Kafka | 功能更全,运维更重 |
简单总结我的判断逻辑:如果消息内容本质上是 Redis 键事件,就用 Keyspace Notifications,前提是配置要打开;如果是应用业务消息,且丢了能接受,用普通 Pub/Sub 最省事;如果丢一条都可能造成资损或者对不了账,不要犹豫,直接上 Stream 加消费组。给 Pub/Sub 打补丁式的修复,不如换一个天生支持回放的模型。
我在实际项目的习惯是,任何“订阅丢消息”的排查,第一件事永远是开两个 redis-cli 终端做最小验证,然后用 CONFIG GET notify-keyspace-events 看开关。这两步做完,已经有很大概率找出问题所在。剩下的,拼的不是运气,而是你对两种模型能力边界的理解和代码时序的耐心检查。