Java 中间件之 RabbitMQ 快速入门: SpringAMQP WorkQueue 工作队列模型

概述

一个消费者每秒只能处理 40 条,生产者每秒却发 50 条,多出来的 10 条会一直堆在队列里直到把内存撑满、新消息被丢弃。WorkQueue 就是解决这件事的:把多个消费者挂到同一个队列上,一起把消息消化掉。

纲要

  • WorkQueue 要解决的问题
    • 单消费者吞吐不足 → 队列堆积 → 队列有内存上限 → 装满后新消息被丢弃
    • 解法:同一个队列绑定多个消费者
  • 核心认知
    • 多个消费者之间是竞争关系,同一条消息只会被其中一个拿到(区别于后面的发布订阅)
    • 这不是新的队列类型,就是普通队列,只是消费者挂了多个
  • 动手部分
    • publisher:SpringAmqpTest#testSendMessage2WorkQueue,循环发 50 条,Thread.sleep(20)
    • consumer:SpringRabbitListener 两个 @RabbitListener 监听同一个 simple.queue,一个 sleep 20ms、一个 sleep 200ms
  • 能者多劳
    • 默认投递是轮询 + 消息预取,导致慢消费者也分到一半
    • spring.rabbitmq.listener.simple.prefetch: 1 让消费者处理完并 ACK 后才拿下一条
    • 实测:改之前约 5 秒,改之后约 1 秒

单消费者为什么会出事

简单队列模型是「一个生产者 → 一个队列 → 一个消费者」。这个模型在消费速度跟得上生产速度时没有问题,一旦消费变慢,队列就开始堆积。

RabbitMQ 的队列本质上是内存中的数据结构,它有存储上限。生产者每秒进 50 条、消费者每秒出 40 条,每秒净增 10 条,队列迟早被填满;填满之后再来的消息就进不去了,只能被丢弃------消息丢了,业务就出问题了。

所以要么提升单个消费者的处理能力,要么加人。加人的成本更低,也更符合横向扩展的思路:
#mermaid-svg-3kpF80Wp6FCBZG0v{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3kpF80Wp6FCBZG0v .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3kpF80Wp6FCBZG0v .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3kpF80Wp6FCBZG0v .error-icon{fill:#552222;}#mermaid-svg-3kpF80Wp6FCBZG0v .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3kpF80Wp6FCBZG0v .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3kpF80Wp6FCBZG0v .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3kpF80Wp6FCBZG0v .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3kpF80Wp6FCBZG0v .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3kpF80Wp6FCBZG0v .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3kpF80Wp6FCBZG0v .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3kpF80Wp6FCBZG0v .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3kpF80Wp6FCBZG0v .marker.cross{stroke:#333333;}#mermaid-svg-3kpF80Wp6FCBZG0v svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3kpF80Wp6FCBZG0v p{margin:0;}#mermaid-svg-3kpF80Wp6FCBZG0v .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3kpF80Wp6FCBZG0v .cluster-label text{fill:#333;}#mermaid-svg-3kpF80Wp6FCBZG0v .cluster-label span{color:#333;}#mermaid-svg-3kpF80Wp6FCBZG0v .cluster-label span p{background-color:transparent;}#mermaid-svg-3kpF80Wp6FCBZG0v .label text,#mermaid-svg-3kpF80Wp6FCBZG0v span{fill:#333;color:#333;}#mermaid-svg-3kpF80Wp6FCBZG0v .node rect,#mermaid-svg-3kpF80Wp6FCBZG0v .node circle,#mermaid-svg-3kpF80Wp6FCBZG0v .node ellipse,#mermaid-svg-3kpF80Wp6FCBZG0v .node polygon,#mermaid-svg-3kpF80Wp6FCBZG0v .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3kpF80Wp6FCBZG0v .rough-node .label text,#mermaid-svg-3kpF80Wp6FCBZG0v .node .label text,#mermaid-svg-3kpF80Wp6FCBZG0v .image-shape .label,#mermaid-svg-3kpF80Wp6FCBZG0v .icon-shape .label{text-anchor:middle;}#mermaid-svg-3kpF80Wp6FCBZG0v .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3kpF80Wp6FCBZG0v .rough-node .label,#mermaid-svg-3kpF80Wp6FCBZG0v .node .label,#mermaid-svg-3kpF80Wp6FCBZG0v .image-shape .label,#mermaid-svg-3kpF80Wp6FCBZG0v .icon-shape .label{text-align:center;}#mermaid-svg-3kpF80Wp6FCBZG0v .node.clickable{cursor:pointer;}#mermaid-svg-3kpF80Wp6FCBZG0v .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3kpF80Wp6FCBZG0v .arrowheadPath{fill:#333333;}#mermaid-svg-3kpF80Wp6FCBZG0v .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3kpF80Wp6FCBZG0v .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3kpF80Wp6FCBZG0v .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3kpF80Wp6FCBZG0v .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3kpF80Wp6FCBZG0v .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3kpF80Wp6FCBZG0v .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3kpF80Wp6FCBZG0v .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3kpF80Wp6FCBZG0v .cluster text{fill:#333;}#mermaid-svg-3kpF80Wp6FCBZG0v .cluster span{color:#333;}#mermaid-svg-3kpF80Wp6FCBZG0v div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3kpF80Wp6FCBZG0v .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3kpF80Wp6FCBZG0v rect.text{fill:none;stroke-width:0;}#mermaid-svg-3kpF80Wp6FCBZG0v .icon-shape,#mermaid-svg-3kpF80Wp6FCBZG0v .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3kpF80Wp6FCBZG0v .icon-shape p,#mermaid-svg-3kpF80Wp6FCBZG0v .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3kpF80Wp6FCBZG0v .icon-shape .label rect,#mermaid-svg-3kpF80Wp6FCBZG0v .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3kpF80Wp6FCBZG0v .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3kpF80Wp6FCBZG0v .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3kpF80Wp6FCBZG0v :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} WorkQueue 模型
publisher
simple.queue
consumer1
consumer2
简单队列模型
publisher
simple.queue
consumer 单实例

