前几天朋友找我排查一个看起来很诡异的问题:他们用 Redis 的 Pub/Sub 做订单状态变更通知,客户端明明已经subscribe上了,可一到高峰期就有部分消息收不到。一开始大家都怀疑是网络抖动,后来我远程让他执行一条CONFIG GET client-output-buffer-limit,问题当场就暴露了。
今天借这个案例好好聊聊:Redis 订阅丢消息,十有八九不是 Redis 本身“丢”了,而是某个你从没注意过的配置,在某个时刻把订阅连接悄悄掐断了。
这个内容适合正在用 Redis Pub/Sub 做实时通知、但又对可靠性心里没底的同学。我会把机制原理、最容易被忽略的配置、排查命令和替代方案一次讲透,你看完至少能少踩三个坑。
1. 先搞清楚:Redis 订阅为什么会“丢消息”
1.1 Pub/Sub 的消息是被“即时投递”的,不存在队列
Redis 的 Pub/Sub 模型本质上是广播模型。发布者往某个频道publish一条消息,Redis 只会把这条消息转发给“此时此刻正在订阅这个频道”的客户端。它不会把消息存下来,也没有队列的概念。
我用一个生活化类比帮你理解:Pub/Sub 就像对讲机。你说一句话,只有当时把对讲机调到同一个频道、并且开着机的人才能听到。如果有人正好没开机,或者中途换台了,这句话就永远过去了,不会等你回来再放一遍录音。
这个机制决定了,Pub/Sub 本身就有天然的丢消息可能:
- 发布消息时,客户端还没完成订阅;
- 客户端订阅后,连接因为网络、超时、服务端策略被断开;
- 客户端在断线重连期间,别人发布了消息;
- 客户端收到了消息,但处理过程中应用崩溃。
前三种情况在大多数业务场景里都会被笼统地称为“订阅丢消息”,而且很多时候不是 Redis 的 bug,而是设计边界。真正需要排查的,是那些“连接明明存在,却被系统主动断开”的场景。
1.2 真正需要排查的是“连接被悄悄断开”
我在国内几个技术社群里看过不少类似问题,很多人一上来就怀疑 Redis 单点故障、网络丢包,折腾半天最后发现是配置问题。典型的场景是:业务低峰期一切正常,一到高峰期就丢消息,而且丢消息的间隔很有规律,甚至每次丢完之后过一会儿又能恢复。
这种“周期性丢消息”,十有八九是服务端或客户端把订阅连接给断开了。断开之后,客户端库如果没有自动重连,或者重连之后没有重新执行subscribe,那断线期间的发布消息就全部丢了。等客户端重新订阅成功,看起来又一切正常,所以非常有迷惑性。
真正需要重点关注的服务端配置有三个:client-output-buffer-limit、timeout、tcp-keepalive。前两个是最容易踩坑的,下面一个一个说。
2. 最容易被忽略的配置:client-output-buffer-limit pubsub
2.1 这个配置是干什么的
Redis 为每个客户端连接维护了一个输出缓冲区,用来暂存还没有发送给客户端的数据。普通命令连接通常不会出问题,但 Pub/Sub 连接比较特殊:只要发布者不停发,Redis 就会往订阅者的输出缓冲区里写数据。
如果订阅者消费速度跟不上,缓冲区就会一直涨。为了防止某个慢客户端把 Redis 内存吃光,Redis 提供了一组保护参数,叫client-output-buffer-limit。
默认配置里,pubsub 相关的限制是:
client-output-buffer-limit pubsub 32mb 8mb 60这行配置表示三个值:
32mb是硬限制。如果某个订阅客户端的输出缓冲区超过 32MB,Redis 会立即断开这个客户端。8mb 60是软限制。如果输出缓冲区持续超过 8MB,并且这种情况连续超过 60 秒,Redis 也会断开这个客户端。
很多人从来没查过这个配置,更没想过它会和“丢消息”扯上关系。但它恰恰是 Pub/Sub 场景下最经典的隐形杀手。
2.2 为什么它会让你“丢消息”
假设你有一个频道,高峰期每秒发布 1000 条消息,每条消息平均 2KB。那么一秒就会产生 2MB 的数据。如果订阅者消费线程卡了 10 秒,Redis 这边的输出缓冲区就会积压 20MB,还没到硬限制 32MB,但已经超过软限制 8MB 了。如果消费线程持续卡顿超过 60 秒,Redis 会直接把这个订阅连接断开。
连接断开后,客户端库通常会抛异常,你的订阅线程可能就退出了。即使客户端库有重连机制,重连后也需要重新subscribe。从断开到重订阅完成的这个窗口期里,所有publish的消息都会丢失。
更隐蔽的是,这个“慢消费”不一定是你业务处理慢,也可能是网络抖动导致 TCP 窗口变小,或者订阅端机器负载高导致读取不及时。Redis 只看缓冲区大小,不会管你是什么原因,超了就杀连接。
我当时帮朋友排查时,用redis-cli client list看了一眼 Pub/Sub 客户端的omem字段,发现高峰时这个值能飙到十几 MB。再结合默认的pubsub 32mb 8mb 60,基本就能确认是这个配置导致的连接被反复清理。
2.3 正确调整方式与注意事项
如果确认是这个配置导致丢消息,调整方式很简单,运行:
CONFIG SET client-output-buffer-limit "pubsub 64mb 16mb 120"但注意,CONFIG SET只对当前运行时生效,重启 Redis 后会恢复成配置文件里的值。所以你还得把redis.conf里对应这一行改掉,保持重启后一致。
生产环境下给多少合适,不能拍脑袋,我一般按这个步骤估:
- 先压测或者看监控,统计单频道高峰期的发布速率,记为
P(条/秒)。 - 统计一条消息的平均大小,记为
S(字节)。 - 估算你允许消费者最长阻塞多久,记为
T(秒)。 - 那么缓冲区理论峰值大约是
P * S * T。
比如高峰期每秒 1000 条消息,每条 2KB,允许消费线程阻塞 30 秒,那理论峰值大约是 60MB。我会建议硬限制留 1.5 到 2 倍余量,比如设成128mb 32mb 60。
这里必须提醒一句:缓冲区调大只是给了 Redis 更多内存来容忍慢消费者,不代表慢消费者的问题消失了。如果订阅端应用本身处理不过来,或者 GC 卡顿严重,调大配置只能延缓断连,不能根治。而且如果把限制调得过大,Redis 内存压力会上升,极端情况下可能触发maxmemory淘汰策略,影响所有业务。所以调配置的同时,一定要盯住订阅端的消费延迟。
3. 第二个隐藏配置:timeout 和 tcp-keepalive 正在误伤订阅连接
3.1 服务端 timeout 把空闲订阅连接当成僵尸连接
Redis 有个配置叫timeout,默认是 0,表示不主动关闭空闲连接。如果你的redis.conf里把它设置成非 0,比如timeout 300,那 Redis 会每 300 秒检查一次,发现某个客户端连接空闲超过 300 秒,就直接关掉。
对于普通命令连接,这个机制没什么问题。但 Pub/Sub 连接很容易踩坑:很多通知场景是非常低频的,比如半小时才发一条消息。在两条消息之间,这个订阅连接是“空闲”的。如果你把timeout设置成了 60 或者 300,Redis 就会认为这是一个空闲连接,然后把它关掉。
连接之后,客户端如果没有重连重订阅,那下一次消息就收不到了。这种丢消息特别难查,因为它和高峰期无关,纯粹是时间到了就断。
排查方法:
CONFIG GET timeout如果返回值不是0,而且你有 Pub/Sub 长连接,我建议直接把timeout改成0,或者确保客户端库有断线重连和自动恢复订阅的机制。
3.2 客户端 readTimeout 让订阅线程“假死”
服务端配置只是其中一半,另一半坑在客户端。很多语言库的 Pub/Sub 订阅是一个阻塞操作,比如 Java 的 Jedis,subscribe方法会一直阻塞等待消息。Jedis 在创建连接时可以设置soTimeout,如果这个值不是 0,socket 在读数据时超过指定时间没有数据,就会抛出SocketTimeoutException。
问题来了:Pub/Sub 连接在空闲时本来就没有数据。如果你给订阅连接设置了 5 秒的读超时,那 5 秒内没有消息,线程就会异常退出。等发布者的消息到达时,订阅线程已经不在了,消息自然丢失。
我自己踩过这个坑。之前写一个 Java 订阅服务,图省事复用了普通连接的JedisPool配置,里面设了timeout=3000,结果上线后每隔几分钟就丢一批消息。后来才反应过来,普通命令请求是“发一条,等一条”,所以超时合理,但订阅是“默默挂在那边等推送”,根本不适合套用短超时。
正确做法是:订阅专用的连接,把读超时设为0,也就是无限等待。Jedis 里大概是这么回事:
// 注意:最后一个参数 soTimeout 要传 0 Jedis subscriber = new Jedis(host, port, connectionTimeout, 0);如果你用的是 Spring Data Redis 的RedisMessageListenerContainer,它默认会帮你维护连接和订阅,通常不会因为单条消息超时退出,但你要留意容器有没有被误关闭、线程池是否被拒绝任务。
Lettuce 的情况稍微复杂一些,默认命令超时一般不会影响订阅消息的接收,但如果你在连接上又套了自定义的timeout,或者依赖了某些代理层,还是要单独压测验证一下。
3.3 生产推荐配置组合
如果你确定要用 Redis Pub/Sub,并且不想在这个问题上反复折腾,我建议至少在服务端保持这样一组配置:
timeout 0 tcp-keepalive 60 client-output-buffer-limit pubsub 128mb 32mb 60timeout 0是让 Redis 不要因为空闲回收订阅连接。tcp-keepalive设置为 60,是为了让 Redis 和客户端之间的连接能穿透 NAT、负载均衡等网络设备的空闲超时,减少“连接假死”的概率。
4. 如果你用的是“键空间通知”,别忘了 notify-keyspace-events
4.1 keyspace 通知与普通 Pub/Sub 的区别
另一种常见的“订阅丢消息”,其实不是用publish/subscribe订阅业务频道,而是订阅 Redis 的键空间通知。比如你想在某个 key 过期时收到通知,于是订阅__keyevent@0__:expired这个频道。
这种功能底层虽然也走 Pub/Sub,但它有一个额外的开关:notify-keyspace-events。这个配置默认是空字符串,也就是所有键空间通知都关闭。如果你没有开启,就算客户端订阅了__keyevent@0__:expired,Redis 也根本不会向这个频道发布任何消息。
所以很多人会疑惑:我明明subscribe成功了,为什么一条消息都收不到?原因就是忘了开notify-keyspace-events。
4.2 以“订阅 key 过期”为例的配置方法
Redis 的notify-keyspace-events使用一串字符来开启特定类型的事件。常见的字符含义:
K:keyspace 事件,会收到__keyspace@<db>__:<key>频道。E:keyevent 事件,会收到__keyevent@<db>__:<event>频道。x:过期事件。g:一般命令事件。$:字符串命令事件。l:列表命令事件。s:集合命令事件。h:哈希命令事件。z:有序集合命令事件。A:等价于g$lshzxe等所有事件的简写。
如果你只是想订阅 key 过期,配置Ex就够。E表示开启 keyevent 频道,x表示包含过期事件。
临时开启:
CONFIG SET notify-keyspace-events Ex持久化到配置文件:
notify-keyspace-events Ex开启之后,客户端订阅__keyevent@0__:expired才能收到过期消息。
4.3 开了还是收不到?多半踩了这些坑
第一,db 序号要对。如果你操作的是 Redis 的 db 0,订阅频道就是__keyevent@0__:expired。如果你把 key 写到了 db 1,那应该订阅__keyevent@1__:expired,订阅 0 号库的频道自然收不到。
第二,事件发布时机和过期时间不完全精确。Redis 的过期事件只会在 key 被删除时发布,而删除可能发生在惰性删除、主动过期扫描、或者 key 被访问时。所以实际收到通知的时间可能比 TTL 到期时间晚,不要在高实时性场景里依赖它。
第三,键空间通知同样是 Pub/Sub,一样有断开连接丢消息的问题。之前提到的client-output-buffer-limit、timeout、客户端超时问题,在这里依然成立。
5. 完整自查清单:一次把订阅丢消息查明白
5.1 Redis 服务端配置排查
遇到订阅丢消息,我建议先按顺序执行这几条命令,把所有可疑配置一次性拉出来:
CONFIG GET client-output-buffer-limit CONFIG GET timeout CONFIG GET tcp-keepalive CONFIG GET notify-keyspace-events把这四个值记录下来,对照下面这张表判断风险:
| 配置项 | 推荐值 | 风险原因 |
|---|---|---|
| client-output-buffer-limit pubsub | 根据发布速率调整,默认值偏保守 | 缓冲区超限会断开订阅连接 |
| timeout | 0 | 非 0 时会回收空闲订阅连接 |
| tcp-keepalive | 60 或更小 | 不设置可能被网络设备断开假死连接 |
| notify-keyspace-events | 按需开启,如 Ex | 未开启时键空间通知完全收不到 |
另外看看 Redis 日志里有没有批量断连的记录,这个在排查时很有用。
5.2 客户端代码排查
服务端配置没问题,就要检查客户端。重点看这几个地方:
- 订阅连接是否和服务端保持长连接,是不是每次发消息前才临时订阅。
- 断线后有没有自动重连,重连后有没有重新执行
subscribe。 - 订阅连接的 read timeout 是否被设置成 0。
- 是否在多个线程里共用了同一个非线程安全的订阅连接。
- 使用 Spring Data Redis 时,有没有自定义
RedisMessageListenerContainer,有没有在容器启动后添加 listener。
我见过一个非常典型的错误:业务代码在每次发送通知前,先subscribe一下再发。要知道subscribe是阻塞调用,执行之后代码根本不会继续往下走,消息自然发不出去。这属于对 Pub/Sub 模型理解不到位,不是配置问题,但排查时容易把人绕晕。
5.3 用 redis-cli 快速定位现场
如果丢消息正在发生,可以用redis-cli连上去看看订阅客户端的输出缓冲区情况:
redis-cli client list | grep -i -E "flags=P|sub"输出里,flags=P表示这个连接处于 Pub/Sub 订阅状态。关注两个字段:obl和omem。obl是输出缓冲区中固定缓冲区的长度,omem是输出缓冲区占用的内存总量。如果你看到某个订阅客户端的omem涨到了几十 MB,同时又出现周期性掉线,那大概率就是client-output-buffer-limit在起作用。
还可以用redis-cli --stat持续观察clients数量。如果订阅连接数量周期性掉到 0 又恢复,基本能坐实是服务端在断连。
6. 如果业务不允许丢消息,直接换方案
6.1 什么时候该放弃 Pub/Sub
我必须说一句实话:上面这些配置调优,只能把 Pub/Sub 丢消息的概率降到比较低,但不能把丢消息概率降到 0。Pub/Sub 的不持久化、不确认、不重投,是它的设计选择,不是配置能改掉的。
如果你的业务要求“消息一个都不能丢”,比如支付回调通知、订单状态流转、对账消息,那最正确的做法不是继续给 Pub/Sub 打补丁,而是换一个具备持久化和确认机制的方案。
6.2 用 Redis Streams 做可靠通知的最小改造
如果不想引入 Kafka、RabbitMQ 这类重型中间件,Redis 本身还有一个更适合的模块:Streams。Streams 可以持久化消息,支持消费者组,还支持确认机制。
生产者发布消息:
XADD order:notify * orderId 1001 status paid消费者创建消费组:
XGROUP CREATE order:notify notify-group 0消费者读取消息:
XREADGROUP GROUP notify-group consumer1 COUNT 10 BLOCK 5000 STREAMS order:notify >处理完业务后确认消息:
XACK order:notify notify-group 1600000000000-0Streams 的消息会保存在 Redis 内存中(也受maxmemory策略影响),没有被确认的消息会留在 pending 列表里,客户端重启后可以继续消费。相比 Pub/Sub,它是把“在线才推送”变成了“消息存下来,消费端来取”,可靠性高一个量级。
注意,Streams 也不能完全替代专业消息队列。如果消息积压量非常大,或者希望消息堆积不影响 Redis 其他业务,我更建议直接上 Kafka 或 RabbitMQ。但如果你只是想把 Redis 里的通知机制从“广播”改成“可靠分发”,Streams 是非常平滑的迁移路径。
6.3 改造后的效果与遗留问题
我帮朋友把那个订单通知场景改造成 Streams 之后,效果很直接:高峰期不再有订阅连接被断开,因为消费者是自己主动拉取消息,而不是被 Redis 推送,输出缓冲区压力小很多。断线期间发布的消息也都还在,等消费者恢复后继续消费,业务上不再有“丢单通知”的投诉。
唯一要注意的是 Streams 需要额外配置内存上限和持久化策略,否则 Redis 重启后消息照样丢。生产环境至少要把 AOF 开启,并设置合理的maxmemory和淘汰策略。
我个人在实际操作中的体会是:遇到 Redis 订阅丢消息,先别急着怀疑网络和 Redis 进程,先花十分钟把client-output-buffer-limit、timeout、notify-keyspace-events这几个配置拉出来看一眼。大多数所谓“诡异丢消息”,最后都藏在配置里。但配置只是止血,真正想一劳永逸,还是要回到业务对可靠性的要求上,选对消息方案。