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本身问题,除非大促这种短时间内突然生产出大量消息,在程序设计之初就要保证消费者消费速度比生产者速度快,如果消息积压,排查消费者代码的方向:
是否在某个资源上卡住,是否有死锁,是否因为消息消费失败导致重复消费。解决方法还是先对消费者扩容,再排查代码。
浙公网安备 33010602011771号