rabbitMQ学习记录

分布式系统通信的两种方式: 1 远程调用,2 借助消息队列

 "RabbitMQ 的订阅方式本质是交换机的路由规则——Direct 精确匹配、 Fanout 广播、Topic 通配符(*一个词/#多个词)、Headers 按头匹配;

rabbitmq使用场景一:报表导出

前端调导出接口,后端返回taskId并发送导出消息到mq,导出服务监听mq消息并导出,前端轮询下载接口。

如果后端不用mq,新开一个线程做导出,直接返回taskId,对于前端来说效果一样,

用mq的好处是导出请求大时用新开线程的方法容易oom,即使有多个实例节点也会oom,nginx的upstream里负载均衡的配置-lease-conn表示将请求分给连接最少的节点,连接少不代表节点压力小,

三个大报表导出的压力远大于5个小报表的压力,所以用nginx的负载均衡配置+异步线程的方式可能oom,用mq可以配置消费者当前未消费完的任务数,这样控制更精确。

如果将线程池的核心线程设5,用无界队列,是不是就可以代替mq了? 不是的,这样虽然能保证同时进来多个导出请求,最多处理5个,但是剩下的请求都在无界队列里,无界队列

BlockingQueue持有强引用,gc不回收,容易导致oom。所以如果导出并发高,时间长,单次导出内存占用大,最好用mq做异步,mq消息堆积在队列用磁盘存储,不怕oom.

报表导出代码案例:

1. 依赖(pom.xml)

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.83</version>
</dependency>
<!-- 其他 Spring Boot Starter Web, MyBatis-Plus, MinIO 等按需 -->

2. application.yml 配置

spring:
  rabbitmq:
    host: 192.168.x.x
    port: 5672
    username: admin
    password: admin
    virtual-host: /
    listener:#消费者配置
      simple:
        acknowledge-mode: manual   # 手动确认
        prefetch: 1                # 每次只取一条,防止积压
    publisher-confirm-type: correlated   # 确认消息是否到交换机,生产者配置
    publisher-returns: true #确认消息是否到队列,还需代码配置 rabbitTemplate.setMandatory(true) ,生产者配置

3. 消息体 DTO

import lombok.Data;
import java.io.Serializable;

@Data
public class ExportMessage implements Serializable {
    private static final long serialVersionUID = 1L;
    private String taskId;          // 任务ID
    private String reportType;      // 报表类型:monthly / delay
    private String month;           // 统计月份
    private String orgCode;         // 执管单位编码
    private String userId;          // 操作人ID(用于权限、邮件通知)
}

4. 队列、交换机、绑定配置

import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitMQConfig {
    public static final String EXCHANGE = "export.exchange";
    public static final String QUEUE = "export.queue";
    public static final String ROUTING_KEY = "export.report";

    @Bean
    public DirectExchange exportExchange() {
        return new DirectExchange(EXCHANGE, true, false);//Direct (直连)类型的交换机,路由规则:消息的路由键(Routing Key)必须与队列的绑定键(Binding Key)完全一致
    }

    @Bean
    public Queue exportQueue() {
        return QueueBuilder.durable(QUEUE).build();
    }

    @Bean
    public Binding exportBinding() {
        return BindingBuilder.bind(exportQueue())
                .to(exportExchange())
                .with(ROUTING_KEY);//这里是设置绑定键
    }
}

5. 生产者(发送消息 + 确认回调)

import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.UUID;

@Component
public class ExportProducer {
    @Autowired
    private RabbitTemplate rabbitTemplate;

    public void sendExportTask(ExportMessage message) {
        CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());//CorrelationData——消息的“身份证号”作用:为每条消息生成一个全局唯一的 ID。Broker 确认回调时,会把这个 ID 原样带回来,让你知道“到底哪条消息发送成功了/失败了”。
如果后续消费者端要做幂等性防重(比如防止用户重复提交导出任务),你可以把这个 ID 存入 Redis,消费时检查是否已处理
        // 发送消息
        rabbitTemplate.convertAndSend( //发送后立即返回,返回结果异步回调
                RabbitMQConfig.EXCHANGE,
                RabbitMQConfig.ROUTING_KEY,
                message,
                correlationData
        );
        // 确认回调(可选)
        correlationData.getFuture().addCallback( //异步监听结果
                result -> {
                    if (result.isAck()) {//消息到达交换机
                        System.out.println("消息发送成功: " + message.getTaskId());
                    } else {//消息没到交换机
                        System.err.println("消息发送失败: " + result.getReason());
                    }
                },
                ex -> System.err.println("消息发送异常: " + ex.getMessage())
        );
    }
}

