很多人学 RabbitMQ 学到 Publish/Subscribe 这一节时,会突然觉得之前的理解“失灵”了。前面几节不管怎么做,队列总有个名字,生产者和消费者围着一个明确的队列转;可到了发布订阅模式,官方教程却告诉你说,队列名由消费者自己生成,最好别指定。我当时看到这句话是有点懵的:消息队列不给队列起名字,消息到底发给谁?后来把整套机制跑通、又在线上的广播场景里踩过几次坑,才算真正理解了这一节在说什么。这篇就围绕 RabbitMQ 的发布订阅模式,把我从“看懂示例代码”到“敢在项目里用 fanout 交换机”这段过程里的理解、实操和教训整理出来,适合刚学完基础队列、准备进入交换机阶段的人,也适合想快速搞清这个模式到底适用哪些场景的开发者。
1. 为什么发布订阅让很多人卡在门口:先分清四种消息模型
1.1 从“队列为中心”到“交换机为中心”的思维转换
RabbitMQ 官方教程一共六节,很多人学到第四节 Publish/Subscribe 就开始犯迷糊,原因很简单:前三节全是围绕“队列”这个核心概念展开的。hello 队列、task_queue 队列,生产者把消息塞进具名队列,消费者从同一个队列里取,整个模型非常直观,像两个人之间拉了一根管子。
到了发布订阅模式,视角突然换了:队列不再是“唯一主角”,交换机(Exchange)才是真正的路由中枢。生产者不再直接把消息发给队列,而是发给一个交换机,交换机再根据绑定关系把消息复制到多个队列里。这个转换对新手来说是最难跳跃的一步——很多人代码照着敲通了,但心里对“消息先到交换机,再到队列,最后到消费者”这条链路没有建立起画面感,后面一遇到问题就无法排查。
我当时给自己理清这条链路,用的不是“交换机”这个词,而是一个更直白的叫法:“中转站”。生产者把消息交给中转站,中转站复制多少份、每个队列收不收,由中转站和队列之间的绑定关系决定。
1.2 一句话讲清楚发布订阅:电台广播与收音机
如果让我用一句话给同事讲清楚发布订阅模式,我会说:生产者根本不知道消息最终发给了谁,它只负责往广播电台投稿;所有订阅了这个电台的收音机都能收到同一份内容,谁中途关掉收音机,谁就错过这条消息。
这个类比里,“电台”是 fanout 类型的交换机,“收音机”是每个消费者的临时队列,“调频”的动作是队列和交换机之间的绑定(binding)。理解了这个类比,很多细节就能推导出来:
- 电台不需要知道有多少收音机在听,所以生产者不需要关心消费者的存在。
- 收音机必须自己调频,消费者必须自己创建队列并绑定交换机。
- 收音机关机之后不会再收到广播,临时队列随着消费者连接断开而自动删除。
这也是发布订阅模式最核心的价值:生产者和消费者之间彻底解耦。生产者不持有消费者的任何信息,新增一个订阅方不用改动生产者一行代码。
1.3 四种模式一表对照
为了让“发布订阅到底处于什么位置”更清晰,我把 RabbitMQ 常用的四种消息模型放在一起对照。这张表在任何面试前都值得自己默写一遍:
| 模式 | 核心组件 | 消息流向 | 典型用途 |
|---|---|---|---|
| 简单队列 | 一个具名队列 | 生产者到队列到单一消费者 | 入门示例、简单任务 |
| 工作队列 | 一个具名队列 + 多个消费者 | 消息在队列中竞争分发,一条只给一个消费者 | 耗时任务分摊 |
| 发布订阅 | fanout 交换机 + 多个临时队列 | 消息复制到每个绑定队列,每个消费者都收到 | 广播通知、状态同步 |
| 路由/主题 | direct/topic 交换机 + 绑定键 | 根据路由键匹配,选择性投递 | 分类日志、按规则分发 |
工作队列和发布订阅是最容易混的。工作队列是“一条消息只被处理一次”,多个消费者抢同一个队列里的任务;发布订阅是“一条消息被广播给多个订阅方”,各收各的,互不干扰。应用在代码上,两者往往只差几个参数,但语义完全不同。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三个关键组件:交换机、绑定、临时队列
2.1 交换机:消息的第一站
RabbitMQ 的交换机一共有四种类型:direct、fanout、topic、headers。发布订阅模式用的是 fanout,中文常翻译成“扇出”或“广播”。fanout 的工作方式非常粗暴:不管消息带什么路由键,只要消息进了这个交换机,它就会把消息复制发给所有与它绑定的队列,一个都不落。
在代码里声明一个 fanout 交换机很简单:
python复制channel.exchange_declare(exchange='logs', exchange_type='fanout')
注意这里和之前“默认交换机”的区别。之前我们写 basic_publish 时没指定交换机的名字,实际上用的是 RabbitMQ 内建的空字符串交换机,它会根据队列名直接把消息路由到指定队列。发布订阅模式里,我们显式声明了名为 logs 的 fanout 交换机,消息的流转就变了:生产者只面向交换机,不再面向任何具体队列。
我在项目里给团队讲这段的时候常说:fanout 交换机就是一个“不管你是谁、只要你绑定了我,我都给你发”的组件。它不做过滤、不做匹配、不谈条件。
2.2 绑定:告诉交换机消息往哪送
光有交换机和队列还不够,两者之间必须建立绑定关系。交换机只负责“广播”,但到底广播给哪些队列,需要队列主动来“登记”。
python复制channel.queue_bind(exchange='logs', queue=queue_name)
绑定之后,这个队列就进入了 fanout 交换机的广播名单。这里有一个关键细节:fanout 交换机会忽略路由键。在 direct 或 topic 模式下,路由键是消息投递的判据;但在 fanout 模式下,你给 queue_bind 和 basic_publish 传什么 routing_key 都不影响结果,消息照样发给所有绑定队列。
这个“路由键失效”的特点,很多新手会忽略。写代码时下意识复制了 direct 模式的写法,给 fanout 也传了一个 routing_key,然后看着两个队列都收到消息,愣是想不通为什么。理解 fanout 的本质后,这类问题就不会再困惑。
2.3 临时队列:订阅者的专属信箱
发布订阅模式里最“反直觉”的就是队列的创建方式。前面几节我们一直在说“队列要有名字”,可发布订阅里,官方推荐的是让 RabbitMQ 随机生成一个队列名,并且声明为排他队列:
python复制result = channel.queue_declare(queue='', exclusive=True)
queue_name = result.method.queue
这里两个参数的含义要拆开讲。
queue='' 表示不指定队列名,RabbitMQ 会自动生成一个类似 amq.gen-M1kDP3tV0H4pVh7L8w2hYQ 的随机名称。exclusive=True 表示这是一个排他队列:它只对当前这个连接可见,当连接关闭,队列被自动删除。
这个设计不是为了省事,它背后有明确的语义:发布订阅模式里,每个消费者都有自己独立的一份消息,消费者下线后,它的积压数据没有保留价值。如果消费者断线了,它错过了广播就错过了,不需要等它回来再补发。临时队列恰好实现了这个语义——连接一断,队列和绑定信息一起消失,不会留下垃圾数据。
2.4 管理界面里读懂绑定关系
如果你用过 RabbitMQ 的管理界面,会发现自带的 Web 控制台(默认端口 15672)很直观。在 “Exchanges” 标签页里找到 logs 这个 fanout 交换机,点进去之后可以看到 “Bindings” 区域,里面列出了所有绑定了这个交换机的队列。
我第一次在界面上看到多个队列同时绑定在同一个交换机下时,整个发布订阅模式突然就通了:交换机在中间,队列在两侧,一条消息进入交换机后,原本一路的消息变成了多份,分叉点就在这里。
建议跑示例代码的时候开着管理界面观察。启动两个订阅端,看交换机下面是不是绑定了两个 amq.gen-* 队列;关闭一个订阅端,看对应的队列是不是自动从 Bindings 里消失了。这个过程比读十遍文档都有用。
3. 用 Python 跑通发布订阅:完整实现与逐行拆解
3.1 环境准备:安装与启动
RabbitMQ 本身是用 Erlang 写的,装起来最省心的方式是用 Docker。一条命令就能带上管理插件启动:
bash复制docker run -d --name rabbitmq \
-p 5672:5672 -p 15672:15672 \
rabbitmq:3-management
其中 5672 是 AMQP 协议端口,15672 是 Web 管理界面端口。本机没有 Docker 的话,Mac 上可以用 brew install rabbitmq,Ubuntu 上可以用 apt install rabbitmq-server。本地源码安装或者包管理器安装之后,如果访问不了管理界面,手动开一下插件:
bash复制rabbitmq-plugins enable rabbitmq_management
服务起来之后,浏览器访问 http://localhost:15672,默认账号密码是 guest/guest。注意这个默认账号只允许从本机访问,远程登录会直接失败,这也是个常见坑。
Python 侧只需要一个 pika 客户端库:
bash复制pip install pika
pika 1.x 是当前主流版本,下面的示例代码都是基于 pika 1.x 的 API 写的。
3.2 先写订阅端:创建临时队列并绑定
发布订阅的正确启动顺序是“先有订阅者,后有发布者”,所以我们先写订阅端。下面是一个完整的 Python 订阅者脚本:
python复制import pika
connection = pika.BlockingConnection(
pika.ConnectionParameters(host='localhost')
)
channel = connection.channel()
# 1. 声明 fanout 交换机
channel.exchange_declare(exchange='logs', exchange_type='fanout')
# 2. 创建随机命名、排他的临时队列
result = channel.queue_declare(queue='', exclusive=True)
queue_name = result.method.queue
# 3. 把临时队列绑定到交换机
channel.queue_bind(exchange='logs', queue=queue_name)
print(f' [*] 等待消息,队列:{queue_name}')
def callback(ch, method, properties, body):
print(f' [x] 收到消息:{body.decode()}')
channel.basic_consume(queue=queue_name, on_message_callback=callback, auto_ack=True)
channel.start_consuming()
这段代码每一步都在落实第 2 章讲的概念。第 1 步声明交换机,第 2 步创建临时队列,第 3 步建立绑定关系。三步做完,订阅者才真正进入了“广播名单”。
有个容易踩的坑是 callback 函数的参数个数。pika 1.x 要求回调函数必须接收四个参数:channel、method、properties、body。如果你按网上老教程只写了两个参数,运行时会直接报 TypeError,提示参数数量不匹配。
3.3 再写发布端:只往交换机发
发布端比订阅端简单得多,因为生产者完全不接触队列:
python复制import pika
import sys
connection = pika.BlockingConnection(
pika.ConnectionParameters(host='localhost')
)
channel = connection.channel()
# 声明同一个交换机
channel.exchange_declare(exchange='logs', exchange_type='fanout')
message = ' '.join(sys.argv[1:]) or 'hello world'
# 发布时路由键填空字符串,fanout 交换机会忽略它
channel.basic_publish(exchange='logs', routing_key='', body=message)
print(f' [x] 发送消息:{message}')
connection.close()
发布端的关键点在于 basic_publish 里指定的是交换机名,而不是队列名。如果你在这里写 routing_key='logs' 或者填其他值,对 fanout 交换机来说没有任何区别,它不会按路由键做任何筛选。
为了确保发布端声明的交换机和订阅端是同一个,发布端要再执行一次 exchange_declare。这个操作是幂等的,如果交换机已存在,声明会直接成功返回,不会重复创建。
3.4 跑起来看效果:两个订阅端都收到同一份消息
实操验证很有必要。开两个终端,分别运行两个订阅端脚本,然后再开一个终端运行发布端脚本,比如发一条 “hello broadcast”。
观察输出,两个订阅端的控制台都会打印这条消息。这个现象直接说明了 fanout 的行为:消息不是被争抢,而是被复制。再对比一下工作队列:如果用默认交换机做同样的事情,两个消费者只会交替收到消息,每一条消息只被消费一次。
这个对比我建议每个人都亲手跑一遍。同一个 RabbitMQ,仅仅换了一下交换机类型和队列创建方式,消息的分发语义就完全改变。把这种“差异感”记住,比背任何文档都管用。
3.5 发布订阅与工作队列在代码上的一字之差
很多人问我,发布订阅和工作队列能靠“改一行代码”互相转换吗?可以,但要改的地方不止一处:
| 改动点 | 工作队列 | 发布订阅 |
|---|---|---|
| 交换机类型 | 不指定,用默认交换机 | fanout |
| 队列声明 | 指定固定名,如 task_queue |
不指定名,随机生成 |
| 队列持久化 | 通常 durable=True |
通常 exclusive=True |
| 生产者的 routing_key | 填队列名 | 填空字符串 |
| 消息语义 | 一条消息给一个消费者 | 一条消息给所有消费者 |
这几行差异的背后就是两种完全不同的设计思想。工作队列看重的是“任务分担”,发布订阅看重的是“事件广播”。代码差异可以靠背,但语义差异一定要理解透,否则场景一变就容易用错模式。
4. 生产环境绕不开的三个问题:消息去向、确认机制、连接管理
4.1 fanout 交换机不存消息,消息到底去哪了
发布订阅模式里,fanout 交换机本身不具备存储能力,这是一个极其重要、也极其容易被忽视的事实。RabbitMQ 的消息存储发生在队列层面,而不是交换机层面。消息进入交换机后,如果至少有一个绑定队列在线,消息会被复制进这些队列,等待消费者消费;如果没有任何绑定队列,消息直接丢失。
我见过不止一次线上事故是这个原因引起的:先启动了发布任务的定时器,再滚动重启消费者服务。消费者重启需要几十秒,在这段空窗期里生产者照常发布了广播消息,结果一条都没留下,重启完的消费者什么也没收到。这个问题的根源就是“生产者以为消息发出去了就安全了,但发布订阅模式根本不提供这种保障”。
所以项目里使用 fanout 做广播时,必须有明确的运维约定:先保证订阅方在线,再允许生产方发布。或者退一步,如果广播内容不容丢失,就需要给临时队列增加持久化能力,或者改用其他消息模型。这里有一个 trade-off:纯发布订阅强调的是实时性和解耦,它默认订阅方错过就错过;如果业务上不能接受错过,就不要用纯 fanout。
4.2 自动确认与手动确认:什么时候会丢消息
示例代码里我用了 auto_ack=True,这是为了演示简洁。生产环境如果直接照抄,就会踩到“消息消失”的坑。
auto_ack=True 的含义是:RabbitMQ 把消息推给消费者的一瞬间,就认为消息已经处理成功,立刻从队列里删除。如果消费者在回调函数里处理消息时抛了异常,或者处理到一半进程崩溃,这条消息已经没机会被重新投递了。
更稳妥的做法是显式关闭自动确认,在处理成功后再发确认信号:
python复制def callback(ch, method, properties, body):
try:
print(f' [x] 处理消息:{body.decode()}')
# 实际处理逻辑
ch.basic_ack(delivery_tag=method.delivery_tag)
except Exception:
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
channel.basic_consume(
queue=queue_name,
on_message_callback=callback,
auto_ack=False
)
这里还有个细节:basic_nack(requeue=True) 会把消息退回队列重新投递,但如果消费者本身有问题,会造成无限循环消费同一条消息。更常见的做法是在确认失败后把消息记录到错误日志或专门的失败队列,方便人工排查。
关于“重复消费”,这里顺便提一句。即使开了手动确认,消费者在处理完消息之后、发送 ack 之前崩溃了,RabbitMQ 会因为没收到 ack 而把消息重新投递给其他消费者,这就可能导致同一条消息被处理了两遍。所以消费端的业务逻辑最好设计成幂等的,至少要对关键操作做幂等保护。
4.3 连接生命周期:持续订阅真的这么简单吗
本地跑 demo 的时候,start_consuming() 一调用,程序就老老实实挂在那里收消息,看起来一切正常。但线上环境里“长期运行”的消费者服务要考虑的问题就多了。
第一个是心跳和断线重连。RabbitMQ 默认对连接有心跳检测,默认心跳时间通常是 60 秒。如果消费者进程被阻塞了很长时间(比如回调里有一个超时的外部接口调用),没有及时收发心跳包,服务端就会把连接断开。pika 的 BlockingConnection 在回调执行期间其实会自动处理心跳,但如果你在回调里做了同步阻塞操作,还是可能触发连接异常。
我的默认做法是在创建连接时就显式设置心跳和超时参数:
python复制parameters = pika.ConnectionParameters(
host='localhost',
heartbeat=600,
blocked_connection_timeout=300
)
connection = pika.BlockingConnection(parameters)
第二个是重连机制。生产环境里 RabbitMQ 服务端重启、网络抖动都可能导致连接断开。一个健壮的消费者服务应该捕获连接异常,并实现按指数退避策略的重连逻辑。pika 的 BlockingConnection 进程如果不去处理连接关闭事件,程序可能直接崩溃退出,所以线上代码里要套一层循环重连,而不是裸奔调用 start_consuming()。
4.4 从管理界面排查异常
RabbitMQ 自带的管理界面在排查问题时非常好用。比如你怀疑广播消息没收到,按下面的顺序一个个查:
- “Queues” 页面里看当前有哪些临时队列,每个队列的 Ready、Unacked、Total 列分别代表待消费、未确认、总消息数。
- 检查交换机页面里 fanout 交换机的 Bindings 数量,确认消费者服务确实成功绑定了队列。
- 看 Connections 和 Channels 页面,确认消费者进程的连接还活着。
- 如果 Total 一直增长而 Ready 不变,说明消息到了队列但消费者没有消费,可能消费者线程死掉了。
- 如果 Unacked 居高不下,说明消费者拉走了消息但一直没确认,大概率是业务处理卡住或者 ack 逻辑有 bug。
这套排查流程我建议直接在演练环境里跑一遍,把“正常状态”和“异常状态”的样子都看一遍。下次线上出问题的时候,你扫一眼界面就能定位问题方向,不用抓瞎。
5. 场景判断:什么时候上发布订阅,什么时候别硬上
5.1 典型的广播式应用场景
发布订阅模式适合“一条消息需要同时触达多个独立子系统”的场景。我从实际项目里挑几个典型的例子:
配置中心推送。配置变更事件发给 fanout 交换机,所有业务服务各自维护一个临时队列绑定上来,收到事件后刷新本地缓存。这里每个服务都需要知道配置变了,消息天然就是“一对多”的。
缓存同步。比如商品信息变更后,需要同时通知本地缓存服务、搜索索引服务、推荐系统刷新数据。用 fanout 一把梭,新增一个下游消费者时,上游一行代码都不用改。
WebSocket 消息推送。用户上线、下线、订单状态变化这些事件广播出去,每个推送服务实例都能收到,再由它推送给对应的长连接客户端。这利用了发布订阅“消费者各自独立”的特性——多个推送实例互不干扰,各推各的连接。
日志分发。一套日志系统同时对接实时监控、离线分析和审计存储三个下游,每个下游用独立的临时队列消费同一份原始日志,互不影响消费进度。
这些场景的共性是:广播事件的“接收方数量”是动态变化的,而且接收方之间没有竞争关系,都必须拿到完整消息。
5.2 需要选择性接收时换用直连或主题
fanout 交换机太好用了,反而容易让人产生路径依赖。但要注意,fanout 是“无差别广播”,它不会根据消息内容做任何路由。如果业务需要“只看 Error 级别的日志”或者“只关心订单相关的消息”,再用 fanout 就得把所有消息都发给所有队列,然后让消费者自己过滤,这既浪费网络带宽,也让消费逻辑变得冗长。
这时候应该改用 direct 或 topic 交换机。direct 交换机根据路由键精确匹配,比如生产者发消息时带 routing_key='error',只有绑定了 error 键的队列才能收到;topic 交换机则支持 *.error、order.# 这类通配符规则,更适合按主题做模糊匹配。
一个简单的判断标准:如果所有订阅者接收的内容完全一样,用 fanout;如果订阅者各自只关心某一类消息,用 direct 或 topic。
5.3 消息模型选型速查表
把上面说的场景总结成一张速查表,选型的时候可以直接对号入座:
| 业务诉求 | 推荐模式 | 理由 |
|---|---|---|
| 一个任务只让一个消费者处理 | 工作队列 | 天然竞争消费 |
| 所有订阅方都必须收到同一条消息 | 发布订阅(fanout) | 一对多广播 |
| 按消息级别或类型精确接收 | 路由(direct) | 路由键匹配 |
| 支持通配符规则的主题订阅 | 主题(topic) | 灵活的模式匹配 |
| 需要持久化且消费者可能短暂离线 | 工作队列 + durable queue | 队列存储消息 |
| 广播且订阅方可容忍丢失实时事件 | 发布订阅(fanout) | 队列随连接销毁 |
这个表不是死规矩,但它能帮助避免把 fanout 用在不该用的地方。我在代码 review 里看到过不少“把工作队列改成发布订阅来提升吞吐”的错误用法,改成 fanout 之后吞吐没提上去,反而因为消息复制给了所有消费者,下游被重复消费搞出了数据问题。
6. 面试官的连环追问,其实都在问同一个本质
6.1 临时队列没有名字,消费者断开会发生什么
这是我面试候选人时必问的问题之一。发布订阅模式下创建临时队列时使用 queue='' 和 exclusive=True,面试官最想听到的完整答案是:
- 队列名由 RabbitMQ 随机生成,形如
amq.gen-xxx; exclusive表示排他队列,只对当前连接可见,其他连接无法访问;- 当消费者连接关闭,临时队列自动删除,不残留数据;
- 因为队列删除了,绑定关系也一并消失,后续广播消息不会再投递给这个已经下线的消费者。
顺着这个还能再问一层:既然连接断开就收不到消息,那临时队列里的消息在消费者断开时会消失吗?答案是会。这也说明发布订阅模式不适合做“离线消息补发”的场景。如果面试者能主动讲出这个 trade-off,说明他是真的理解了这个模式,而不只是背了 API。
6.2 两个消费者绑定同一个具名队列,还会广播吗
这个追问是很多人翻车的地方。如果两个消费者不是各自创建临时队列,而是绑定同一个具名队列到 fanout 交换机,会发生什么?
答案是:不会有广播效果。虽然交换机仍然是 fanout,会把消息投递到这个具名队列,但队列只有一份,消息进入队列后依然按竞争方式分发给消费者。也就是说,两个消费者会交替收到消息,每一条消息只被其中一个消费。
这个例子很好地说明了发布订阅模式的本质:广播是发生在交换机与队列之间,而不是交换机与消费者之间。要保证每个消费者都收到完整消息,必须让每个消费者拥有自己的队列。面试的时候把这个点讲清楚,会显得理解层次高不少。
6.3 fanout 与 direct、topic 的分工
面试中另一个高频问题就是对比交换机类型。可以把三种类型放在一起对比:
- fanout:不看路由键,发给所有绑定队列。最“无脑”,最广播。
- direct:按路由键精确匹配,一个队列可以绑定多个键,生产者的路由键必须和队列的绑定键完全相同才能投递。
- topic:改进版 direct,绑定键和路由键都支持通配符,
*匹配一个单词,#匹配零个或多个单词,适合复杂路由规则。
用生活化的说法:fanout 是群发短信,所有人收到的内容一样;direct 是对暗号,暗号对上了才投递;topic 是按订阅标签分发,订阅了“体育-篮球”的人就只收篮球新闻。
面试官一般还会让候选人结合项目说一个使用场景。这里最忌讳的是背定义,最好把自己的真实项目场景讲出来——比如你用了 fanout 广播配置变更,为什么不用 direct?因为所有服务都需要知道,不想维护一堆路由键。这个回答比背诵概念有用得多。
6.4 讲好发布订阅的现场答题框架
如果你只是临时需要应付面试,我建议按这个顺序组织答案,基本能把这个问题答得完整:
先说核心定义:发布订阅模式通过 fanout 交换机实现一对多的消息广播,生产者不直接接触队列,只把消息发给交换机。
再说机制:交换机把消息复制给所有绑定到它上面的队列,每个消费者使用独立的临时队列订阅,因此每条消息都会被每个消费者收到。
然后说效果:它让生产者和消费者完全解耦,新增或移除订阅方不影响生产者。
最后说代价:fanout 交换机本身不存储消息,临时队列也不持久化,消费者离线就会错过消息,因此它不适合要求可靠投递离线补发的场景。
这个框架好在它不是死背概念,而是把“定义—机制—效果—代价”串成一条逻辑链。面试官继续深挖的时候,你再从这条线上展开回答即可。
最后再分享一个我自己的习惯:任何使用发布订阅模式的项目里,我都要留一个“广播监控任务”,定时检查 fanout 交换机下的绑定数量是否和预期服务实例数一致。这个数量只要对不上,就说明有消费者服务没上线或者已经死掉,属于典型的静默故障,不主动发现的话业务上可能要过很久才会暴露问题。发布订阅模式给你的解耦是价值,但它也意味着生产者对消费者完全“无感”,这种无感在故障场景里恰恰是最危险的地方,类似的教训我在线上踩过一次之后,就再也不敢省略这一步。
