
前阵子有个朋友在群里问我“Redis的发布订阅模式大概什么流程” 我当时回了一句你先开两个redis-cli一个订阅一个发布十秒钟就能看懂。后来想想这个问题其实挺有代表性的——很多人用过Redis做缓存、做分布式锁但对发布订阅Pub/Sub这套机制的理解往往停留在“知道有这么个功能”真到要设计一个广播通知、配置刷新或者聊天室场景时脑子里只有一个模糊的印象好像能用Redis发消息至于消息怎么投递、订阅关系存哪、为什么消息会“丢”完全说不上来。这篇文章我就把Redis发布订阅Pub/Sub的完整链路掰开揉碎讲清楚。从核心概念、命令到底层消息分发机制再到用redis-cli、Python、Spring Boot三种方式跑通一次完整流程最后把我在生产环境里踩过的坑和排查思路一并分享出来。无论你是刚接触Redis的新手还是已经在项目里用过Pub/Sub但没时间深究的老手这篇都能帮你把这块知识补扎实。1. 发布订阅不是消息队列先搞懂这套设计再动手1.1 三个角色和一条“广播总线”Redis发布订阅模型里有三个核心角色发布者Publisher、频道Channel、订阅者Subscriber。你可以把Channel想象成一个微信群的群名发布者是往群里发语音的人订阅者是蹲在群里听消息的人。发布者不需要知道群里有多少人、分别是谁它只管往群里喊一嗓子订阅者也不需要关心消息是谁发出来的只关心自己能收到。这种“发布方和订阅方完全解耦”的设计是Pub/Sub最舒服的地方。在Redis里频道名就是一个普通的字符串比如order:paid、config:update、chat:room_1。发布者用PUBLISH order:paid order_12345把消息扔到名为order:paid的频道里所有执行过SUBSCRIBE order:paid的客户端都会收到这条消息。频道不需要提前创建第一次有人发布或订阅时Redis内部就会自动记录这个频道的状态这一点和Redis的Key设计很像——不存在就创建非常随意但也因此埋了一些坑后面讲。1.2 即发即弃模型为什么Redis要这么设计很多人在刚接触Pub/Sub时会下意识地把它和消息队列比如RabbitMQ、Kafka画等号这是一个非常大的误解。Redis Pub/Sub采用的是“Fire and Forget”即发即弃模型消息一旦被PUBLISH命令发出Redis会立刻把它推给当前所有在线的订阅者推完之后这条消息的生命周期就结束了。Redis不会把消息写入RDB或AOF文件不会把它缓存在内存里等消费者来取更不会在消费者离线时保留消息等它回来再发。为什么Redis要这么设计原因是性能。Redis本身是一个单线程事件驱动的内存数据库它的优势在于极低延迟和高吞吐。如果Pub/Sub要支持消息持久化、ACK确认、消费者组这些重量级能力那它的消息投递模型就会变得异常复杂单线程的Redis根本扛不住。所以Redis选择了一条极简路线只管“转发”不管“存储”。你可以把它理解成楼下的对讲机——按下按键喊一句所有接通这个频道的对讲机同时响但对讲机不会帮你录下“对方没听到的那句话”。能力项Redis Pub/Sub消息队列RabbitMQ/Kafka消息持久化不支持重启即丢支持按策略持久化到磁盘消费者离线补发不支持离线就错过支持按offset或队列留存ACK确认机制无发完即走有消费成功后才确认消费者组/负载均衡无每个订阅者都收到全量消息有可组内分摊消息典型用途广播通知、轻量实时通信可靠任务队列、事件流处理这张表基本说明白了如果你需要“每条消息都得被可靠处理”Pub/Sub不是一个合适的选择如果你需要“一条消息同时广播给所有关心它的人丢了也没太大关系”那Pub/Sub就是最轻量、最高效的方案。2. 一条消息从发布到收到的完整路径拆解2.1 命令层SUBSCRIBE/PUBLISH到底做了什么先看最基本的五条命令这是理解整个流程的地基SUBSCRIBE channel [channel ...]订阅一个或多个频道进入订阅模式。UNSUBSCRIBE [channel ...]退订频道不带参数表示退订所有已订阅的频道。PUBLISH channel message向指定频道发布消息返回值为收到该消息的订阅者数量。PSUBSCRIBE pattern [pattern ...]按模式订阅比如PSUBSCRIBE order:*能收到所有以order:开头的频道的消息。PUNSUBSCRIBE [pattern ...]按模式退订。PUBSUB CHANNELS [pattern]查看当前活跃的频道PUBSUB NUMSUB channel查看某频道的精确订阅者数量。当你执行SUBSCRIBE order:paid时Redis会做这样几件事先把这个客户端标记为“订阅者”状态然后把order:paid这个频道名写入服务端内存中的一张订阅表里最后向客户端返回一个数组回复告诉你订阅成功。这个数组长这样1) subscribe 2) order:paid 3) (integer) 1第三个数字是“当前客户端订阅的频道总数”不是频道里的订阅者人数这点很容易混淆要注意。当你执行PUBLISH order:paid order_12345时Redis会做这样几件事第一步在订阅表中查出所有精确订阅了order:paid的客户端第二步再遍历所有模式订阅找出能匹配order:paid的模式比如order:*把匹配的客户端也加进接收名单第三步把消息组装成标准回复格式逐个写入这些客户端的输出缓冲区第四步统计实际写入的客户端数量作为PUBLISH命令的返回值返回给发布者。2.2 服务端内部频道表、订阅链表和消息分发Redis在内存中维护了一张全局的字典DictKey就是频道名Value是一个链表链表里挂着所有订阅了这个频道的客户端结构体。每次有客户端执行SUBSCRIBE就往这个链表的尾部加一个节点执行UNSUBSCRIBE就移除对应节点当某个频道的订阅链表变成空链表时Redis会把这个频道从字典里删掉相当于这个频道“消失”了下次再有人订阅时重新创建。发布消息时Redis会走一遍“查字典、遍历链表、逐客户端写缓冲区”的流程。因为Redis是单线程事件循环所以这个过程天然是原子性的不存在多个线程同时修改订阅链表的并发问题。这也是为什么Pub/Sub在Redis里可以做到极高的吞吐——所有操作都在同一个事件循环里顺序执行没有锁竞争。消息在客户端链路里以数组回复的形式传递。精确订阅收到的消息格式是1) message 2) order:paid 3) order_12345模式订阅收到的消息格式稍有不同多了一个“匹配到的模式”字段1) pmessage 2) order:* 3) order:paid 4) order_12345如果你的代码里是拿数组下标硬编码去取消息内容的这两个格式的差异就非常关键——我见过不止一次测试精确订阅没问题一改模式订阅就取错字段的情况。2.3 订阅模式下的客户端状态为什么订阅后没法执行普通命令这一点是新手最容易踩、老手容易忽略的规则。在RESP2协议下一个客户端一旦执行了SUBSCRIBE或PSUBSCRIBERedis就会把这个连接标记为订阅模式。处于订阅模式的连接只能执行SUBSCRIBE、UNSUBSCRIBE、PSUBSCRIBE、PUNSUBSCRIBE、PING、QUIT这几条命令其他命令比如GET、SET一律报错丢给你一句“only (P)SUBSCRIBE / (P)UNSUBSCRIBE / PING / QUIT allowed in this context”。这个限制的本质原因很简单订阅模式下连接会一直等待接收消息服务端和客户端之间的通信变成了“以消息推送为主导”的模式这个连接不能既当普通命令连接又当订阅连接。这也是为什么在redis-cli里执行SUBSCRIBE之后终端就“卡住”了——它不是卡住了是进入了监听状态在等Redis给你推送消息。这个设计对客户端SDK影响很大比如Java的Jedis里同一个连接不能既执行订阅又执行普通读写必须单独为订阅开一条连接。后面讲Spring Boot接入时会再提到。3. 从零跑通一次发布订阅命令行与代码实操3.1 redis-cli五步验证全流程如果你还没装Redis可以先去官网下载安装包Windows直接下载解压版就能跑Linux用apt install redis-server或者源码编译都行这部分不过多展开。装好之后启动一个Redis实例然后开两个终端窗口跟着做一遍整个流程就通了。终端A执行订阅redis-cli 127.0.0.1:6379 SUBSCRIBE test_channel Reading messages... (press Ctrl-C to quit) 1) subscribe 2) test_channel 3) (integer) 1看到这个输出说明订阅已经建立。终端B执行发布redis-cli 127.0.0.1:6379 PUBLISH test_channel hello world (integer) 1返回的1表示有1个客户端收到了消息。切回终端A你会看到1) message 2) test_channel 3) hello world这就完完整整走完了一次发布订阅流程。多开几个终端订阅同一个频道再发布一次你会发现PUBLISH返回的数字会变成订阅者数量所有订阅终端都能同时收到同样的消息——广播特性已经演示出来了。再试一个模式订阅。终端A先CtrlC断开执行127.0.0.1:6379 PSUBSCRIBE order:* Reading messages... (press Ctrl-C to quit) 1) psubscribe 2) order:* 3) (integer) 1终端B执行127.0.0.1:6379 PUBLISH order:paid order_10086 (integer) 1终端A收到1) pmessage 2) order:* 3) order:paid 4) order_10086推荐你把这几个步骤亲手敲一遍几分钟时间但对理解Pub/Sub的直观感受远超看十篇文章。3.2 Python最小Demo用redis-py把频道跑起来命令行验证完就该上代码了。Python里用redis-py最方便。先安装依赖pip install redis订阅端代码import redis r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) pubsub r.pubsub() pubsub.subscribe(test_channel) print(waiting for messages...) for message in pubsub.listen(): print(message) if message[type] message: print(freceived: {message[data]})发布端代码import redis r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) r.publish(test_channel, hello from python)订阅端运行后再运行发布端订阅端会打印出消息。这里有几个细节要提醒decode_responsesTrue很关键。Redis返回的是字节串不设置这个参数你收到的message[data]是bhello from python这样带b前缀的字节处理起来很别扭。pubsub.listen()是一个阻塞迭代器会持续监听。但注意listen()返回的第一条消息通常是订阅成功的确认消息type为subscribe业务消息要过滤掉所以上面代码里做了type判断。推荐用get_message()配合time.sleep做定时轮询或者用listen()阻塞监听。如果是Web服务里用建议把订阅逻辑放到单独的线程里避免阻塞事件循环。3.3 Spring Boot接入RedisMessageListenerContainer与RedisTemplateJava生态里Spring Boot对Redis Pub/Sub做了很完善的封装核心是RedisMessageListenerContainer。它负责启动监听线程、维护订阅关系、断线重连你在代码里只需要注册一个MessageListener和对应的Topic。配置类示例Configuration public class RedisPubSubConfig { Bean public RedisMessageListenerContainer redisMessageListenerContainer( RedisConnectionFactory connectionFactory) { RedisMessageListenerContainer container new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); return container; } Bean public MessageListenerAdapter messageListenerAdapter() { return new MessageListenerAdapter(new OrderPaidListener()); } PostConstruct public void registerListener( RedisMessageListenerContainer container, MessageListenerAdapter adapter) { container.addMessageListener(adapter, new ChannelTopic(order:paid)); } }监听器代码public class OrderPaidListener implements MessageListener { Override public void onMessage(Message message, byte[] pattern) { String channel new String(message.getChannel()); String body new String(message.getBody()); System.out.println(channel: channel , message: body); } }发布消息时用RedisTemplate的convertAndSendService public class OrderService { Autowired private RedisTemplateString, String redisTemplate; public void payOrder(String orderId) { // business logic... redisTemplate.convertAndSend(order:paid, orderId); } }这里有一个高频坑RedisTemplate默认的序列化器是JdkSerializationRedisSerializer如果你直接把一个字符串对象convertAndSend出去接收端new String(message.getBody())极大概率会得到一堆乱码。原因是发送方把字符串用JDK序列化成了二进制字节接收方却按UTF-8直接转了字符串。解决办法两种要么把RedisTemplate的序列化器改成StringRedisSerializer要么直接用StringRedisTemplate它就是RedisTemplate以String序列化器的封装版本。我个人的习惯是在Spring Boot项目里直接用StringRedisTemplate来收发消息最省心。4. 必须知道的边界发布订阅适合做什么、不适合做什么4.1 典型应用场景盘点根据我自己的实践经验Redis Pub/Sub最适合这几类场景配置中心广播配置变更后所有服务实例收到通知拉取最新配置。这类消息偶尔丢一条影响也不大下次配置变更再拉一次就行。缓存失效广播在分布式部署下某个节点更新了数据可以用Pub/Sub通知其他节点主动清理本地缓存。Redis缓存治理里经常会用到这个思路。在线状态通知用户上线/下线通过频道广播给相关客户端。轻量聊天室不需要历史消息、不需要离线补发的临时聊天室Redis Pub/Sub能很轻松撑起来。实时指标广播服务端周期性计算指标广播给所有可视化看板客户端。这些场景的共同特征是消息价值是“瞬时”的错过就算了不会造成严重后果需要广播给所有在线节点对消息可靠性要求不高。4.2 发布订阅、Redis Streams、消息队列怎么选Redis 5.0之后引入了Streams它是一种“可持久化的Pub/Sub升级版”支持消息持久化、消费者组、ACK确认。如果说Pub/Sub是对讲机Streams就是带录音功能的群聊消息存在Redis里消费者离线也能补拉。我自己的选型建议是只需要广播、丢消息无所谓的用Pub/Sub简单直接。需要持久化、需要消费者组、需要ack的优先用Redis Streams。它在Redis内部的生态集成度很高不用额外引入消息队列组件。消息量巨大、需要复杂路由、严格不丢、跨团队解耦的直接用RabbitMQ或Kafka别硬用Redis扛。现实中有个很常见的设计错误拿Redis Pub/Sub当任务队列发布一个订单消息然后期望消费者无论如何都能处理。一旦消费者进程重启消息就永久丢失订单就“静默消失”了。这种场景必须用Streams或真正的消息队列。4.3 缓存治理、分布式锁与发布订阅的关系热词里反复出现“redis缓存治理”、“redis分布式锁”这里顺便把它们和Pub/Sub的关系理清。Redis作为缓存使用时Pub/Sub通常扮演“通知”角色——比如缓存更新后同一个Redis集群里的多个应用实例通过订阅频道知道“某个key的缓存失效了”从而主动清掉本地的二级缓存这是缓存治理里很常见的联动手段。而分布式锁本身用到的核心命令是SETNX、过期时间、Lua脚本和Pub/Sub没有直接关系。但分布式锁的“等待锁释放通知”可以和Pub/Sub结合——锁释放时发一条广播等待中的客户端收到通知再重新抢锁比死等轮询高效不少。需要提醒的是Redis分布式锁本身是一个复杂的工程问题Pub/Sub只是锦上添花的通知机制不要把它当成分布式锁的组成部分来理解。5. 生产环境踩坑实录与排查方法5.1 消息“丢”了先检查订阅时序最经典的坑客户端先从代码里执行SUBSCRIBE紧接着发布消息但订阅端就是收不到。排查半天最后发现是时序问题——PUBLISH命令发出时对端的SUBSCRIBE命令还没有真正执行到Redis服务端。我举个例子在Spring Boot里启动一个监听器容器初始化需要时间同一时刻另一个服务已经发了一条消息过来。容器还没来得及注册订阅消息就被Redis“转发”给了空气。所以设计系统时一定要明确Pub/Sub只能接收“订阅建立之后”的消息。如果业务上确实要确保不丢只能加消息持久化方案比如Streams或者在发布端做补偿查询。5.2 断线重连后订阅关系丢失这个问题在长连接场景里非常常见。Redis服务器有超时机制或者网络抖动导致TCP连接断开客户端SDK会自动重连但重连之后Redis不会主动帮你恢复之前的订阅关系。不信你可以试试订阅之后手动在服务端CLIENT KILL TYPE pubsub杀掉所有订阅连接客户端那边看着是重连成功了但你再发布消息它什么都收不到。解决方案是在客户端SDK的重连回调里把之前订阅过的频道重新注册一遍。Spring Boot的RedisMessageListenerContainer内部有重连和重订阅的逻辑所以用封装好的框架通常不用太担心但如果你是自己管理连接的裸客户端就必须处理这个重订阅逻辑。5.3 慢消费者会把Redis“推”崩这是我特别想提醒的一个点。Pub/Sub模式下Redis把消息写入消费者连接的输出缓冲区后就不管了如果消费者处理太慢Redis向它写入的速度远大于它读走的速度消息会在输出缓冲区里积压。Redis默认对pubsub类型的连接有输出缓冲限制一旦积压超过阈值Redis会直接断开这个客户端。红框配置在redis.conf里长这样client-output-buffer-limit pubsub 32mb 8mb 60意思是pubsub类连接硬限制32MB软限制8MB持续超过软限制60秒就强制断开。这个参数很关键——它保护的是Redis自己防止个别慢消费者拖垮整个实例。遇到过掉进这个坑的案例一个聊天室场景消费者端做敏感词过滤某次算法卡住了消费者线程阻塞了十几秒大量消息积压在连接缓冲区最后客户端被Redis强制断开消息全断。排查时看Redis日志会发现类似Client closed connection或者output buffer limit reached的提示。解决思路第一保证消费者处理速度第二调大pubsub缓冲区上限治标第三评估这个场景是否真的适合用Pub/Sub——处理逻辑慢的话不如用Streams配合消费者组可控性强得多。5.4 哨兵、集群模式下使用发布订阅要注意什么在Redis Sentinel哨兵模式下Pub/Sub是直接可用的因为哨兵模式本质上还是一个主从架构客户端通过哨兵获取主节点地址订阅连接连上主节点就行。但你们要注意一个点如果发生故障转移原来的主节点变成从节点新的主节点上没有任何订阅关系客户端必须重连到新主节点并重新订阅才会继续收到消息。这个过程如果有客户端没有正确重连消息就会静默丢失。在Redis Cluster集群模式下Pub/Sub的行为和其他命令不太一样——它不是按照槽位分发而是每个节点都会把PUBLISH消息广播到集群里的其他节点。也就是说任意节点上的发布者发布消息集群里所有订阅了对应频道的客户端都能收到。这个机制在跨节点场景下是好用的但同时也意味着一个节点上的慢消费者或大流量频道可能波及整个集群的网络带宽。所以在集群里用Pub/Sub更要谨慎评估频道的消息量和消费者数量。5.5 用可视化工具观察频道与消息热词里反复出现“redis可视化客户端”、“redis desktop manager”、“another redis desktop manager”说明大家日常还是习惯用图形化工具管理Redis。这些工具对Pub/Sub的支持主要是“控制台模式”你可以在命令行面板里执行PUBSUB CHANNELS查看当前有哪些活跃频道用PUBSUB NUMSUB channel查看某个频道的订阅者数量也可以在控制台里执行订阅命令——但要注意图形化客户端里的订阅命令同样会阻塞当前控制台所以要观察消息通常是开一个独立的SUBSCRIBE窗口再从另一个窗口发布。个人建议日常开发用可视化工具看Key和缓存没问题但排查Pub/Sub问题还是老老实实用redis-cli。可视化工具经常会在无意之中建立或占用订阅连接干扰你对“订阅者数量”的判断。比如你开着Redis Desktop Manager的订阅监听窗口忘了关PUBLISH返回值里就会多算一个订阅者排查问题时容易被带偏。6. 最后聊几点实在的体会踩过几次坑之后我对Redis发布订阅的态度是它是一把非常好用但也非常“脆”的刀。好用在于链路极短、性能极好、代码极少十分钟就能把一套广播通知机制跑起来脆在于它没有持久化、没有ACK、没有重试任何一环掉线都可能导致消息静默消失。所以在实际项目里我给自己定了几条原则凡是要广播给所有在线实例的“通知类”消息优先用Pub/Sub但消息本身要设计成“幂等且可补偿”——丢了最多晚一点靠后续轮询或下次变更补上凡是“任务类”消息一条都不能丢的绝不碰Pub/Sub直接上Streams或者独立消息队列。另外发布订阅模式用得好不好很大程度上取决于你对Redis客户端连接模型的理解。多花点时间在订阅连接的生命周期管理上比盯着命令本身更有价值。希望这篇流程拆解能帮大家把这块“似懂非懂”的知识彻底焊死。