上面处理不完全, isAck == true只能说明消息正常进了交换机,== false说明消息被broker收到但拒绝接收,可能的原因是交换机不存在或内部错误,上面代码只能处理这两种情况,如果是发送阶段异常,比如 连接断开、信道关闭、序列化失败 ,需要在将 rabbitTemplate.convertAndSend方法 用try catch包裹,在catch中处理,还有可能broker假死/回调时网络异常 getFuture一直不完成,所以getFuture要加超时时间 correlationData.getFuture().get(5, TimeUnit.SECONDS);

 

保证消息从交换机到队列配置:

import org.springframework.amqp.core.RabbitTemplate;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.annotation.PostConstruct;

@Configuration
public class RabbitMQConfig {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    // 1. 在Bean初始化时设置 mandatory = true
    @PostConstruct
    public void initRabbitTemplate() {
        // 核心:必须设为true,否则交换机找不到队列时,消息会被静默丢弃,Return回调不会触发
        rabbitTemplate.setMandatory(true);

        // 2. 注册退回回调(处理路由失败)
        rabbitTemplate.setReturnsCallback(returnedMessage -> {
            // 拿到被退回的消息本体
            String messageBody = new String(returnedMessage.getMessage().getBody());
            String exchange = returnedMessage.getExchange();
            String routingKey = returnedMessage.getRoutingKey();
            String replyText = returnedMessage.getReplyText(); // 例如 "NO_ROUTE"
            int replyCode = returnedMessage.getReplyCode();

            System.err.println("========== 消息路由失败 ==========");
            System.err.println("交换机: " + exchange);
            System.err.println("路由键: " + routingKey);
            System.err.println("失败原因: " + replyText);
            System.err.println("消息内容: " + messageBody);

            // **** 生产级兜底:将失败消息落库,由定时任务人工/自动补偿 ****
            saveFailedMessageToDB(exchange, routingKey, messageBody, replyText);
        });
    }

    // 模拟落库(实际可用 @Async 或线程池异步执行,避免阻塞I/O线程)
    private void saveFailedMessageToDB(String exchange, String routingKey, String body, String reason) {
        // insert into t_mq_failed_msg(exchange, routing_key, body, reason, create_time) values (...)
        System.out.println("已落库,等待人工介入或定时任务重试");
    }
}

 

6. 消费者(处理业务)

import com.rabbitmq.client.Channel;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class ExportConsumer {

    @Autowired
    private ExportService exportService;  // 真正的业务处理类

    @RabbitListener(queues = RabbitMQConfig.QUEUE)
    public void handleExport(ExportMessage msg, Channel channel, Message message) throws IOException {
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        try {
            System.out.println("接收到导出任务: " + msg.getTaskId());
            // 执行业务:查询数据、生成Excel、上传MinIO
            String fileUrl = exportService.generateReport(msg);
            // 更新任务状态(例如存入Redis/DB,状态为已完成,附带fileUrl)
            updateTaskStatus(msg.getTaskId(), "SUCCESS", fileUrl);
            // 手动确认
            channel.basicAck(deliveryTag, false);
        } catch (Exception e) {
            System.err.println("处理失败: " + e.getMessage());
            // 可根据业务决定是否重试(重新入队或丢弃)
            // 这里选择拒绝并重新入队(最多重试3次,通过Redis计数,此处省略)
            channel.basicNack(deliveryTag, false, true);
        }
    }
}

场景二:mysql到es做数据增量同步

 数据同步监听器(核心)代码

package com.example.sync.listener;

import com.alibaba.fastjson.JSON;
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.protocol.CanalEntry;
import com.alibaba.otter.canal.protocol.Message;
import com.example.sync.config.MqConstants;
import com.example.sync.entity.ProductDocument;
import com.example.sync.repository.ProductElasticsearchRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Slf4j
@Component
@RequiredArgsConstructor
public class CanalDataSyncListener {

    private final CanalConnector canalConnector;
    private final RabbitTemplate rabbitTemplate;
    private final ProductElasticsearchRepository esRepository;

    private volatile boolean running = true;

