1. 项目背景
三节点成群后,架构评审最容易变成口号会:「全部 quorum,金融级」「日志也 quorum,别丢」「网关回调也 quorum,省得选」。另一种口号是「全用经典队列,咱们刚第 16 章验过」。两种都会在大促翻车。
推广中台真实约束不同:
| 业务 | 问法 | 若选错 |
|---|---|---|
| 支付成功 / 履约 | 竞争消费、要 Ack、要抗一台宕机 | classic:节点死则盘上消息不可达;stream:不能当工作队列 Ack 掉 |
| 库存/埋点审计 | 要回放、多组独立进度、可砍旧数据 | quorum:堆积贵、非为回放设计 |
| 开放网关的连接级临时缓冲 | 连接在则在,连接断则应消失 | quorum:元数据与副本浪费;持久 classic:连接断了队列还在 |
4.x 把类型收口到声明参数x-queue-type:classic、quorum、stream。另有rabbit_volatile_queue:元数据不进 Khepri,消息不落缓冲,MQTT QoS0 / 瞬发路径用,不要给支付声明。客户端不写类型时,走 VHost 默认或default_queue_type,最后回落到rabbit_classic_queue(rabbit_queue_type:fallback/0)。
痛点图:
同一 VHost 混用没问题 ↓ 错在「同一条队列名改类型」→ 406 inequivalent ↓ 或默认类型被运维改成 quorum,网关 exclusive 声明失败 ↓ 或把 stream 当支付队列,消费者无法破坏性 Ack本章交付一张选型表 + 三条可运行声明。复制如何投票、offset 如何存,分别留给 19、20 章,但今天必须让测试能list_queues name type看到三种并存。
2. 项目设计
小胖把食堂窗口照片摊开:炒菜、面档、甜品,都叫窗口。
小胖:这不都是队列吗?不就是先进先出。为啥还要四种盘子?全部做成最厚的不锈钢(quorum)不就结实?网关那点临时数据也复制三份,大不了多点内存。
大师:炒菜窗口要出餐即走(竞争消费 + Ack),面档要留样给食药监回放(日志),甜品车跟着服务员走、人走车撤(exclusive)。全部不锈钢:面档贵且不好回放,甜品车焊在地上撤不掉。Stream非破坏性,一个人读完别人还能从 offset 再读;支付要的是「这份订单只有一个履约者拿走」。Volatile 连盘子都不入库,服务员没接住就掉地上——MQTT QoS0 可以,支付不行。
技术映射:classic/quorum = 破坏性竞争队列;stream = 日志;volatile = 不落盘的瞬发。
小白:默认类型改了,老客户端不带x-queue-type会怎样?混集群滚动升级时类型模块缺失呢?x-queue-leader-locator的client-local和balanced怎么选?classic exclusive 还要不要 durable?stream 能否绑定 Fanout 当触达?quorum 能否 exclusive?volatile 能从 AMQP 声明吗?
大师:不带类型就用默认;若默认改成 quorum,昔日「随手 declare 临时队列」可能失败或不符预期,所以业务声明应写死类型,不要吃默认。滚动升级时rabbit_queue_type:discover/1找不到模块会失败,检查单要含插件与版本。locator:client-local把 Leader 放在声明者所在节点,连接已均衡时延迟好;balanced是推荐默认,把 Leader 摊开。random/least-leaders已弃用并映射到 balanced。exclusive classic 必须随连接,通常非跨节点;4.x 默认拒绝非 exclusive 的 transient(第 7 章)。Stream 可以绑交换机,但消费语义仍是日志,Fanout 触达第 16 章那种「每人一份然后 Ack 掉」应用 quorum 或 classic。quorum 不支持 exclusive。volatile 不是给你在支付代码里填x-queue-type=volatile玩的,本章只点名边界。
小胖:那支付q.order.pay.q用 quorum,日志q.log.stock.s用 stream,网关amq.gen-*exclusive classic。旧的第 16 章q.order.pay先留着对照,不在评审里当 HA。
大师:好。名字带后缀是为了避免和已存在 classic 同名改类型。迁移是新队列 + 双写或 Shovel,不是改 arguments。Policy 可以打 TTL/DLX,但打不到「把 classic 变成 quorum」。
技术映射:类型是声明时的建筑结构;Policy 是热水时间;二者不可互换。
小白:list_queues如何看类型?HTTP API 字段?Leader 不在本节点时 classic 客户端会不会被转发?三种类型对 Confirm 的含义是否相同?
大师:rabbitmqctl list_queues name type;HTTP/api/queues有type。classic 消息体在宿主节点,集群会把 publish/consume定位到该队列所在节点(不是复制)。quorum/stream 的 Leader 也可能不在你连的那台,协议层会把命令送到 Leader。Confirm:classic 是本节点落盘;quorum 是多数派提交(第 19 章);stream 是写入日志段。不要用同一套超时当三种成功。
小胖:实验就声明三条,发三条,list 看类型,再故意用错类型重声明看 406。选型表贴评审纪要。
3. 项目实战
3.1 环境准备
第 17 章三节点 Running=3。应用账号沿用第 16 章,连127.0.0.1:5672(rabbit1)。需要管理插件已开(镜像自带)。Stream 作为队列类型走 AMQP 声明即可,不必先讲 Stream 协议端口。
dockerexecrabbit1 rabbitmqctl cluster_statusdockerexecrabbit1 rabbitmqctl list_feature_flags运行结果:三节点;Khepri 相关 flag 已启用(4.4 默认)。不必改 feature flag。
3.2 步骤一:对照声明三种队列
步骤目标:同一order/marketing里三种类型并存。
# promo-mq/ch18/declare_types.pyimportpikadefconn(vh,user,pwd,name):returnpika.BlockingConnection(pika.ConnectionParameters("127.0.0.1",5672,vh,pika.PlainCredentials(user,pwd),client_properties={"connection_name":name}))defmain():c=conn("order","app_order","ord_dev_2026","ch18-declare-order")ch=c.channel()ch.queue_declare("q.order.pay.q",durable=True,arguments={"x-queue-type":"quorum","x-quorum-initial-group-size":3,"x-queue-leader-locator":"balanced",})ch.exchange_declare("ex.order.direct","direct",durable=True)ch.queue_bind("q.order.pay.q","ex.order.direct","pay.ok.q")c.close()c=conn("marketing","app_mkt","mkt_dev_2026","ch18-declare-mkt")ch=c.channel()ch.queue_declare("q.log.stock.s",durable=True,arguments={"x-queue-type":"stream","x-max-age":"1D","x-queue-leader-locator":"balanced",})ch.exchange_declare("ex.log.topic","topic",durable=True)ch.queue_bind("q.log.stock.s","ex.log.topic","stock.#")c.close()print("quorum+stream declared")if__name__=="__main__":main()网关 exclusive classic 必须在长连接里声明,声明完连接还在才能看见:
# promo-mq/ch18/declare_exclusive.pyimporttime,pika c=pika.BlockingConnection(pika.ConnectionParameters("127.0.0.1",5672,"order",pika.PlainCredentials("app_order","ord_dev_2026"),client_properties={"connection_name":"ch18-gw-excl"}))ch=c.channel()q=ch.queue_declare("",exclusive=True,durable=False,arguments={"x-queue-type":"classic"})print("exclusive",q.method.queue)time.sleep(30)# 这 30 秒内 list_queues 能看到它c.close()python declare_types.pydockerexecrabbit1 rabbitmqctl list_queues-porder nametypedockerexecrabbit1 rabbitmqctl list_queues-pmarketing nametype运行结果:q.order.pay.q为quorum;q.log.stock.s为stream。exclusive 在睡眠窗口显示 classic。
坑:对已存在的第 16 章q.order.pay(classic)再 declare quorum → 406。
坑:stream 的x-max-age是保留策略,不是 TTL 到点变死信。
坑:quorum 的x-quorum-initial-group-size大于集群节点数时,声明可能卡住或不满员,三节点实验室写 3。
3.3 步骤二:选型对照表(评审打印)
| 维度 | Classic | Quorum | Stream | Volatile |
|---|---|---|---|---|
| 复制 | 不复制(镜像已过时) | Raft 多数派 | 复制日志段 | 无 |
| 消费 | 破坏性 Ack | 破坏性 Ack | 非破坏性 offset | 至多一次、不缓冲 |
| 顺序 | 单队列 FIFO(重投会影响) | 近似 FIFO + 投递限制 | 分区内 offset 序 | 无保证 |
| 堆积 | 相对能扛(仍有上限) | 贵,不适合无限积 | 按 max-age/max-length-bytes 砍 | 不积 |
| exclusive | 支持 | 不支持 | 不支持 | 会话型 |
| 典型 | 网关临时、非关键 | 支付、库存指令 | 审计、回放 | MQTT QoS0 |
支付选 quorum,日志选 stream,网关选 exclusive classic。第 16 章 Fanout 三触达在未上集群复制前可暂留 classic,上线检查单要写明单点,或改为三条 quorum(成本换 HA)。
3.4 步骤三:故意 inequivalent
# promo-mq/ch18/inequivalent.pyimportpikafrompika.exceptionsimportChannelClosedByBroker c=pika.BlockingConnection(pika.ConnectionParameters("127.0.0.1",5672,"order",pika.PlainCredentials("app_order","ord_dev_2026")))ch=c.channel()try:ch.queue_declare("q.order.pay.q",durable=True,arguments={"x-queue-type":"classic"})print("UNEXPECTED")exceptExceptionase:print("expected fail",type(e).__name__,str(e)[:200])运行结果:406 PRECONDITION_FAILED,连接仍在(第 4 章:换通道)。
坑:应用把 406 当整连接死亡会连坐。
坑:默认类型若被改成 quorum,不带 arguments 的 declare 可能与你以为的 classic 冲突。
3.5 步骤四:各发一条,确认「能进」
# promo-mq/ch18/publish_smoke.pyimportjson,time,uuid,pikadefpub(vh,user,pwd,ex,rk,body):c=pika.BlockingConnection(pika.ConnectionParameters("127.0.0.1",5672,vh,pika.PlainCredentials(user,pwd)))ch=c.channel()ch.confirm_delivery()ch.basic_publish(ex,rk,body.encode(),properties=pika.BasicProperties(delivery_mode=2,content_type="application/json",message_id=str(uuid.uuid4()),timestamp=int(time.time())),mandatory=True)c.close()pub("order","app_order","ord_dev_2026","ex.order.direct","pay.ok.q",json.dumps({"orderId":"P-TYPE-1"}))pub("marketing","app_mkt","mkt_dev_2026","ex.log.topic","stock.change",json.dumps({"sku":"SKU-1","delta":-1}))print("smoked")AMQP 消费 stream 队列需要x-stream-offset(第 20 章)。本章验收以list_queues name type messages为准:quorum 深度 1;stream 也有消息计数但语义是日志长度。
运行结果:支付 quorum ready≥1;stream 有数据。不要用 Get 去「掏空」stream。
3.6 步骤五:leader locator 观察
dockerexecrabbit1 rabbitmqctl list_queues-porder nametypeleader membersdockerexecrabbit1 rabbitmqctl list_queues-pmarketing nametypeleader运行结果:quorum 的 members 三人;leader 可能在任一节点。多次删除重建(实验室队列)配合balanced可看到 leader 变化,不要在生产用删队列做实验。
坑:x-queue-master-locator已弃用,写x-queue-leader-locator。
坑:客户端连 rabbit2 声明client-local时,Leader 倾向 rabbit2,LB 不均匀时会把 Leader 堆到某台。
3.7 步骤六:默认类型门禁(运维)
# 不建议实验室乱改;若演示,改完必须改回 # default_queue_type = quorum测试用例:不带x-queue-type声明q.probe.default,记录实际 type,作为「环境默认」基线。业务代码禁止依赖该基线。
3.8 源码对照
| 问题 | 模块 |
|---|---|
| 字符串类型到模块 | rabbit_queue_type:discover/1 |
| 默认/回落 | default/0、fallback/0→ classic |
| 选 Leader | rabbit_queue_location.erl(client-local/balanced) |
| 各类型实现 | rabbit_classic_queue/rabbit_quorum_queue/rabbit_stream_queue/rabbit_volatile_queue |
volatile 文件头写明:元数据不进 metadata store,消息不缓冲,credit 不够就丢。评审纪要写「不用于推广中台业务队列」。
3.9 完整代码清单
promo-mq/ch18/ declare_types.py declare_exclusive.py inequivalent.py publish_smoke.py column/samples/ch18/3.10 测试验证
| 编号 | 名称 | 期望 |
|---|---|---|
| TC-CH18-01 | list type | pay.q=quorum,stock.s=stream |
| TC-CH18-02 | exclusive | 连接在则存在,关闭则消失 |
| TC-CH18-03 | 406 | 同名 classic 声明失败 |
| TC-CH18-04 | smoke publish | Confirm 成功 |
| TC-CH18-05 | members | quorum members=3 |
| TC-CH18-06 | 代码带 x-queue-type | grep 业务 declare 不得省略类型 |
值班检查单:新队列评审必须填类型列;禁止「先 classic 上线再改 quorum」;默认类型变更当生产事件;Fanout 触达若仍 classic 须在风险清单留单点。
评审会上用三分钟把「问法」问完即可,不必把 Raft 讲完:这条消息处理完是否必须从所有人眼前消失?要不要隔夜从头再读?连接断了队列还要不要在?三问分别钉死 quorum/classic 工单、stream、exclusive。问不完就休会,不要用「先建 classic 再说」当妥协——那会在第 19 章迁类型时付出 406 与双写成本。把第 16 章通知中心的每条队列填进下表,作为中级篇的资产台账,而不是重新发明名字。
| 队列名 | 当前类型 | 目标类型 | 迁法 | 风险 |
|---|---|---|---|---|
| q.order.pay | classic | quorum(新名 q.order.pay.q) | 双写/切流量 | 旧队列单点 |
| q.mkt.sms/mail/push | classic | 可暂留或改 quorum | 按通道独立 | 单点丢触达 |
| q.log.stock | classic | stream(q.log.stock.s) | 新绑 Topic | 旧队列无回放 |
| 网关临时 | exclusive classic | 保持 | 无 | 勿改 quorum |
台账进 Git,测试按行做 TC-CH18-01。运维每周list_queues name type与台账 diff,多出来的无类型声明视为事故苗头。开发在代码评审里对queue_declare打勾:看得见x-queue-type。三部门用同一张表,避免架构在 Confluence 写 quorum、代码里仍 classic。
4. 项目总结
优点与缺点
| 策略 | 优点 | 缺点 |
|---|---|---|
| 按问法选类型 | 成本与语义匹配 | 团队要懂三套消费模型 |
| 全 quorum | 心智简单 | 日志与临时队列被惩罚 |
| 全 classic | 与第 16 章一致 | 组网后仍不抗节点死亡 |
优点:1)类型成为一等声明。2)locator 可解释。3)406 把错迁移挡在门外。
缺点:1)迁移要新名字。2)stream 误当工作队列。3)默认类型坑老客户端。
对比 Kafka:stream 靠近日志,quorum 靠近复制工作队列;RabbitMQ 赢在同一套权限与交换机,输在不要用它冒充完整事件平台——第 20 章会划边界。
适用场景
- 架构评审强制填类型。
- 三节点实验室冒烟。
- 改造第 16 章通知中心的支付路径。
- 培训「破坏性 vs 非破坏性」。
不适用:在单节点上宣布 quorum 高可用(副本无处放);用 volatile 做支付;用 stream 做「处理完就删」的工单。
注意事项
- 声明写死类型。
- 4.x 无经典镜像。
- 安全:类型不替代 ACL。
- 版本:弃用 master-locator 名称。
常见踩坑(生产)
- 把旧 classic 改成 quorum 想热切换,全渠道 406。根因:类型属 inequivalent。处理:新队列双写。
- 运维把 default_queue_type 改成 quorum,网关 exclusive 大面积失败。根因:默认被业务误吃。处理:回滚默认,客户端显式 classic。
- 审计用 quorum 堆积一周,磁盘与 Raft 变慢,回放还是做不到。根因:选错问法。处理:迁 stream。
思考题
- Fanout 三条触达若从 classic 改为 quorum,Confirm、Ack、死信、成本各发生什么变化?哪一条不该改 quorum?
- 若
balanced把支付 Leader 放到最忙的节点,你用 Policy 还是改客户端 locator?为何?
下一章把q.order.pay.q真的杀掉 Leader,看多数派与delivery-limit。
选型不是一次性会议。大促前两周再扫一遍:有没有人把压测队列建成 quorum 占满 Raft;有没有人把 stream 当短信通道导致「Ack 了用户仍收到」。扫表比扫代码快。把inequivalent.py放进 CI 对已知名字做负例,防止合并请求里「顺手改类型」。实验室允许删队列,预发不行。预发迁类型必须新名字、双写、对账、切读、再下线旧队列,周期按天计,不要按小时吹。
Volatile 再记一笔给 MQTT 同事:它不是第四种业务队列,是协议路径上的瞬发缓冲。推广中台 AMQP 应用禁止声明它。若谁在评审里说「volatile 更轻,支付也用」,把源码文件头四条(不入库、不缓冲、至多一次、credit 不够就丢)读出来,会议即可结束。
开发联调时若看到「队列在,类型不是我以为的那个」,先list_queues name type再猜网络。默认类型被改过的环境最会制造这种幻觉。把默认类型打印进应用启动日志(只读配置,不在运行时改 Broker),比出了 406 再翻群聊天记录便宜。
附录 C:第 17 章思考题参考答案
题 1:连上任一节点能声明 ≠ 经典消息三份。
声明写的是元数据(Khepri 复制),classic 的消息体仍在队列宿主节点的盘上。应用连 rabbit2 消费 rabbit1 上的 classic,靠集群转发,不是本地有副本。领导要今晚抗单机宕机:最少完成本章选型并按第 19 章把支付建成3 副本 quorum,且 Confirm 等到多数派;只完成第 17 章组网不够。
题 2:三处女同时启动。
仍可能竞态形成不一致视窗,尽管 classic 后端有global:set_lock。SOP:种子节点健康后再起其余;锁定超时要告警而不是静默单节点;已有 volume 的节点禁止当处女发现。生产用编排工具的depends_on+ 就绪探针,不要靠「有锁就不会出事」一句。
延伸阅读与资源
SQLAlchemy 2.0从入门到进阶的实战之旅
Dify 从入门到进阶:LLM 应用平台实战修炼
Java 工程师进阶:从 JVM 生产排障到OpenJDK原理
NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化)
Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Redis 实战修炼与原理进阶
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
MongoDB 实战进阶与内核修炼
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析