两个消费者每秒各处理 40 条,合起来 80 条/秒,面对 50 条/秒的生产速率就有余量了。

竞争消费:一条消息只给一个人

这里有个容易被后面知识带偏的点,必须先钉死:同一个队列上的多个消费者是竞争关系。

RabbitMQ 的消息是「阅后即焚」的------消息被投递给消费者 1、消费者 1 处理完并确认之后,这条消息就从队列里删除了,消费者 2 不可能再拿到同一条。所以 50 条消息进来,不是每人各收 50 条,而是两人各处理一部分,加起来正好 50 条。

这一点和后面的发布订阅(Fanout/Direct/Topic)完全不同,别混:

维度 WorkQueue 发布订阅
中间角色 只有队列 多一个 exchange
消费者关系 竞争,抢同一批消息 各自独立,互不干扰
一条消息被处理几次 1 次 每个队列各 1 次
典型目的 提高吞吐、防止堆积 一条消息通知多个下游
判断依据 多个 @RabbitListener 的 queues 值相同 各自绑定不同队列

一句话判断:看你 @RabbitListener(queues = "...") 里的队列名。名字一样就是竞争,名字不一样就是各收各的。

动手:一个队列挂两个消费者

工程结构(mq-demo,代码在 day04-MQ/代码/mq-demo):

tree 复制代码
mq-demo
├── pom.xml
├── publisher
│   ├── pom.xml
│   └── src
│       ├── main
│       │   ├── java
│       │   │   └── cn/itcast/mq/PublisherApplication.java
│       │   └── resources
│       │       └── application.yml
│       └── test
│           └── java
│               └── cn/itcast/mq/spring/SpringAmqpTest.java
└── consumer
    ├── pom.xml
    └── src
        └── main
            ├── java
            │   └── cn/itcast/mq
            │       ├── ConsumerApplication.java
            │       └── listener/SpringRabbitListener.java
            └── resources
                └── application.yml

这一节没有新 API,全是上一篇 SimpleQueue 用过的东西:RabbitTemplate.convertAndSend 发,@RabbitListener 收。区别只有两个:发送端改成循环发,接收端从一个监听器变成两个。示例工程基于 Spring Boot 2.3.9.RELEASE。

依赖与启动类

父工程 mq-demo/pom.xml 里只需要一个关键依赖,spring-boot-starter-amqp 内部已经带上了 spring-rabbit 和 amqp-client:

xml 复制代码
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
    </dependency>
</dependencies>

两个服务都是最普通的 Spring Boot 启动类,没有任何 MQ 相关注解------@RabbitListener 是扫包自动注册的,不需要 @EnableRabbit(RabbitAnnotationDrivenConfiguration 在自动配置里已经开了):

java 复制代码
package cn.itcast.mq;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ConsumerApplication {
    public static void main(String[] args) {
        SpringApplication.run(ConsumerApplication.class, args);
    }
}

publisher:循环发送 50 条

沿用已有的 simple.queue,不新建队列。循环里加 Thread.sleep(20) 是为了让 50 条消息在一秒左右发完,方便观察消费端的耗时差异;如果一口气发完,就看不出「处理速度跟不上发送速度」的现象了。

