ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis订阅丢消息排查:从输出缓冲区到Streams迁移

Redis订阅丢消息排查:从输出缓冲区到Streams迁移 前几天朋友找我排查一个看起来很诡异的问题他们用 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 pubsub2.1 这个配置是干什么的Redis 为每个客户端连接维护了一个输出缓冲区用来暂存还没有发送给客户端的数据。普通命令连接通常不会出问题但 Pub/Sub 连接比较特殊只要发布者不停发Redis 就会往订阅者的输出缓冲区里写数据。如果订阅者消费速度跟不上缓冲区就会一直涨。为了防止某个慢客户端把 Redis 内存吃光Redis 提供了一组保护参数叫client-output-buffer-limit。默认配置里pubsub 相关的限制是client-output-buffer-limit pubsub 32mb 8mb 60这行配置表示三个值32mb是硬限制。如果某个订阅客户端的输出缓冲区超过 32MBRedis 会立即断开这个客户端。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 或者 300Redis 就会认为这是一个空闲连接然后把它关掉。连接之后客户端如果没有重连重订阅那下一次消息就收不到了。这种丢消息特别难查因为它和高峰期无关纯粹是时间到了就断。排查方法CONFIG GET timeout如果返回值不是0而且你有 Pub/Sub 长连接我建议直接把timeout改成0或者确保客户端库有断线重连和自动恢复订阅的机制。3.2 客户端 readTimeout 让订阅线程“假死”服务端配置只是其中一半另一半坑在客户端。很多语言库的 Pub/Sub 订阅是一个阻塞操作比如 Java 的 Jedissubscribe方法会一直阻塞等待消息。Jedis 在创建连接时可以设置soTimeout如果这个值不是 0socket 在读数据时超过指定时间没有数据就会抛出SocketTimeoutException。问题来了Pub/Sub 连接在空闲时本来就没有数据。如果你给订阅连接设置了 5 秒的读超时那 5 秒内没有消息线程就会异常退出。等发布者的消息到达时订阅线程已经不在了消息自然丢失。我自己踩过这个坑。之前写一个 Java 订阅服务图省事复用了普通连接的JedisPool配置里面设了timeout3000结果上线后每隔几分钟就丢一批消息。后来才反应过来普通命令请求是“发一条等一条”所以超时合理但订阅是“默默挂在那边等推送”根本不适合套用短超时。正确做法是订阅专用的连接把读超时设为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-events4.1 keyspace 通知与普通 Pub/Sub 的区别另一种常见的“订阅丢消息”其实不是用publish/subscribe订阅业务频道而是订阅 Redis 的键空间通知。比如你想在某个 key 过期时收到通知于是订阅__keyevent0__:expired这个频道。这种功能底层虽然也走 Pub/Sub但它有一个额外的开关notify-keyspace-events。这个配置默认是空字符串也就是所有键空间通知都关闭。如果你没有开启就算客户端订阅了__keyevent0__:expiredRedis 也根本不会向这个频道发布任何消息。所以很多人会疑惑我明明subscribe成功了为什么一条消息都收不到原因就是忘了开notify-keyspace-events。4.2 以“订阅 key 过期”为例的配置方法Redis 的notify-keyspace-events使用一串字符来开启特定类型的事件。常见的字符含义Kkeyspace 事件会收到__keyspacedb__:key频道。Ekeyevent 事件会收到__keyeventdb__: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开启之后客户端订阅__keyevent0__:expired才能收到过期消息。4.3 开了还是收不到多半踩了这些坑第一db 序号要对。如果你操作的是 Redis 的 db 0订阅频道就是__keyevent0__:expired。如果你把 key 写到了 db 1那应该订阅__keyevent1__: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根据发布速率调整默认值偏保守缓冲区超限会断开订阅连接timeout0非 0 时会回收空闲订阅连接tcp-keepalive60 或更小不设置可能被网络设备断开假死连接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 flagsP|sub输出里flagsP表示这个连接处于 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这几个配置拉出来看一眼。大多数所谓“诡异丢消息”最后都藏在配置里。但配置只是止血真正想一劳永逸还是要回到业务对可靠性的要求上选对消息方案。
返回列表