前面几篇我们把 RabbitMQ 的入门、原理、进阶都讲了一遍。但知识是散的,这篇用一个完整的故事把它们串起来:一家电商公司从上线 RabbitMQ 到撑过双十一,踩过的所有坑和对应的解法。
故事按时间顺序推进,每一章都是真实开发中会遇到的问题。读完之后,你对持久化、可靠性、一致性、有序性、消息积压这五个概念的理解,会比背定义深刻得多。
引子:老王的团队
老王是一家电商公司的后端 Leader。公司业务不大,但订单量在稳步增长。
最初,订单系统是这样的:
java
public void createOrder(Order order) {
orderService.save(order); // 保存订单
stockService.deduct(order); // 扣库存
pointService.add(order); // 加积分
smsService.send(order); // 发短信
logisticsService.push(order); // 推物流
}
问题很快来了:
- 短信服务响应慢,用户下单要等 3 秒;
- 积分服务偶尔挂,整个下单接口跟着报错;
- 双十一那天,数据库被瞬间涌入的请求打爆了。
老王拍板:"上 RabbitMQ,把后面几件事都异步化!"
于是,一条订单消息的奇幻漂流,开始了。
第一章:半夜重启,订单全没了
出事了
上线一周后,运维半夜升级服务器,RabbitMQ 重启了一次。
第二天早上,运营发现:昨晚 8 点到 10 点的订单,库存都没扣。
老王吓出一身冷汗,赶紧排查。
定位原因
打开代码一看,队列是这么声明的:
java
@RabbitListener(queuesToDeclare = @Queue("order.queue"))
public void onMessage(Order order) {
// ...
}
@Queue 默认 durable=false,也就是队列不持久化。RabbitMQ 一重启,队列本身没了,里面的消息自然也全丢了。
怎么解决
RabbitMQ 要保证重启后消息还在,需要三个层次同时持久化,缺一不可:
| 层次 | 怎么配 | 类比 |
|---|---|---|
| Exchange 持久化 | durable=true |
分拣台图纸存档 |
| Queue 持久化 | durable=true |
货架位置登记在册 |
| Message 持久化 | delivery_mode=2 |
包裹贴上"重要文件"标签 |
老王改成了配置类显式声明:
java
@Configuration
public class RabbitConfig {
@Bean
public DirectExchange orderExchange() {
return ExchangeBuilder.directExchange("order.exchange")
.durable(true) // 交换机持久化
.build();
}
@Bean
public Queue orderQueue() {
return QueueBuilder.durable("order.queue").build(); // 队列持久化
}
@Bean
public Binding orderBinding() {
return BindingBuilder.bind(orderQueue())
.to(orderExchange())
.with("order.created");
}
}
发送时给消息打上持久化标记:
java
rabbitTemplate.convertAndSend(
"order.exchange",
"order.created",
order,
message -> {
// 消息持久化
message.getMessageProperties()
.setDeliveryMode(MessageDeliveryMode.PERSISTENT);
return message;
}
);
小王的一个疑问
实习生小王问:"三样都持久化了,是不是就 100% 不丢了?"
老王摇头:"还有一个坑。 消息进磁盘是批量刷盘的,不是每条都 fsync。如果节点在'消息已收到但还没落盘'的瞬间宕机,还是可能丢。"
"那怎么办?"
"用 Quorum 队列。它基于 Raft 协议,要多数派节点确认后才返回成功。代价是至少需要 3 个节点,而且不支持非持久化消息和优先级队列。"
java
@Bean
public Queue orderQuorumQueue() {
return QueueBuilder.durable("order.quorum.queue")
.withArgument("x-queue-type", "quorum") // 声明 Quorum 类型
.build();
}
第一章小结:持久化 = Exchange + Queue + Message 三层 durable,要更强的保证就上 Quorum。
第二章:消息发出去,库存少扣了几笔
又出事了
持久化问题解决后,安稳了两个月。
结果有一天,财务对账发现:有三笔订单,钱收了,库存没扣。
老王再次排查。这次不怪重启,是消息根本没到 Broker。
定位原因
看代码:
java
rabbitTemplate.convertAndSend("order.exchange", "order.created", order);
一行代码就发出去了,发完不管。如果当时网络抖动,消息在传输中丢了,生产者也不知道。
再往深处想,还有第二种情况:消息到了 Broker,但路由键写错了,Exchange 找不到队列,默认会直接丢弃,生产者同样不知道。
怎么解决
RabbitMQ 提供了两道确认机制,解决这两个问题:
- Publisher Confirm:消息是否到达 Broker?
- Publisher Return:消息到了 Broker,但路由不到队列,退回给生产者。
配置:
yml
spring:
rabbitmq:
publisher-confirm-type: correlated # 开启到达确认
publisher-returns: true # 开启无法路由退回
template:
mandatory: true # 强制退回无法路由的消息
配置类里加回调:
java
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory factory,
MessageConverter converter) {
RabbitTemplate template = new RabbitTemplate(factory);
template.setMessageConverter(converter);
template.setMandatory(true);
// 消息是否到达 Broker
template.setConfirmCallback((data, ack, cause) -> {
if (!ack) {
System.err.println("消息没到 RabbitMQ: " + cause);
// 落库重发、发告警
}
});
// 消息到了 Broker 但找不到队列被退回
template.setReturnsCallback(returned -> {
System.err.println("消息被退回: " + returned);
});
return template;
}
发送时带上 CorrelationData,方便回调里知道是哪条消息:
java
rabbitTemplate.convertAndSend(
"order.exchange",
"order.created",
order,
new CorrelationData(UUID.randomUUID().toString())
);
那消费端呢
老王又想到一层:消息到了队列,消费者取走后崩溃了怎么办?
自动 ACK 模式下,消息一投递就立刻从队列删除,消费者崩溃消息就没了。必须改成手动 ACK:
yml
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: manual # 手动确认
prefetch: 10 # 每个消费者最多 10 条未确认
java
@RabbitListener(queues = "order.queue")
public void onMessage(Order order,
Channel channel,
@Header(AmqpHeaders.DELIVERY_TAG) long tag)
throws IOException {
try {
stockService.deduct(order);
channel.basicAck(tag, false); // 业务成功才确认
} catch (Exception e) {
channel.basicNack(tag, false, false); // 失败拒绝,进死信
}
}
第二章小结:可靠性 = Producer Confirm + Return + 持久化 + Consumer 手动 ACK。缺一段,就会漏消息。
第三章:一个订单,扣了两次库存
又又出事了
一个用户投诉:他下了一单,库存被扣了两次。
老王一看日志,这条订单消息确实被消费了两次。但代码里只处理一次啊?
定位原因
消费者处理完业务后,还没来得及发 ACK,网络抖了一下,ACK 丢了。
RabbitMQ 等不到 ACK,以为消费者没处理完,就把消息重新投递了一次。
这就是 RabbitMQ 的语义: "至少一次"投递。它保证消息不丢,但不保证不重复。
一旦开启可靠投递和手动 ACK,重复就是必然的。 解决思路不是阻止重复,而是让重复不影响业务------这叫幂等。
怎么解决
方案一:数据库唯一索引(最常用)
sql
CREATE TABLE order_consume_log (
order_id VARCHAR(64) PRIMARY KEY, -- 订单号做主键
created_at DATETIME
);
java
try {
consumeLogMapper.insert(new ConsumeLog(order.orderId()));
stockService.deduct(order); // 插入成功才处理业务
} catch (DuplicateKeyException e) {
// 已经处理过,直接忽略
}
思路:用订单号当主键,第一次插入成功就处理;第二次插同一个订单号会抛异常,直接忽略。
方案二:Redis 去重
java
String key = "order:consumed:" + order.orderId();
Boolean success = redis.opsForValue()
.setIfAbsent(key, "1", 24, TimeUnit.HOURS);
if (Boolean.FALSE.equals(success)) {
return; // 已经处理过,跳过
}
stockService.deduct(order);
setIfAbsent 是"不存在才设置",天然去重。24 小时后自动过期,避免 Redis 无限增长。
方案三:业务状态机
java
if (order.getStatus() != OrderStatus.CREATED) {
return; // 状态已流转,说明处理过了
}
为什么三个方案都要
老王跟小王解释:"数据库唯一索引最可靠,但需要一次数据库写入;Redis 去重快,但 Redis 挂了就失效;业务状态机免费,但只适合有明确状态流转的场景。"
"实际项目中,通常会组合使用。比如 Redis 挡第一层,数据库唯一索引挡第二层。"
第三章小结:一致性 = 唯一标识 + 去重存储 + 业务可重放。重复不可怕,幂等就好。
第四章:下单和取消,顺序反了
又又又出事了
用户下单后又取消,结果系统报错:"订单状态异常:当前状态为 CANCELLED,无法执行支付。"
老王一看日志,取消消息竟然比下单消息先被消费了。
定位原因
RabbitMQ 的队列是 FIFO 的,为什么顺序会乱?
因为消费者并行消费。队列里消息一条条排好,但多个消费者同时取,谁先处理完谁先返回,完成顺序就乱了。
还有几种情况会乱序:
- 生产者多线程并发发送;
- 消息按 Topic 分散到多个队列,不同队列的消费者速度不一样;
- 消费者处理失败
requeue=true,消息回到队列尾部,插到了新消息后面。
怎么解决
方案一:全局有序(简单粗暴)
单队列 + 单消费者:
yml
spring:
rabbitmq:
listener:
simple:
concurrency: 1 # 只启动一个消费者
max-concurrency: 1
代价是吞吐量受限。只适合消息量不大但顺序要求严格的场景。
方案二:分组有序(推荐)
实际业务中,通常同一个订单的消息需要有序,不同订单之间不需要。可以按订单号哈希路由到不同队列,每个队列一个消费者:
java
// 按 orderId 哈希选择队列
int queueIndex = Math.abs(order.orderId().hashCode()) % 3;
String routingKey = "order.queue." + queueIndex;
rabbitTemplate.convertAndSend("order.exchange", routingKey, order);
这样:
- 同一个订单的消息始终进同一个队列,由一个消费者处理,保证有序;
- 不同订单分散到多个队列,并行处理,吞吐量不受影响。
方案三:不要用 requeue
消费失败时如果 requeue=true,消息会回到队列尾部,打乱顺序。建议设 requeue=false,让消息进死信队列,由单独的死信消费者按顺序处理。
第四章小结:有序性 = 单队列单消费者(强一致),或按业务 ID 分组路由(推荐)。
第五章:双十一,队列堆了 500 万条消息
大促来了
前面四章的问题都解决后,系统稳定运行了半年。
然后双十一来了。
零点一过,订单量瞬间暴涨 20 倍。半小时后,队列堆积了 500 万条消息,消费者处理不过来,用户下单后迟迟看不到扣库存的结果。
先定位原因
老王打开管理后台,发现:
- 队列长度持续增长;
- 消费者数量正常,但处理速度跟不上;
- 有大量 Unacked 消息堆积。
问题很明确:生产快,消费慢,中间队列被撑爆。
怎么解决
紧急止血:扩容消费者
yml
spring:
rabbitmq:
listener:
simple:
concurrency: 10 # 启动 10 个消费者线程
max-concurrency: 50 # 最多扩到 50 个
prefetch: 10 # 每个消费者最多 10 条未确认
同时暂停非关键生产者
让非核心业务(比如推荐、广告)先停掉消息发送,让订单主链路先消化。
优化消费者代码
老王的团队排查后发现,消费者里有一段慢逻辑:
java
// 优化前:串行执行
stockService.deduct(order); // 扣库存
smsService.send(order); // 同步发短信,慢!
pointService.add(order); // 加积分
改成:
java
// 优化后:核心操作同步,非核心操作异步
stockService.deduct(order); // 核心:同步执行
noticeProducer.send(order); // 非核心:再发一条消息到其他队列
长期优化
给队列设上限,避免无限堆积:
java
@Bean
public Queue orderQueue() {
return QueueBuilder.durable("order.queue")
.maxLength(100_000) // 最多 10 万条
.overflow(QueueBuilder.Overflow.rejectPublish) // 超过后拒绝新消息
.build();
}
启用惰性队列,消息直接写磁盘:
java
@Bean
public Queue orderLazyQueue() {
return QueueBuilder.durable("order.lazy.queue")
.lazy() // 惰性模式,消息直接进磁盘
.build();
}
上监控和告警
重点监控三个指标:
- 队列长度:持续增长说明消费跟不上;
- Unacked 数量:数量过多说明消费者卡住了;
- 消费者数量:掉到 0 说明消费者挂了。
超过阈值自动告警,及时介入。
第五章小结:消息积压 = 扩容消费者 + 生产者限流 + 优化消费逻辑 + 队列设上限 + 监控告警。
复盘:一条订单消息的安全旅程
把五章串起来,就是一条订单消息从生产到消费的完整旅程:

**五大问题的核心方案对照**:
| 问题 | 遇到的现象 | 核心方案 |
|---|---|---|
| 持久化 | 重启后消息全丢 | Exchange/Queue/Message 三层 durable |
| 可靠性 | 消息丢失、消费失败丢数据 | Confirm + Return + 手动 ACK + 死信 |
| 一致性 | 同一订单扣了两次库存 | 唯一标识 + 去重存储(幂等) |
| 有序性 | 取消消息比下单消息先处理 | 单队列单消费者 / 按 ID 分组路由 |
| 消息积压 | 双十一队列堆了 500 万条 | 扩容 + 限流 + 优化 + 监控 |
写在最后
这五章不是孤立的,它们是层层递进、相互关联的:
- 要做到可靠 ,必须先做到持久化;
- 要做到可靠 ,就一定会遇到重复 ,所以必须做幂等;
- 要做到有序 ,可能要牺牲吞吐;
- 要解决积压 ,可能又会打破有序。
每个方案都有取舍,关键是根据业务场景找到平衡点。
老王的团队最后总结出一句话,写在团队的技术规范里:
别把 RabbitMQ 当成"发消息的工具",把它当成"一套可靠投递的承诺"。你承诺什么,就要对应地做什么。
如果这个故事对你有帮助,欢迎点赞、收藏、关注。