java 复制代码
package cn.itcast.mq.spring;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringAmqpTest {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    /**
     * WorkQueue:向队列中不停发送消息,模拟消息堆积。
     * 50 条消息,每条间隔 20ms,约 1 秒发完。
     */
    @Test
    public void testSendMessage2WorkQueue() throws InterruptedException {
        // 队列名称,沿用简单队列模型的队列
        String queueName = "simple.queue";
        // 消息内容,拼接序号方便观察被哪个消费者消费
        String message = "hello, message__";
        for (int i = 1; i <= 50; i++) {
            rabbitTemplate.convertAndSend(queueName, message + i);
            Thread.sleep(20);
        }
    }
}

publisher 端的 application.yml 只需要连上 broker,没有任何消费相关配置:

yaml 复制代码
spring:
  rabbitmq:
    host: 192.168.150.101 # RabbitMQ 的 IP 地址
    port: 5672 # 端口
    username: itcast
    password: 123321
    virtual-host: /

consumer:两个监听器抢同一个队列

两个方法都监听 simple.queue,用 sleep 模拟处理能力差异:消费者 1 每条耗时 20ms(约 50 条/秒),消费者 2 每条耗时 200ms(约 5 条/秒)。日志上也有区分------一个走 System.out(白),一个走 System.err(红),末尾拼上 LocalTime.now() 方便看时间跨度。

java 复制代码
package cn.itcast.mq.listener;

import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

import java.time.LocalTime;

@Component
public class SpringRabbitListener {

    /**
     * 消费者1:每条消息处理 20ms,约 50 条/秒
     */
    @RabbitListener(queues = "simple.queue")
    public void listenWorkQueue1(String msg) throws InterruptedException {
        System.out.println("消费者1接收到消息:【" + msg + "】" + LocalTime.now());
        Thread.sleep(20);
    }

    /**
     * 消费者2:每条消息处理 200ms,约 5 条/秒
     */
    @RabbitListener(queues = "simple.queue")
    public void listenWorkQueue2(String msg) throws InterruptedException {
        System.err.println("消费者2........接收到消息:【" + msg + "】" + LocalTime.now());
        Thread.sleep(200);
    }
}

处理能力加起来 55 条/秒,超过发送端的 50 条/秒,理论上 1 秒内应该全部处理完。

先跑一遍默认配置

启动 ConsumerApplication,清空控制台,运行上面的测试方法。实际结果和预期差很远:

text 复制代码
31:31.685  消费者1接收到消息:【hello, message__2】
31:31.706  消费者1接收到消息:【hello, message__4】
...
32:32.685  消费者1接收到消息:【hello, message__50】    # 消费者1 结束,不足 1 秒
31:31.7xx  消费者2........接收到消息:【hello, message__1】
...
36:36.490  消费者2........接收到消息:【hello, message__49】  # 消费者2 结束

统计下来:

  • 消费者 1 拿到的是 2、4、6 ... 50,全是偶数,25 条,不到 1 秒处理完,然后就闲着了
  • 消费者 2 拿到的是 1、3、5 ... 49,全是奇数,25 条,花了将近 5 秒
  • 整体耗时从 31 秒 685 一直到 36 秒 490,约 4.8 秒,远不是预期的 1 秒

慢的那个忙得要死,快的那个早就干完在划水。25 × 200ms = 5 秒,整体耗时几乎完全由慢消费者决定。

消息预取:平均分配的根源

原因在于 RabbitMQ 的消息预取(prefetch)机制。