    /**
     * 启动Canal监听线程,持续拉取binlog变更
     */
    @PostConstruct
    public void startCanalListener() {
        new Thread(() -> {
            while (running) {
                try {
                    // 批量获取消息,batchSize=1000
                    Message message = canalConnector.getWithoutAck(1000);
                    long batchId = message.getId();
                    if (batchId == -1 || message.getEntries().isEmpty()) {
                        Thread.sleep(100);
                        continue;
                    }
                    // 处理每条变更记录
                    processEntries(message.getEntries());
                    // 确认消费成功
                    canalConnector.ack(batchId);
                } catch (Exception e) {
                    log.error("Canal监听异常", e);
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException ignored) {}
                }
            }
        }, "canal-listener-thread").start();
        log.info("Canal监听线程已启动");
    }

    /**
     * 处理Entry列表,将变更数据发送到RabbitMQ
     */
    private void processEntries(List<CanalEntry.Entry> entries) {
        for (CanalEntry.Entry entry : entries) {
            if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) {
                continue;
            }
            try {
                CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
                CanalEntry.EventType eventType = rowChange.getEventType();
                String tableName = entry.getHeader().getTableName();

                for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
                    // 构造变更消息:包含表名、操作类型、变更前后的数据
                    Map<String, Object> message = new HashMap<>();
                    message.put("table", tableName);
                    message.put("eventType", eventType.name());

                    if (eventType == CanalEntry.EventType.DELETE) {
                        // 删除:取before数据(包含id),因为删除操作的after为null
                        message.put("before", parseColumns(rowData.getBeforeColumnsList()));
                    } else {
                        // INSERT / UPDATE:取after数据,插入操作的befor为Null
                        message.put("after", parseColumns(rowData.getAfterColumnsList()));
                     
                    }

                    // 发送到RabbitMQ
                    String json = JSON.toJSONString(message);
                    rabbitTemplate.convertAndSend(
                            MqConstants.EXCHANGE_NAME,
                            MqConstants.ROUTING_KEY,
                            json
                    );
                    log.info("发送Canal变更消息到MQ: table={}, eventType={}", tableName, eventType);
                }
            } catch (Exception e) {
                log.error("解析Canal Entry失败", e);
            }
        }
    }

    /**
     * 将Canal的Column列表转为Map
     */
    private Map<String, Object> parseColumns(List<CanalEntry.Column> columns) {
        Map<String, Object> map = new HashMap<>();
        for (CanalEntry.Column column : columns) {
            map.put(column.getName(), column.getValue());
        }
        return map;
    }

    /**
     * 监听RabbitMQ队列,接收Canal变更消息并同步到ES
     */
    @RabbitListener(queues = MqConstants.QUEUE_NAME)
    public void handleCanalMessage(String messageJson) {
        try {
            log.info("收到Canal变更消息: {}", messageJson);
            Map<String, Object> message = JSON.parseObject(messageJson, Map.class);
            String table = (String) message.get("table");
            String eventType = (String) message.get("eventType");

            // 只处理 product 表
            if (!"product".equals(table)) {
                return;
            }

            Map<String, Object> after = (Map<String, Object>) message.get("after");
            Map<String, Object> before = (Map<String, Object>) message.get("before");

            switch (eventType) {
                case "INSERT":
                case "UPDATE":
                    if (after != null) {
                        ProductDocument doc = new ProductDocument();
                        doc.setId(Long.valueOf((String) after.get("id")));
                        doc.setName((String) after.get("name"));
                        doc.setDescription((String) after.get("description"));
                        doc.setPrice(Double.valueOf((String) after.get("price")));
                        doc.setStock(Integer.valueOf((String) after.get("stock")));
                        esRepository.save(doc);
                        log.info("ES同步成功: {} id={}", eventType, doc.getId());
                    }
                    break;
                case "DELETE":
                    if (before != null) {
                        Long id = Long.valueOf((String) before.get("id"));
                        esRepository.deleteById(id);
                        log.info("ES删除成功: id={}", id);
                    }
                    break;
                default:
                    log.warn("未知事件类型: {}", eventType);
            }
        } catch (Exception e) {
            log.error("同步到ES失败", e);
        }
    }

    @PreDestroy
    public void stop() {
        running = false;
        if (canalConnector != null) {
            canalConnector.disconnect();
        }
        log.info("Canal监听线程已停止");
    }
}

 

canal监听Mysq-binlog日志,将增删改消息推送到mq,监听服务将数据变动同步到es

 

rabbitMQ如何保证消息可靠投递:

rabbitMQ整个消息投递路径为: producer->exchange->queue->consumer

1 生产者发送消息到交换机: 

配置: publisher-confirm-type: correlated, 代码: rabbitTemplate.convertAndSend用catch捕获发送失败的消息,correlationData.getFuture().addCallback //异步监听结果,
分为 isAck==true/false/调用超时

2 交换机根据路由键将消息路由到队列:

rabbitTemplate.setMandatory(true); rabbitTemplate.setReturnsCallback

