前言
做分布式系统,几乎绕不开消息队列。但 MQ 不是银弹------它换来解耦和异步的同时,也把"消息丢失、重复、最终一致性"这些问题甩给了你。所以这篇文章不堆概念,而是按"价值 → 选型 → 收发 → 可靠性"的实战顺序,把 SpringAMQP 这条链路串清楚。
一、为什么用消息队列(MQ)
直接用同步调用(比如 HTTP / RPC)会撞上三个问题:
- 耦合高:下游每加一个消费者,上游就得改一次代码。
- 响应慢:多个下游串行调用,延迟不断叠加。
- 级联故障:链路里一个服务挂了,整条调用链跟着崩。
引入 MQ 后,由中间人(Broker)转发消息,带来四个收益:
- 解耦:上下游只认队列,不认彼此。
- 异步提速:主流程不阻塞在下游调用上。
- 故障隔离:下游挂了自己慢慢恢复,不拖垮上游。
- 削峰填谷:流量高峰先堆进队列,消费者按自己节奏消化。
代价 :链路变长,你必须自己处理消息丢失、重复消费、最终一致性。后面第八章专门讲这块。
二、主流 MQ 选型
表格
| MQ | 特点 | 适用场景 |
|---|---|---|
| Kafka | 吞吐最高 | 日志收集、大数据管道 |
| RocketMQ | 金融级可靠、事务消息 | 强一致性交易链路 |
| RabbitMQ | 稳定、低延迟、文档全、好排查,万级 TPS | 中小系统的业务异步解耦 |
RabbitMQ 的优势是"稳 + 好上手 + 易排查",对绝大多数中小业务的异步解耦场景是首选。
三、核心概念与安装
核心链路:
bash
生产者 → 交换机(Exchange) → 路由键(Routing Key) → 绑定(Binding) → 队列 → 消费者
要点:生产者永远把消息发给交换机,由交换机按路由规则投递到队列,生产者不直接对队列发消息。
安装 :用 Docker 一行命令即可启动。开启管理插件后访问 15672 端口使用 Web 控制台。
虚拟主机(vhost) :用于数据隔离,类似 namespace,不同业务用不同 vhost;内置 default。
四、SpringAMQP 收发消息
发送消息
1. 引入 amqp 依赖
bash
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
<!-- 这里的 version 要跟自己的 SpringBoot 版本对应 -->
<version>2.7.18</version>
</dependency>
2. 在 yml 中配置 RabbitMQ 服务端信息
bash
spring:
rabbitmq:
host: 127.0.0.1 # RabbitMQ 服务器 IP
port: 5672 # 注意:客户端连接用 5672
username: guest # 用户名
password: 123 # 密码
注意端口区分:客户端连接用 5672 ;
15672是浏览器访问管理后台、调用管理接口用的。
3. 注入 RabbitTemplate 发送
bash
@Slf4j
@SpringBootTest
class SpringAmqpTest {
@Autowired
private RabbitTemplate rabbitTemplate;
@Test
public void testSimpleQueue() {
// 1. 队列名
String queueName = "simple.queue";
// 2. 消息
String message = "hello, spring amqp!";
// 3. 发送消息
rabbitTemplate.convertAndSend(queueName, message);
}
}
接收消息
声明一个类加上 @Component 注册成 Bean,方法加 @RabbitListener 成为监听器。接收参数的类型与发送类型对应,对象也能传,Spring 会自动完成对象与消息之间的转换,我们只管处理信息。
bash
@Slf4j
@Component
public class SpringRabbitListener {
@RabbitListener(queues = "simple.queue")
public void listenSimpleQueue(Message message) {
log.info("监听到 simple.queue 的消息:【{}】", new String(message.getBody()));
log.info("消息 ID:【{}】", message.getMessageProperties().getMessageId());
}
}
五、Work Queues 与公平分发
多个消费者绑定同一队列时,一条消息只会被其中一个消费者处理,用来提升消费速度、缓解积压。
- 默认分发是轮询(round-robin),不看消费者快慢,会造成"忙者更忙"。
- 把
prefetch(basicQos)设为 1 并配合手动 ack,Broker 才会优先把消息发给空闲消费者,实现"能者多劳"。
六、交换机类型与路由机制
三种交换机的差别,本质是"用什么规则把消息投递到队列"。
1. 广播交换机的 Fanout Exchange
- 把消息发送到所有绑定的队列,无视路由键。
- 实现一对多分发,类似广播。
应用:实时消息发布、通知系统等需要"一条消息发给多个消费者"的场景。
发送时填三个参数:交换机房、Routing Key、消息,其中 Routing Key 可以为空。
bash
@Test
public void testFanoutQueue() {
// 1. 交换机房名
String exchangeName = "hmall.fanout";
// 2. 消息
String message = "hello everyone!";
// 3. 发送消息(Fanout 的路由键可为空)
rabbitTemplate.convertAndSend(exchangeName, "", message);
}
路由键 :用于把消息路由到指定队列的关键字。
绑定关系指"队列绑定到交换机房":Direct / Topic 绑定时带路由键,Fanout 不带。
2. 定向交换机的 Direct Exchange
- 根据消息的路由键,投递到与之完全匹配的队列。
- 适合一对一传递,如日志处理、任务分发,需要确保消息精确投递时使用。
3. 话题交换机的 Topic Exchange
- 根据路由键与绑定键的模式匹配投递。
- 用通配符扩展性更强:
*代表一个词 ,#代表一个或多个词。 - 适合主题订阅模型,如邮件分类、日志级别过滤。
七、声明式配置与消息转换器
有两种声明交换机房 / 队列 / 绑定的方式。
方式一:Bean 声明
创建配置类,把队列、交换机房、绑定关系分别声明为 Bean,返回对应对象或用工厂类构造(推荐),启动后会自动创建对应资源。
Queue:声明队列,用QueueBuilder构建。Exchange:声明交换机房,用ExchangeBuilder构建。Binding:声明绑定关系,用BindingBuilder构建。
注意:
Exchange只是接口,具体实现是各类交换机房,如FanoutExchange、DirectExchange、TopicExchange。
bash
@Configuration
public class FanoutConfiguration {
@Bean
public FanoutExchange fanoutExchange() {
return ExchangeBuilder.fanoutExchange("TH.fanout").build();
}
@Bean
public Queue fanoutQueue1() {
return QueueBuilder.durable("TH.queue1").build();
}
@Bean
public Binding bindFanoutQueue1(FanoutExchange fanoutExchange, Queue fanoutQueue1) {
return BindingBuilder.bind(fanoutQueue1)
.to(fanoutExchange)
.with("routingKey");
}
}
方式二:@RabbitListener 注解声明
直接在监听器上用 bindings 一次性声明队列、交换机房和绑定:
bash
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "dlx.queue", durable = "true"),
exchange = @Exchange(name = "dlx.exchange", type = ExchangeTypes.DIRECT),
key = {"hi"}
))
public void listenDlxQueue(String message) {
log.info("消费者监听到 dlx.queue 的消息:【{}】", message);
}
value = @Queue(...):定义队列属性。exchange = @Exchange(...):指定关联的交换机房。key = {"hi"}:设置绑定的路由键。
方式三:Jackson2JsonMessageConverter 配置与对象消息收发
当业务需要发送 Java 对象(如订单、用户实体)时,默认的 SimpleMessageConverter 只能处理字符串和字节数组,无法完成对象与 JSON 的互转。此时需要配置 Jackson2JsonMessageConverter,让发送端把对象序列化为 JSON 写入消息体,接收端再反序列化回对应类型。
1. 通过 @Bean 注册到 RabbitTemplate
在配置类中声明 MessageConverter 的 Bean,Spring Boot 会自动把它注入到 RabbitTemplate 和监听容器中,无需手动调用 setMessageConverter():
java
@Configuration
public class RabbitMqConfig {
@Bean
public MessageConverter messageConverter(ObjectMapper objectMapper) {
Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter(objectMapper);
// 自动为每条消息生成 messageId,便于幂等去重
converter.setCreateMessageIds(true);
return converter;
}
}
如果项目没有引入 spring-boot-starter-web,需要单独添加 Jackson 依赖:
xml
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
2. 发送对象消息
定义订单实体类,发送时直接传入对象,转换器会自动完成序列化:
java
public class OrderDTO {
private Long orderId;
private String userId;
private BigDecimal amount;
// 省略 getter / setter
}
java
@Service
public class OrderSender {
@Autowired
private RabbitTemplate rabbitTemplate;
public void sendOrder(OrderDTO order) {
// 直接发送对象,Jackson2JsonMessageConverter 自动序列化为 JSON
rabbitTemplate.convertAndSend("order.exchange", "order.create", order);
}
}
3. 消费者接收对象消息
接收端监听方法直接声明对象类型参数,转换器会自动反序列化回 OrderDTO:
java
@Slf4j
@Component
public class OrderListener {
@RabbitListener(queues = "order.queue")
public void handleOrder(OrderDTO order) {
log.info("收到订单消息:orderId={}, userId={}, amount={}",
order.getOrderId(), order.getUserId(), order.getAmount());
// 执行业务逻辑
}
}
要点:发送端和接收端必须使用同一个 Jackson2JsonMessageConverter(即两个服务都扫描到该配置类),否则接收端无法正确反序列化。若消息体带 __TypeId__ 头,转换器会据此还原具体类型;跨服务传输时建议保持实体类的包路径一致,或通过 setTypePrecedence 调整类型解析策略。
八、消息可靠性
消息在 MQ 链路上有三段会丢:发送端 → Broker、MQ 存储、Broker → 消费端。逐段处理。
8.1 发送端可靠性
8.1.1 发送者重连
网络波动会导致发送者连接 MQ 失败,可在 yml 开启发送重试:
bash
spring:
rabbitmq:
connection-timeout: 1s
template:
retry:
enabled: true
initial-interval: 1000ms
multiplier: 1
max-attempts: 3 # 实际 yml 注意缩进,否则会报错
表格
| 参数 | 含义 | 本配置效果 |
|---|---|---|
| connection-timeout: 1s | 建立 TCP 连接超时时间 | 1 秒连不上即失败 |
| template.retry.enabled: true | 是否开启发送重试 | 开启生产者发送重试 |
| initial-interval: 1000ms | 首次失败后等待多久重试 | 失败先等 1 秒 |
| multiplier: 1 | 重试间隔倍数 | 每次间隔不增长,都是 1 秒 |
| max-attempts: 3 | 最多尝试几次(含首次) | 最多 3 次:第 1 次 + 重试 2 次 |
弊端 :同步阻塞拖慢业务线程、可能重复投递、只解决瞬时抖动、参数配错会放大影响,而且不替代生产者确认和消费端幂等。
8.1.2 发送者确认机制(Publisher Confirm + Return)
为解决"消息到底有没有到达 Broker",引入两半机制:
- Publisher Confirm :确认消息是否到达 Exchange。
- Publisher Return :确认消息是否从 Exchange 到达 Queue。
发送者与 Exchange 之间是投递 关系,Exchange 与 Queue 之间是路由关系。
整体判定规则:
- 消息投递到 MQ 但路由失败 → 先 Publisher Return 返回路由异常原因,再返回 ACK(多因自身代码问题,重发也大概率失败,故算投递成功)。
- 临时消息投递到 MQ 且入队成功 → 返回 ACK。
- 持久消息投递到 MQ 且完成持久化 → 返回 ACK。
- 其他情况一律返回 NACK。
配置 Confirm 与 Return(发送端 yml):
bash
spring:
rabbitmq:
publisher-confirm-type: correlated # 开启 confirm 回调
publisher-returns: true # 开启 return 回调
template:
mandatory: true # 路由失败时触发 return
publisher-confirm-type 三种模式:
表格
| 模式 | 含义 | 是否推荐 |
|---|---|---|
| NONE | 关闭 confirm | 不推荐 |
| SIMPLE | 同步阻塞等待 MQ 回执 | 低吞吐场景可用 |
| CORRELATED | 异步回调返回每条消息确认结果 | 生产推荐 |
注册 ReturnCallback(全局注册一次,捕获路由失败的消息):
bash
@Slf4j
@Configuration
public class RabbitConfig {
@Bean
public RabbitTemplateCustomizer rabbitTemplateCustomizer() {
// 返回 RabbitTemplate 的定制器,增强内置 RabbitTemplate
return template -> {
// 双保险:即使 yml 没配,也强制 mandatory
// 消息已到 Exchange 但无法路由到任何 Queue 时,Broker 退回给生产者而非丢弃
template.setMandatory(true);
// confirm 回调:ack / nack
template.setConfirmCallback((correlationData, ack, cause) -> {
String id = correlationData == null ? null : correlationData.getId();
if (ack) {
log.info("publisher confirm ack, id={}", id);
} else {
log.error("publisher confirm nack, id={}, cause={}", id, cause);
}
});
// return 回调:消息无法路由时触发
template.setReturnsCallback(returned -> log.error(
"publisher return, code={}, text={}, exchange={}, routingKey={}, message={}",
returned.getReplyCode(), returned.getReplyText(),
returned.getExchange(), returned.getRoutingKey(), returned.getMessage()));
};
}
// 一般放在项目 config 包下,和启动类同级或子级即可
}
发送消息并关联 confirm 结果:
bash
@Service
public class MessageSender {
private final RabbitTemplate rabbitTemplate;
public MessageSender(RabbitTemplate rabbitTemplate) {
this.rabbitTemplate = rabbitTemplate;
}
public void send(String payload) {
String messageId = UUID.randomUUID().toString();
CorrelationData correlationData = new CorrelationData(messageId);
rabbitTemplate.convertAndSend(
"biz.exchange",
"biz.key",
payload,
message -> {
message.getMessageProperties().setMessageId(messageId);
message.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT);
return message;
},
correlationData
);
// 异步获取该消息的 confirm 结果
correlationData.getFuture().whenComplete((confirm, ex) -> {
if (ex != null) {
log.error("confirm error, id={}", messageId, ex);
return;
}
if (!confirm.isAck()) {
log.error("confirm nack, id={}, reason={}", messageId, confirm.getReason());
return;
}
// 注意:ack 不代表一定路由成功,还要看是否被 return
if (correlationData.getReturned() != null) {
log.error("message returned, id={}, returned={}", messageId, correlationData.getReturned());
return;
}
log.info("message accepted and routed, id={}", messageId);
});
}
}
CorrelationData 是发送方与 MQ 之间用来标记一次发送的对象。
@PostConstruct 标记方法在 Bean 实例化、依赖注入完成后自动执行一次。
ApplicationContextAware 让 Bean 能拿到
ApplicationContext从而手动getBean,用于绕过某些版本的循环依赖;实际开发中用@RequiredArgsConstructor声明要注入的 Bean、自动生成构造器即可。
实际开发场景:发送消息 + 本地消息表 + 定时兜底
主线程业务(订单与消息记录同事务,事务提交后再发):
bash
@Transactional
public void createOrderAndNotifyStock(OrderDTO order) {
// 1. 订单入库(状态=待确认)
orderService.save(order);
// 2. 本地消息表入库(状态=PENDING)------ 与订单同事务
String msgId = IDUtil.getSnowflakeNextIdStr(); // 雪花算法生成全局唯一 id
MessageRecord record = MessageRecord.pending(msgId, order.getOrderId(),
"inventory.exchange", "stock.reduce", buildPayload(order));
messageRecordMapper.insert(record);
// 3. 事务提交后再发消息(避免消息先到、DB 未提交导致消费方查不到)
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
doSend(record);
}
});
}
doSend 把消息发到 MQ,并根据 Broker 的 ACK/NACK 更新消息记录状态:
bash
private void doSend(MessageRecord record) {
CorrelationData cd = new CorrelationData(record.getMsgId());
cd.getFuture().addCallback(new ListenableFutureCallback<>() {
@Override public void onFailure(Throwable ex) {
log.error("消息发送失败 msgId={}", record.getMsgId(), ex);
messageRecordMapper.markStatus(record.getMsgId(), "EXCEPTION");
}
@Override public void onSuccess(CorrelationData.Confirm r) {
// 回执有两种:ACK(已到达 Exchange) / NACK(未到达 Exchange)
messageRecordMapper.markStatus(record.getMsgId(), r.isAck() ? "SUCCESS" : "FAIL");
}
});
try {
rabbitTemplate.convertAndSend(
record.getExchange(), record.getRoutingKey(), record.getPayload(), cd);
} catch (Exception e) {
log.error("send 抛异常 msgId={}", record.getMsgId(), e);
messageRecordMapper.markStatus(record.getMsgId(), "SEND_EXCEPTION");
}
}
定时任务兜底重发:
bash
@Scheduled(fixedDelay = 30_000)
public void retryFailedMessages() {
List<MessageRecord> list = messageRecordMapper.findRetryable(3, 50);
for (MessageRecord r : list) {
if (r.getRetryCount() >= MAX_RETRY) {
alertService.notifyOps(r); // 告警 + 人工介入
continue;
}
doSend(r);
messageRecordMapper.incrRetry(r.getMsgId());
}
}
8.2 MQ 端可靠性:持久化
为什么需要持久化? RabbitMQ 把消息存在内存里以降低收发延迟,这会带来两个问题:① MQ 宕机时消息全丢;② 内存有限,消费慢或故障时消息堆积引发堵塞。
- 交换机房持久化:交换机房元数据被保存,默认持久化,也可建临时交换机房。
- 队列持久化 :队列元数据被保存。注意它只保证队列结构不变,重启后队列里的消息仍会丢失;默认持久化,也可建临时队列。
- 消息持久化 :协议层默认非持久化 ;但 SpringAMQP 发送的消息默认是持久化的,也可自定义构造非持久化消息。
Spring AMQP 常用构造器有三类:ExchangeBuilder、QueueBuilder、BindingBuilder,配合 Binding / Exchange / Queue 的构造方法和 @RabbitListener 使用。
MessageBuilder 构造消息本身:
bash
Message message = MessageBuilder
.withBody("订单创建成功".getBytes(StandardCharsets.UTF_8)) // 消息体
.setContentType(MessageProperties.CONTENT_TYPE_TEXT_PLAIN) // 内容类型
.setDeliveryMode(MessageDeliveryMode.PERSISTENT) // 持久化
.setHeader("source", "order-service") // 自定义头
.setHeader("traceId", UUID.randomUUID().toString()) // 自定义头
.setExpiration("60000") // 过期时间
.setPriority(5) // 优先级,越大越优先
.build(); // 构建
rabbitTemplate.send("order.direct", "order.create", message);
当生产者开启了 Publisher Confirm、发送的是持久化消息、队列也是持久化队列时,RabbitMQ 会在消息成功写入所有匹配队列、且持久化消息完成持久化后,才回 ACK。
Lazy Queue(惰性队列)
Lazy Queue 收到消息后直接存入磁盘,不常驻内存;消费时才从磁盘读取并加载到内存(可预缓存部分消息到内存,最多 2048 条)。
RabbitMQ 3.12 之后经典队列默认就按类似 Lazy 的方式运行 ,x-queue-mode=lazy 会被忽略,无法切回旧的"非 lazy"模式。也就是说只要 RabbitMQ ≥ 3.12,创建的队列默认就类似 Lazy Queue;旧版本才需要手动设置为 Lazy Queue。
bash
// 1. SpringAMQP:QueueBuilder(旧版本手动 lazy)
@Bean
public Queue lazyQueue() {
return QueueBuilder.durable("lazy.queue")
.lazy() // 等价于 x-queue-mode=lazy
.build();
}
// 2. SpringAMQP:@RabbitListener
@RabbitListener(queuesToDeclare = @Queue(
name = "lazy.queue",
durable = "true",
arguments = @Argument(name = "x-queue-mode", value = "lazy")))
public void listenLazyQueue(String msg) {
// 处理消息
}
8.3 接收端可靠性
消费者确认机制
确认消费者是否成功处理消息,处理完向 MQ 发回执:
- ACK:成功处理,MQ 删除该消息。
- NACK:处理失败,MQ 需再次投递。
- REJECT:处理失败并拒绝,MQ 删除该消息。
Spring AMQP 的 acknowledge-mode 有三种:
- NONE:不确认,投递后直接删除。
- AUTO:Spring 自动管理。方法正常返回 ACK;抛异常不 ACK,消息可能重投。
- MANUAL :手动确认,必须自己调用
basicAck / basicNack / basicReject。有业务侵入,但更灵活。
生产环境尤其是订单、支付、一致性场景,通常用手动确认。
bash
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: manual # 手动确认
prefetch: 1 # 每次只预取一条,处理完再拉下一条
业务异常要 catch 或走死信 / 落库,否则不 ack 会触发无限重投(毒消息刷屏)。
失败重试机制
Spring AMQP 提供消费者本地重试,异常时在本地重试而不是无限投回 MQ:
bash
spring:
rabbitmq:
listener:
simple:
retry:
enabled: true # 开启重试
initial-interval: 1000ms # 第一次重试间隔
multiplier: 1 # 失败后重试间隔倍数
max-attempts: 3 # 最大重试次数
stateless: true # true 无状态;业务含事务设 false
耗尽重试仍失败,用 MessageRecoverer 处理,有三种实现:
- RejectAndDontRequeueRecoverer:直接 reject 丢弃(默认)。
- ImmediateRequeueMessageRecoverer:返回 nack,消息重新入队。
- RepublishMessageRecoverer :把失败消息投递到指定交换机房(推荐)。
bash
@Configuration
@ConditionalOnProperty(
prefix = "spring.rabbitmq.listener.simple.retry",
name = "enabled",
havingValue = "true")
public class RabbitMQRecoverConfig {
@Bean
public DirectExchange errorExchange() {
return new DirectExchange("error.direct");
}
@Bean
public Queue errorQueue() {
return new Queue("error.queue", true);
}
@Bean
public Binding errorBinding(Queue errorQueue, DirectExchange errorExchange) {
return BindingBuilder.bind(errorQueue).to(errorExchange()).with("error");
}
@Bean
public MessageRecoverer messageRecoverer(RabbitTemplate rabbitTemplate) {
return new RepublishMessageRecoverer(
rabbitTemplate,
"error.direct", // 失败消息投递到的交换机房
"error"); // 路由键
}
}
@ConditionalOnProperty由 Spring 提供,根据配置属性值决定是否加载某 Bean / 配置类:prefix前缀、name属性名、havingValue必须精确等于的值、matchIfMissing(默认 false,配置缺失时条件是否成立)。
8.4 业务幂等性
幂等性:同一业务执行一次或多次,对业务状态的影响一致。
为什么必须做幂等? RabbitMQ 是 at-least-once(至少一次) 投递语义,重复不可避免;MQ 本身没有内置幂等 ,幂等必须由业务在消费端实现。核心思路都是 唯一标识 + 去重。
方案一:每条消息带唯一 Id
每条消息生成唯一 id 随消息投递,消费者处理成功后把 id 落库;再收到相同 id 就放弃处理。
bash
// 生产者为消息打唯一 id
rabbitTemplate.convertAndSend(exchange, routingKey, payload, message -> {
// 优先用业务唯一键(如订单号),没有就用 UUID
message.getMessageProperties().setMessageId(orderNo);
return message;
});
去重方案 1:数据库唯一索引
建一张只含主键 msg_id 的记录表。消费者先插一条,成功即首次消费继续业务;插入抛 DuplicateKeyException 说明已处理,直接 ACK 跳过。
bash
@Service
public class OrderConsumer {
@Autowired
private MsgLogMapper msgLogMapper;
@Autowired
private OrderService orderService;
@RabbitListener(queues = "order.queue")
@Transactional(rollbackFor = Exception.class)
public void handle(Message message) {
String msgId = message.getMessageProperties().getMessageId();
try {
msgLogMapper.insert(msgId); // 唯一约束:重复插入会抛 DuplicateKeyException
} catch (DuplicateKeyException e) {
log.info("消息 {} 已消费,跳过", msgId);
return; // 方法正常返回 → 自动 ACK
}
// 执行业务(与去重记录同事务,一起提交 / 回滚)
orderService.process(message.getBody());
}
}
去重方案 2:Redis SETNX
bash
@RabbitListener(queues = "order.queue")
public void handle(Message message, Channel channel,
@Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException {
String msgId = message.getMessageProperties().getMessageId();
if (msgId == null) { return; }
String key = "mq:consume:" + msgId;
// SET key 1 NX EX 86400 ------ 原子操作
Boolean first = redisTemplate.opsForValue().setIfAbsent(key, "1", Duration.ofDays(1));
if (Boolean.FALSE.equals(first)) {
log.info("重复消息 {},直接 ACK", msgId);
channel.basicAck(tag, false);
return;
}
try {
orderService.process(message.getBody());
channel.basicAck(tag, false);
} catch (Exception e) {
// 业务失败:删除去重标记,让消息重投时能再次处理
redisTemplate.delete(key);
channel.basicNack(tag, false, true);
}
// 若 basicAck 抛异常(如连接断开)消息会重投,但 Redis 标记仍在 → 重投消息被正确跳过
}
去重方案 3:状态机 / 状态前置校验
复杂业务对象有明确、不可逆的状态流转(订单:待支付→已支付→已发货→已完成;文件:新建→编辑中→已审核)。用"状态机"思维做安全校验:
bash
int rows = orderMapper.updateStatusPaid(orderNo);
if (rows == 0) {
// 已经处理过或状态不对,幂等返回
log.info("订单 {} 状态非待支付,忽略重复消费", orderNo);
return;
}
去重方案 4:乐观锁 / 版本号
MyBatis-Plus 的 Lambda 链式更新:
bash
// UPDATE t_order SET status = 2, pay_time = '当前时间' WHERE id = #{orderId} AND status = 1;
orderService.lambdaUpdate()
.set(Order::getStatus, 2) // 设置为已支付
.set(Order::getPayTime, LocalDateTime.now()) // 记录支付时间
.eq(Order::getId, orderId) // 锁定订单
.eq(Order::getStatus, 1) // 只有未支付才能更新(防重)
.update(); // 返回影响行数
Message 类与关键属性
bash
public class Message {
private final byte[] body; // 消息体:二进制数据
private final MessageProperties messageProperties; // 消息属性:元数据
}
messageProperties 常用:
getMessageId():之前生成的 messageId(幂等去重)。getContentType():消息体类型(如application/json)。getDeliveryTag():极其重要,手动确认(ACK/NACK)的标签。getTimestamp():发送时间。getReceivedExchange()/getReceivedRoutingKey():来自哪个交换机房与路由键。
8.5 配置消息转换器
RabbitMQ 底层传字节流,发送实体类要配消息转换器(发送端 + 接收端都配)。发送端 toMessage() 把 Java 对象序列化写入消息体并设 content-type;接收端 fromMessage() 反序列化回 Java 对象。
bash
// 若项目已有 spring-boot-starter-web,一般不必再单独引 Jackson;否则引 jackson-databind
@Configuration
public class RabbitMqConfig {
@Bean
public MessageConverter messageConverter(ObjectMapper objectMapper) {
Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter(objectMapper);
converter.setCreateMessageIds(true);
return converter;
}
}
只要这个 Bean 在两个服务里都被扫描到,RabbitTemplate 和监听容器会自动使用同一个转换器,无需手动设置。
8.6 死信队列(DLX)实战配置
死信交换机(DLX)与死信队列的作用 :当消息在业务队列中满足死信条件(消费失败且 requeue = false、消息过期、队列堆积溢出)时,RabbitMQ 不会直接丢弃,而是把消息转发到该队列指定的死信交换机(Dead Letter Exchange),再由死信交换机按路由键投递到死信队列。死信队列专门用于收集这些异常消息,方便后续排查、人工补偿或二次处理,避免消息静默丢失。
1. 通过 @Bean 声明死信交换机、死信队列及绑定关系
在配置类中分别声明死信交换机、死信队列,并把业务队列通过 dead-letter-exchange 和 dead-letter-routing-key 属性关联到死信交换机:
java
@Configuration
public class DlxConfig {
// 1. 死信交换机(Direct 类型)
@Bean
public DirectExchange dlxExchange() {
return new DirectExchange("dlx.exchange");
}
// 2. 死信队列
@Bean
public Queue dlxQueue() {
return new Queue("dlx.queue", true);
}
// 3. 死信队列绑定到死信交换机,路由键为 error
@Bean
public Binding dlxBinding(Queue dlxQueue, DirectExchange dlxExchange) {
return BindingBuilder.bind(dlxQueue).to(dlxExchange).with("error");
}
// 4. 业务队列:声明死信交换机与死信路由键
@Bean
public Queue businessQueue() {
return QueueBuilder.durable("business.queue")
.deadLetterExchange("dlx.exchange") // 指定死信交换机
.deadLetterRoutingKey("error") // 死信消息使用的路由键
.build();
}
// 5. 业务交换机(可选,用于正常投递)
@Bean
public DirectExchange businessExchange() {
return new DirectExchange("business.exchange");
}
// 6. 业务队列绑定到业务交换机
@Bean
public Binding businessBinding(Queue businessQueue, DirectExchange businessExchange) {
return BindingBuilder.bind(businessQueue).to(businessExchange).with("business.key");
}
}
2. 将消费失败的消息转发到死信队列
消费者处理失败时,通过 basicNack 或 basicReject 并设置 requeue = false,消息就会进入死信交换机,再被投递到死信队列:
java
@Slf4j
@Component
public class BusinessListener {
@RabbitListener(queues = "business.queue")
public void handle(Message message, Channel channel,
@Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException {
try {
// 业务处理逻辑
process(message);
// 处理成功,手动确认
channel.basicAck(tag, false);
} catch (Exception e) {
log.error("业务处理失败,消息转入死信队列,msgId={}",
message.getMessageProperties().getMessageId(), e);
// requeue = false:不重回业务队列,直接进入死信交换机
channel.basicNack(tag, false, false);
}
}
private void process(Message message) {
// 模拟业务处理
}
}
3. 消费死信队列中的消息
单独写一个监听器消费死信队列,用于告警、落库或人工补偿:
java
@Slf4j
@Component
public class DlxListener {
@RabbitListener(queues = "dlx.queue")
public void handleDlx(Message message) {
log.error("收到死信消息:msgId={}, body={}",
message.getMessageProperties().getMessageId(),
new String(message.getBody()));
// 落库记录、发送告警或人工介入处理
}
}
要点:死信交换机与死信队列的声明方式与普通交换机、队列完全一致,核心在于业务队列必须通过 deadLetterExchange 属性指向死信交换机;消费失败时务必设置 requeue = false,否则消息会无限重投而无法进入死信队列。
九、延迟消息与死信
延迟消息:发送时指定一个时间,消费者不会立刻收到,而是在指定时间后才收到。可用于延时任务。
死信(dead letter):队列中消息满足下列任一情况即成为死信:
- 消费者用
basic.reject/basic.nack声明消费失败且requeue = false; - 消息是过期消息(达到队列或消息本身设置的过期时间),超时无人消费;
- 要投递的队列堆积满了,最早的消息可能成为死信。
这些消息经死信交换机房(DLX) 转入死信队列,用于排查与人工补偿。
DLX 死信交换机房 :若队列通过 dead-letter-exchange 属性指定了一个交换机房,该队列的死信就会投递到这个交换机房。可利用该机制实现延迟消息。
延迟插件 :rabbitmq_delayed_message_exchange 插件可把普通交换机房改造为支持延迟消息的交换机房,消息投递后可暂存一定时间、到期再投递到队列(Docker 安装配置插件见 RabbitMQ 安装部分)。
延迟消息两种做法:
- TTL + DLX:传统方案,存在精度与队头阻塞问题。
- 延迟插件:更直观。
典型场景:订单创建后超时未支付则自动关闭。
结语
一条完整的可靠消息链路,本质是三段加固的叠加:
- 发送端:confirm + return + 本地消息表 + 定时兜底,保证"消息一定到 Exchange、路由一定进 Queue"。
- MQ 端:交换机房 / 队列 / 消息三者都持久化(必要时用 Lazy Queue)。
- 消费端:手动 ack + 本地重试 + 失败 Republish + 业务幂等。
把这三段都想清楚,RabbitMQ 才真正从"能发消息"进化成"消息不会丢、不会乱、重复了也不出错"。
写在最后
这一篇从"为什么用 MQ"写到了"可靠性三段加固",是我在真实项目里踩坑后整理的,建议 收藏,做订单 / 支付 / 异步解耦时随时回来对照。