消费者启动时会告诉 broker「我最多能同时拿多少条未确认的消息」,这个数字就是 prefetch count。broker 在投递时不关心你有没有处理完,只要未 ACK 的数量没到上限,就继续按轮询顺序往下投:给消费者 1 一条,给消费者 2 一条,回来再给消费者 1 一条...... 由于两个消费者的处理速度不一样,快的那个 ACK 得快、位子空得快,但 broker 是轮询的,压根不看这个,结果就是两个人拿到的数量一模一样。
consumer2 (200ms/条) consumer1 (20ms/条) simple.queue consumer2 (200ms/条) consumer1 (20ms/条) simple.queue #mermaid-svg-n4Iec4wQId6gV4vm{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-n4Iec4wQId6gV4vm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-n4Iec4wQId6gV4vm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-n4Iec4wQId6gV4vm .error-icon{fill:#552222;}#mermaid-svg-n4Iec4wQId6gV4vm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-n4Iec4wQId6gV4vm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-n4Iec4wQId6gV4vm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-n4Iec4wQId6gV4vm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-n4Iec4wQId6gV4vm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-n4Iec4wQId6gV4vm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-n4Iec4wQId6gV4vm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-n4Iec4wQId6gV4vm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-n4Iec4wQId6gV4vm .marker.cross{stroke:#333333;}#mermaid-svg-n4Iec4wQId6gV4vm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-n4Iec4wQId6gV4vm p{margin:0;}#mermaid-svg-n4Iec4wQId6gV4vm .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-n4Iec4wQId6gV4vm text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-n4Iec4wQId6gV4vm .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-n4Iec4wQId6gV4vm .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-n4Iec4wQId6gV4vm .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-n4Iec4wQId6gV4vm .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-n4Iec4wQId6gV4vm #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-n4Iec4wQId6gV4vm .sequenceNumber{fill:white;}#mermaid-svg-n4Iec4wQId6gV4vm #sequencenumber{fill:#333;}#mermaid-svg-n4Iec4wQId6gV4vm #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-n4Iec4wQId6gV4vm .messageText{fill:#333;stroke:none;}#mermaid-svg-n4Iec4wQId6gV4vm .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-n4Iec4wQId6gV4vm .labelText,#mermaid-svg-n4Iec4wQId6gV4vm .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-n4Iec4wQId6gV4vm .loopText,#mermaid-svg-n4Iec4wQId6gV4vm .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-n4Iec4wQId6gV4vm .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-n4Iec4wQId6gV4vm .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-n4Iec4wQId6gV4vm .noteText,#mermaid-svg-n4Iec4wQId6gV4vm .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-n4Iec4wQId6gV4vm .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-n4Iec4wQId6gV4vm .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-n4Iec4wQId6gV4vm .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-n4Iec4wQId6gV4vm .actorPopupMenu{position:absolute;}#mermaid-svg-n4Iec4wQId6gV4vm .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-n4Iec4wQId6gV4vm .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-n4Iec4wQId6gV4vm .actor-man circle,#mermaid-svg-n4Iec4wQId6gV4vm line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-n4Iec4wQId6gV4vm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} prefetch 默认较大,broker 不等 ACK 就继续投 已处理完 1、3,空转等待 2 还没处理完,4 继续排队 message__1 message__2 message__3 message__4 message__5 message__6

Spring AMQP 的 SimpleMessageListenerContainer 默认 prefetch 是 250(版本不同略有出入,但都是「一大把」的量级)。50 条消息对于 250 的上限来说根本不算什么,等于没有限制,所以表现就是纯粹的轮询平均分。

能者多劳:prefetch 设为 1

要让 broker 别再盲目轮询,就得把预取上限压到 1------只有处理完并 ACK 之后,才能拿到下一条 。改 consumer 服务的 application.yml:

yaml 复制代码
spring:
  rabbitmq:
    host: 192.168.150.101 # RabbitMQ 的 IP 地址
    port: 5672 # 端口
    username: itcast
    password: 123321
    virtual-host: /
    listener:
      simple:
        prefetch: 1 # 每次只能获取一条消息,处理完成并 ACK 后才能获取下一条

这个配置只加在 consumer 端,publisher 端不用动。重启 ConsumerApplication,清日志,重新发 50 条:

text 复制代码
56:56.982  消费者1接收到消息:【hello, message__2】
56:56.9xx  消费者1接收到消息:【hello, message__3】
56:56.9xx  消费者1接收到消息:【hello, message__4】
...                                    # 消费者1 吃掉绝大多数
56:57.1xx  消费者2........接收到消息:【hello, message__1】
56:57.3xx  消费者2........接收到消息:【hello, message__11】
56:57.5xx  消费者2........接收到消息:【hello, message__21】
56:57.7xx  消费者2........接收到消息:【hello, message__31】
56:57.969  消费者2........接收到消息:【hello, message__41】  # 结束

改前改后的对比:

指标 默认 prefetch prefetch = 1
总耗时 约 4.8 秒(31.685 → 36.490) 约 1.0 秒(56.982 → 57.969)
消费者 1 消费条数 25(全偶数) 约 45 条
消费者 2 消费条数 25(全奇数) 约 5 条
是否出现空闲 消费者 1 提前 4 秒结束,空转 基本同时结束
消息编号分布 严格奇偶交替 连续、不规律

为什么 prefetch=1 就能做到能者多劳?逻辑很直白:broker 还是按轮询顺序找下一个人,但发现你上一条还没 ACK,就直接跳过你去找下一个。消费者 1 处理快、ACK 快,每次轮询到它时它都是空闲的,于是拿到的机会就多;消费者 2 慢,经常被跳过,自然就少拿。谁干得快谁多干------这就是能者多劳。

prefetch 到底设多少