3 交换机/队列/消息持久化:交换机/队列设置 durable=true,消息设置 DeliveryMode-(MessageDeliveryMode.PERSISTENT);默认就是,

4 消费者:channel.basicAck/channel.basicNack

TTL:

在RabbitMQ中,TTL(Time-To-Live,生存时间) 指的是消息或队列的“存活期限”。它定义了消息在未被消费的情况下,最多能在队列中存活多久。

当存活时间超过设定的TTL值后,消息就会变成“死信”(Dead Letter),随后会被丢弃或者转发到死信交换机(DLX) 做进一步处理。

TTL在RabbitMQ中分为消息的TTL和队列的TTL,它们的用途和机制截然不同:

设置消息过期时间

 MessageProperties messageProperties = new MessageProperties();
        //设置消息的过期时间3s
        messageProperties.setExpiration("3000");
        Message message = new Message("boot,hello".getBytes(),messageProperties);
        rabbitTemplate.convertAndSend(BootConfig.EXCHANGE_NAME,"boot.haha",message);

为特点:

  • 消息到达队列后开始计时。

  • RabbitMQ 采用惰性检查策略:只检查队列头部(最先到达)的消息是否过期。如果队头的消息没过期,即使队尾有消息已经过期,RabbitMQ 也不会去扫描和剔除它(这是出于性能考虑,避免扫描整个队列)。

  • 坑点:如果队头消息设置了很长的TTL(比如1小时),而队尾消息设置了很短的TTL(比如1秒),队尾的消息并不会在1秒后被立即删除!它必须等队头消息被消费或过期后,才能排到队头被检查。所以,消息级TTL并不保证消息在精确时间后被删除,只能保证在指定时间之后会被检查

设置队列过期时间

 @Bean("bootQueue")
    public Queue bootQueue(){
        return QueueBuilder.durable(QUEUE_NAME).ttl(5000).build();
    }

 

死信队列: 死信队列和死信交换机在创建时都是普通的队列和交换机,只是在其他队列声明时指定它的死信交换机是谁,routing key是谁

消息成为死信的三种情况: 1:队列长度或数据大小超过限制.2消费者拒绝接收消息(basicNack),并且不把消息重新放回原目标队列.3 队列设置了超时时间,消息达到时间未被消费 

设置死信队列代码

// 1 创建死信交换机
    @Bean("deadExchange")
    public Exchange deadExchange(){
        return ExchangeBuilder.topicExchange("dead_exchange").durable(true).build();
    }
// 3 死信队列和死信交换机绑定
    @Bean
    public Binding deadBindQueueExchange(@Qualifier("deadQueue") Queue queue, @Qualifier("deadExchange") Exchange exchange){
        return BindingBuilder.bind(queue).to(exchange).with("dead.#").noargs();
    }
// 2 创建队列,并绑定死信交换机和路由键
    @Bean("bootQueue")
    public Queue bootQueue(){
        return QueueBuilder.durable(QUEUE_NAME).deadLetterExchange("dead_exchange").deadLetterRoutingKey("dead.test").build();
    }

队列过期时间(TTL)+死信队列实现延时队列:消息先进一个 "死期队列"(TTL 队列),过期后自动进死信队列,消费者从死信队列里拿到的就是 "延迟 X 秒的消息"

@Bean
public Queue delayQueue() {
    return QueueBuilder.durable("delay.queue")
            .ttl(30000)                              // 30 秒后过期
            .deadLetterExchange("dlx.exchange")      // 过期后进 DLX
            .deadLetterRoutingKey("order.delay")     // 路由到延迟消费队列
            .build();
}

// 死信队列(真正的业务消费者监听这个)
@Bean
public Queue delayConsumerQueue() {
    return QueueBuilder.durable("delay.consumer.queue").build();
}

延迟队列的坑:RabbitMQ 只检查队头消息是否过期—— 如果队头是一条 60 秒的,后面 5 秒的被堵住,5 秒那条不会按时过期。要精确延迟,要么按延迟时间分级建多个 TTL 队列

 消息队列如何解决消息积压:消息积压基本都是因为消费者消费的慢导致的,基本不可能是生产者和mq本身问题,除非大促这种短时间内突然生产出大量消息,在程序设计之初就要保证消费者消费速度比生产者速度快,如果消息积压,排查消费者代码的方向:

是否在某个资源上卡住,是否有死锁,是否因为消息消费失败导致重复消费。解决方法还是先对消费者扩容,再排查代码。             

 
 
 
posted @ 2022-06-29 13:38  杨吃羊  阅读(104)  评论(0)    收藏  举报