一个订单的奇幻漂流:RabbitMQ 五大难题实战

前面几篇我们把 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 当成"发消息的工具",把它当成"一套可靠投递的承诺"。你承诺什么,就要对应地做什么。

如果这个故事对你有帮助,欢迎点赞、收藏、关注

相关推荐
她的男孩1 小时前
账号锁定配了"错 4 次锁 30 分钟",我连错 100 次一次没锁上:扒完 4091 行认证源码,找到 5 个坑
java·后端·架构
Methy1 小时前
一次线上死锁排查:std::list::size () 居然是 O (n)?
c++·后端
MacroZheng1 小时前
Redis官方发布高颜值可视化工具,功能更是强的离谱!
java·redis·后端
回家路上绕了弯1 小时前
为什么 AI 写代码时,总喜欢“防御性编程”?
后端
灯澜忆梦2 小时前
【Redis中间件】#4 | 进阶数据类型语法
redis·后端
广州山泉婚姻2 小时前
前后端分离的核心优势
前端·后端
雪芽蓝域zzs2 小时前
第 4 章:Postman 安装与基础使用
spring boot
binqian2 小时前
【java】Java 两种锁机制与线程阻塞唤醒原理
jvm·spring boot·spring
IT_陈寒2 小时前
Vue的响应式让我加班到凌晨,问题竟出在这个不起眼的地方
前端·人工智能·后端