prefetch: 1 是演示能者多劳最直观的值,但不是所有场景的最优解。prefetch 越小,broker 和消费者之间的网络往返次数越多,单条延迟越敏感的场景反而吃亏。

场景 建议取值 理由
单条消息耗时长、消费者能力不均 1 严格按需分配,慢节点不会积压
单条耗时短、吞吐优先 10 ~ 100 减少网络往返,提高吞吐
消费者能力接近、消息耗时均匀 可保持默认 平均分配本身就够用
单消费者串行处理、严格要求顺序 1 同时只处理一条,天然保序

有个容易忽略的前提:prefetch 限制的是未 ACK 的消息数 。如果你的 listener 方法抛异常且没有正确 ACK/Spring 没有做异常处理,未确认数会一直卡在上限,消费者看起来就像「卡住了」。排查时先看 RabbitMQ 管理后台队列详情里的 Unacked 计数------这个数一直贴着 prefetch 值不动,基本就是消费端卡在某条消息上。

常见坑

  • 两个消费者必须监听同一个队列名 。写成 simple.queue 和 simple.queue2,那就是两条独立队列,各收各的 50 条,完全没有竞争关系,也不会有任何提速效果。
  • prefetch 设太大等于没设。设成 1000 而队列里只有 50 条消息,效果和默认一样,还是轮询平均分。prefetch 必须小于「队列里可能的积压量」才起作用。
  • 配置要加在 consumer 端。prefetch 是消费者告诉 broker 的约束,写在 publisher 的 yml 里没有任何效果。
  • 只改配置不重启,看不到效果。prefetch 在消费者建立 channel 时就确定好了,改完 yml 必须重启 consumer 服务。
  • 别用 WorkQueue 做广播。需要「一条消息通知多个系统」时,WorkQueue 做不到------消息被其中一个消费者拿走就没了,那是发布订阅的活。
  • Thread.sleep 只是演示手段。真实项目里慢消费者通常是因为数据库慢查询、外部 HTTP 调用超时、大对象序列化这类原因,用 sleep 模拟出来的现象和真实瓶颈表现一致(都是占用线程时间变长),但排查时得往这些方向查。

API 速览

能力 用法
发送消息 rabbitTemplate.convertAndSend(queueName, message)
声明监听器 @RabbitListener(queues = "simple.queue") 标注在 @Component 类的方法上
多消费者竞争 多个方法的 queues 填同一个队列名
控制预取上限 spring.rabbitmq.listener.simple.prefetch
观察投递情况 RabbitMQ 管理后台队列详情页的 Ready / Unacked
消息轨迹辅助 消息内容里拼序号,日志里拼 LocalTime.now()

官方文档

总结

  • WorkQueue 不是新的队列类型,就是普通队列挂了多个消费者,目的是提高吞吐、避免消息堆积。
  • 同一队列上的消费者是竞争关系,一条消息只会被其中一个处理,处理完就从队列删除------这点和发布订阅完全不同。
  • 默认投递是「轮询 + 预取」,broker 不等 ACK,结果是平均分配,慢消费者会拖垮整体耗时。
  • prefetch: 1 让消费者处理完并 ACK 后才拿下一条,快的自然多拿,实测总耗时从约 4.8 秒降到约 1 秒。
  • prefetch 不是越小越好,短耗时高吞吐场景取 10~100 更划算;配错队列名、配错服务、忘记重启,是看不到效果的三个高频原因。
相关推荐
代码山河1 小时前
第一个Java程序:Hello World从零编写与运行
java·学习·架构·教程·面向对象·项目
codigger1 小时前
Java 26 深度解析:一次“性能兑现 + 契约收紧”的版本
java·后端·编程·编程语言
盖世英雄酱581361 小时前
格式化成日期为什么提前一天呢,同样的时间点还是非必现???
java·后端
威哥爱编程1 小时前
【AI全栈12-05】Spring Boot 用 Tool Calling 让 AI 查实时订单与库存
java·spring boot·后端
Guarding and trust1 小时前
项目拆解(二):模型输出是‘野‘的——三层兜底+重分析,把AI变成可靠零件
java·人工智能·python·大模型·fastapi·ollama·错误兜底
春风霓裳1 小时前
0xc0000005 = 内存访问违规
java
威哥爱编程1 小时前
【AI全栈12-06】Spring Boot 用 RAG 搭企业私有知识库问答
java·spring boot·后端
迅猛龙办公室1 小时前
Python实现获取星期字符串
java·前端·python
小蒜学长1 小时前
基于微信小程序的医院医疗废弃物追溯管理系统(代码+数据库+LW)
java·spring boot·后端·微信小程序·医疗废弃物