Redis Pub/Sub消息丢失排查:输出缓冲区限制与慢消费者实战
1. 现象复盘:日志显示已发布,订阅端却沉默了一整天
1.1 第一轮常规排查:代码、网络、序列化全部“正常”
某个周三下午,订单组反馈线上支付成功后有部分用户积分没到账。一查代码,支付成功回调里执行的是这样一行:
日志显示这行代码执行了,返回值也是正常的状态,说明 Redis 确实收到了命令。积分服务那边实现了一个标准的 MessageListener,在 onMessage 方法里第一行就打印了日志,但那个时段的日志里就是找不到对应的消息。
这种“发布端说发了、消费端说没收到”的问题,是分布式系统里最磨人的一种。我按惯例先怀疑了几个常见点:
- 频道名称是否一致:检查了两边,确认都是字符串
order.paid,没有空格、没有大小写问题。 - 序列化方式:发送端和接收端都用的
StringRedisTemplate,不存在 JDK 序列化和 JSON 序列化不匹配的情况。 - 网络连通性:两边到 Redis 的 TCP 连接都通,防火墙也没有拦截。
- 订阅容器是否启动:日志里
RedisMessageListenerContainer正常注册了监听器,没有启动失败。
更诡异的是,问题不是“完全收不到”,而是“大部分能收到,偶尔少几条”。这种偶发性最坑人,因为复现不了,你连排查入口都难找。我当时甚至怀疑过是不是 GC 停顿导致消费线程卡死,但和 GC 日志对不上。
1.2 从 Redis 自身找证据:PUBSUB 与 CLIENT LIST 暴露了真相
常规手段查不出结果后,我回到 Redis 服务器上直接看现场。先执行:
频道存在,订阅者数量也正常,说明路由没问题。接着我执行了:
重点盯一个字段:omem,也就是客户端输出缓冲区内存。正常情况这个值应该很小,但我发现积分服务对应的连接在消息峰值时 omem 一路涨到十几 MB,然后这条连接直接消失了。再过一两秒,客户端框架自动重连,订阅关系恢复,日志里只剩一条轻描淡写的 warn,稍不注意就会被忽略。
到这里真相已经浮出水面:连接被 Redis 服务端强制断开,断线期间发布的消息全部丢失,重连后又继续接收。真正的原因不是哪段业务代码写错了,而是 Redis 对 pubsub 类型客户端有一套默认的输出缓冲区限制:client-output-buffer-limit pubsub 32mb 8mb 60。这个配置几乎不会出现在入门教程里,但它恰恰是线上“订阅丢消息”最常见的元凶之一。
1.3 一个容易被误解的返回值:PUBLISH 返回数字不等于业务处理成功
这里要特别提醒一个认知误区。很多人看到发布端执行成功,就觉得消息已经“安全发出去了”。实际上 Redis 的 PUBLISH 命令返回的是一个整数,代表“当前订阅了这个频道的客户端连接数”。返回 1,说明 Redis 把消息写入到了一个订阅者的输出缓冲区,仅此而已。
它不代表消息被消费端业务处理完了,也不代表消费端不会丢。如果这个连接随后因为缓冲区超限被强制断开,那么 Redis 侧并不会重新投递。消息从缓冲区里消失,订阅端自然什么也收不到。这个返回值顶多能告诉你“当时有没有人在听”,不能告诉你“对方有没有真正拿到并处理完”。
2. Redis Pub/Sub 投递的底层逻辑:即发即弃,连接即一切
2.1 一条消息从发布到消失,Redis 到底做了什么
很多人把 Redis Pub/Sub 当成消息队列来用,这是最根本的误解。我们看看一条消息在 Redis 内部的生命周期:
- 发布者执行
PUBLISH order.paid 12345。 - Redis 在内存里找到所有订阅了
order.paid这个频道的客户端连接。 - 把消息追加到这些连接的输出缓冲区。
- 网络线程尝试把缓冲区的数据写到客户端 socket。
整个过程没有任何落盘动作。RDB、AOF 这两种持久化机制都不会保存 Pub/Sub 的瞬时报文。Redis 也不会为订阅者维护“待消费列表”,没有 ACK 机制,更不会因为你离线就给你留消息。
用生活化的例子来说,Pub/Sub 更像一群人开着免提打电话:你必须在通话里才能听到大家说话。中途挂断电话的人,错过了就是错过了,没有人会给你单独重播这一段。而消息队列更像快递柜,哪怕你当时不在家,包裹也会被放进柜子里,等你方便了再来取。Redis Pub/Sub 是前者,不是后者。
2.2 三个必丢场景:什么时候丢消息是“命中注定”的
理解了上面的机制,就能推导出三个必然丢消息的场景:
场景一:订阅晚于发布。 应用启动时,发布方某个定时任务先触发,或者支付回调网络先到,订阅方的监听容器还没就绪,消息发出去后没有任何订阅者在线上,直接就没人接收。很多人以为“后来订阅了就能收到之前的消息”,这是不可能的。
场景二:订阅方断线。 网络抖动、Redis 主从切换、客户端 Full GC 时间过长导致心跳超时、连接被服务端断开——只要连接一断,断线期间所有发布到频道的消息全部跳过。这里不存在“补拉”的概念。
场景三:消费速度跟不上发布速度。 这是最隐蔽的一种。订阅者明明在线,但回调方法耗时太久,Redis 服务端发送数据的速度远大于客户端读取数据的速度,消息在输出缓冲区里越积越多,最终触发断开。下面重点拆这个。
2.3 慢消费者机制:为什么“本地不丢、线上丢”
先理解 Redis 的输出缓冲区是什么。每个客户端连接在 Redis 服务端都有一段内存空间,用来暂存还没成功发送给客户端的数据。普通读写场景下,客户端发一个请求、收一个响应,缓冲区几乎不会积压。但订阅场景不同,服务端会持续往缓冲区里推数据,消费端如果读得慢,积压就来了。
Redis 为了防止某个慢客户端拖垮服务端内存,对不同类型客户端设了缓冲区上限。pubsub 类型客户端的默认值是:
意思很直接:缓冲区超过 32MB,立即断开连接;缓冲区超过 8MB,并且持续 60 秒,也断开连接。断开之后,消息自然就没了。
为什么很多人本地复现不出来?因为本地测试的订阅者通常只是打印一行日志,消费速度极快,缓冲区永远是空的。到了生产环境,onMessage 里面可能调了外部接口、写了数据库、发了短信,单条消息处理时间从几毫秒变成几百毫秒,发布端稍微一压,积压就上来了。线上丢消息、本地不丢,不是玄学,是慢消费被默认配置“优化”掉了。
2.4 集群环境还有一个变量:普通 Pub/Sub 的跨节点传播
如果你用的是 Redis Cluster,还要多确认一件事:普通 PUBLISH 是会向集群所有节点广播的,订阅端只要连接任意一个节点,节点之间会转发消息,保证订阅者能收到。这个设计保证了可用性,但代价是每个节点都要承担广播压力。
Redis 7.0 引入了 Sharded Pub/Sub,通过 SSUBSCRIBE、SPUBLISH 这类命令,让消息只在同一个分片内传播。两种模式的行为不同。如果说预期是“我只想收到某个分片的消息”,但用的却是普通订阅,那么你可能会受到其他分片的消息,也可能因为节点间转发逻辑和你理解的不一样,误以为消息丢了。集群模式下排查订阅问题,第一步先确认你用的是普通订阅还是分片订阅,这个前置条件不搞清楚,后面全是在猜。
3. 被默认配置掩盖的真相:client-output-buffer-limit 全拆解
3.1 三个数字分别控制什么
这个配置的完整格式是:
四个部分分别解释:
<class>:客户端类型。分三种:normal(普通读写客户端)、replica(从库连接,老版本叫 slave)、pubsub(执行过 SUBSCRIBE/PSUBSCRIBE 的订阅客户端)。<hard limit>:硬限制。输出缓冲区内存超过这个值,Redis 立即关闭客户端连接,没有商量的余地。<soft limit>:软限制。缓冲区内存超过这个值,不会马上断开,而是开始计时。<soft seconds>:软限制持续时间。超过软限制的状态持续了这么多秒,连接同样会被关闭。
默认配置通常长这样:
注意,normal 类型默认是 0 0 0,也就是完全不限制。所以普通读写客户端很少遇到“输出缓冲区超限被断开”的问题。但 pubsub 类型默认却有限制。为什么?因为发布订阅是即发即弃的,Redis 不可能让一个慢消费者无限积压消息,那会把服务端内存吃光。宁可断开,也要保护整体。这个设计本身合理,但它确实会让业务方付出“丢消息”的代价。
3.2 调大配置的正确姿势和注意事项
临时调整当前实例:
注意,CONFIG SET 只对当前运行中的实例生效,重启后会还原。想持久化,需要执行:
让 Redis 把当前配置写回 redis.conf,或者直接编辑配置文件:
改的时候注意三点:
- 调大只是给慢消费者更多的缓冲时间,治标不治本。消费端如果持续跟不上,缓冲区迟早还会涨上来,只不过把断连时间从几分钟延长到了几十分钟。
- Redis Cluster 场景下,每个节点都要改。可以通过配置管理工具统一下发,或者逐台执行
CONFIG SET和CONFIG REWRITE。 - 改之前用
CONFIG GET client-output-buffer-limit确认当前值,别上来就覆盖。
从我们当时的实践看,从 32MB 调到 64MB 确实显著减少了断连频率,但没有根除。真正根除还是靠消费端异步化,这个后面会细说。
3.3 另一个容易混淆的“忘了开”:notify-keyspace-events
排查订阅问题时,还会遇到一种长得特别像但原因完全不同的情况:用 Redis 的 keyspace notification 监听 key 过期、删除等事件,结果一条都收不到。
很多代码是这么写的:
但 redis.conf 里根本没有开启事件通知。Redis 的 notify-keyspace-events 默认是一个空字符串,意味着所有 key 事件通知都关闭。你需要根据实际需要配置,比如监听过期事件:
E 表示开启 key 事件,x 表示过期事件。如果希望开启所有事件,可以配置成 KEA,但生产环境一般不推荐,因为事件量太大会有额外开销。
这种“忘了开配置”和本文讲的 Pub/Sub 丢消息,症状上都表现为订阅方收不到消息,但问题层面完全不一样。面试时如果被问到 Redis 订阅相关的问题,能主动区分这两类场景,会显得你对底层的理解更扎实。
3.4 Docker 部署 Redis 时的常见坑:配置文件没挂载
热词里有人搜“docker安装redis主从”,这里多说一句相关经验。很多人用 Docker 启动 Redis 时是这样:
这种启动方式下,容器内使用的是官方默认配置,client-output-buffer-limit pubsub 虽然也有内置默认值,但你没法直接改。要调参,必须挂载配置:
主从模式下还要额外注意,主从切换期间所有客户端连接都会中断。Pub/Sub 的消息在这种故障窗口期照丢不误,这是架构层面的限制。主从能减少的是 Redis 宕机导致的长时间不可用,而不是 Pub/Sub 消息的可靠性。理解了这一点,就不会在架构评审时提出“用主从解决丢消息”这种方案。
4. 实操修复:服务端调参之外,客户端必须做三件事
4.1 服务端配置调整是第一步,但不是最后一步
把 client-output-buffer-limit pubsub 调大,能显著降低连接被强制断开的概率,这一点在故障应急时非常有效。但如果业务对消息有“不能丢”的诉求,服务端配置只是争取时间,客户端必须同步做改造。下面三个动作,我建议每条都落实。
4.2 客户端自救一:断线重连后必须重新订阅
Java 生态里,Spring Data Redis 的 RedisMessageListenerContainer 通常会自动处理重连和重新订阅。但自动重连存在一个盲区:服务端在 T1 时刻断开连接,客户端在 T2 时刻感知到并重连,T1 到 T2 之间发布的消息不会补发。日志里那条 warn 之所以容易漏掉,正是因为它看起来不像错误,只是连接重置。
如果用原生 Jedis,需要自己保证断线后重新订阅。subscribe 是一个阻塞方法,连接断开后会抛出异常,这时需要进入重连循环:
这段代码的核心思想是:订阅一旦退出,马上重来。但必须清醒认识到,重连前发生的事故已经无法追回。真要补偿,只能在业务层面做对账,比如定时扫描“支付成功但积分未发放”的数据,而不是指望 Redis 帮你补发。
4.3 客户端自救二:不要在 onMessage 里做慢操作
这是最容易踩、也是最容易改的问题。错误示范:
addPoints 如果依赖外部接口,一次调用几百毫秒,消费速度立刻下降。Redis 服务端往这个连接推消息的速度远大于应用读取速度,输出缓冲区就会快速膨胀。正确做法是把业务逻辑丢到独立线程池,让回调方法快速返回:
这里要提醒一点:线程池的队列必须有界,拒绝策略要谨慎。如果用无界队列,积压的任务会占用大量内存,最后应用 OOM,比 Redis 断连更严重。CallerRunsPolicy 虽然不是最优解,但至少能让回调线程在队列满时自己执行任务,天然形成背压,倒逼生产端降速。
4.4 客户端自救三:给连接状态加上显式日志
很多时候丢消息不是一次性的,而是一个“断开-重连-再断开”的循环。如果日志里没有任何连接状态变化的信息,排查起来就像在黑屋子里找东西。建议在订阅容器上注册连接状态监听。
Spring Boot 环境下,可以通过自定义监听器感知连接建立和断开:在 RedisMessageListenerContainer 中设置连接监听,断线时输出 warn 日志,重连成功输出 info 日志。这样每次丢消息的时间点都能和连接断开事件对上。我们团队加上这个日志后,再排查类似问题的时间从以小时计缩短到分钟级。
不要嫌这步多余。真实线上环境里,一个连接断开往往转瞬即逝,回调日志里看不到任何异常,只有连接状态日志能留下证据。
5. 如果业务不允许丢消息:从 Pub/Sub 平滑迁移到 Stream
5.1 先看清楚 Pub/Sub 和 Stream 的可靠边界
Pub/Sub 适合广播、实时通知、在线状态同步等场景。但如果消息是订单事件、积分流水这类“必须处理成功”的数据,硬用 Pub/Sub 就是拿导弹打蚊子,早晚出事。Redis Stream 才是官方给出的可靠消息方案。两者的能力差别可以用一张表看明白:
| 能力 | Pub/Sub | Stream |
|---|---|---|
| 消息持久化 | 无,重启即丢 | 追加写入内存,可配合持久化落盘 |
| 消费者离线 | 收不到,不补发 | 可以从最早或指定 ID 开始消费 |
| ACK 确认 | 无 | 支持 XACK,处理完再确认 |
| 消费组 | 无,广播给所有订阅者 | 消费者组,消息在组内分摊 |
| 适用场景 | 实时通知、在线广播 | 任务队列、可靠通知、事件溯源 |
一句话:Pub/Sub 是“在线才通知,丢了无所谓”;Stream 是“一定要处理完,处理不完就重试”。选型前先问自己:这条消息丢了,业务上能不能接受?不能接受,就不要在 Pub/Sub 上折腾配置。
5.2 Stream 消费组的最简落地模型
Stream 的核心模型并不复杂。先看发布端:
创建消费组:
消费端读取:
这里的 > 表示只读取从未投递给其他消费者的新消息。处理成功后再确认:
没有 XACK 的消息会一直留在 PEL(Pending Entries List)里。下次应用重启,可以通过 XREADGROUP GROUP order-service consumer-1 STREAMS order:paid 0 读取到所有未确认的消息,实现断点续传。这正好补上了 Pub/Sub 最大的短板。
5.3 迁移到 Stream 的兼容技巧
存量业务切到 Stream,不建议一把梭。最稳的方式是“双写”:发布端同时保留旧的 PUBLISH 和新加的 XADD,消费端先接 Stream,跑一段时间,确认数据一致后再关闭 Pub/Sub。
双写阶段要注意一个问题:Stream 消费组需要先创建好,否则消息 XADD 进去后没有消费者组来读,消息就静静躺在 Stream 里。新业务上线前,先执行 XGROUP CREATE 创建组,再启动消费进程。这个顺序搞反了,会出现“消息已经在 Stream 里,但消费者组看不到历史消息”的尴尬。
5.4 为什么不建议把输出缓冲区配置调到极大值硬扛
我见过有人为了不丢消息,把 client-output-buffer-limit pubsub 调到 1GB。这种做法非常危险。Redis 是单线程模型,输出缓冲区占用的内存越大,对服务端整体性能的影响越明显。一个慢消费者积压 1GB 内存,可能直接把 Redis 进程的内存顶到系统 OOM。多个慢消费者同时出现,整个集群都可能被拖垮。
配置的存在是为了保护 Redis,不是为了让慢消费者无限堆积。调大是应急手段,不是最终方案。真正常态化方向是:消费异步化、通道拆分、或者升级为 Stream。把 Pub/Sub 当 MQ 硬扛,属于用错了工具还怪工具不好用。
6. 上线后的监控:量化丢消息,别等用户反馈
6.1 用 CLIENT LIST 实时定位慢消费者
判断某个订阅端是不是已经在积压,最简单的命令是:
输出里的 omem 字段表示该客户端输出缓冲区占用的字节数。正常情况下这个值应该很小。如果持续上涨,说明消费速度已经跟不上发布速度,离被断开也不远了。
还可以配合 INFO clients 查看全局指标:
重点看 client_recent_max_output_buffer。不过 INFO 是全局值,只能反映整体压力,定位到具体连接还是得用 CLIENT LIST。建议把这两条命令写进巡检脚本,每 30 秒采集一次,把 omem 的趋势记录下来。等到用户投诉再来查,基本就只能事后分析了。
6.2 Prometheus + redis_exporter 告警规则示例
如果已经是 Prometheus 监控体系,可以针对输出缓冲区内存配置告警。一个参考规则:
不同版本的 redis_exporter 指标命名可能有差异,配置前先执行 curl http://localhost:9121/metrics | grep client_list 确认一下。没有现成 exporter 时,用脚本定时解析 CLIENT LIST 也能达到效果。
6.3 一个低成本的自检方案:测试消息探活
除了被动监控,还可以主动探测。设计一个固定的测试频道,比如 __healthcheck__,定时往里面发布消息。消费端收到后,写一条带有时间戳的日志,或者直接更新一个 Redis key。监控脚本对比发布时间和消费时间:
- 差值为 0~几秒,说明链路正常。
- 差值持续变大,说明消费延迟在增加,输出缓冲区可能在积压。
- 消费端完全没反应,说明订阅连接已经断了,或者重连逻辑失效。
这个方案成本很低,却能覆盖一多半的“静默丢消息”问题。尤其是刚刚改完配置、上完新代码之后,留着这个探活通道,可以持续验证修复效果。
最后分享一点个人体会。我后来回看这个案例,发现问题不只在配置,也不在某一段代码,而在团队对 Pub/Sub 的预期上。Redis 官方把它定义为 fire and forget,这个定位本身就说明它会丢消息。调大 client-output-buffer-limit 只是让慢消费导致的断连问题变得不那么频繁,真正要做的,是在架构设计时想清楚“这条消息丢了能不能接受”。如果答案是不能,请直接选择 Stream 或者专业消息队列。把这个原则想明白,比记住任何一个参数都更管用。