RabbitMQ 最佳实践

基于(调整代码等):RabbitMQ 最佳实践

在使用消息机制时,需要重点关注以下几个问题:

  • 消息不能丢失。
  • 保证消息一定能投递到目的地。
  • 保证业务处理与消息发送/消费之间的一致性。

本文将以RabbitMQ为例,详细探讨如何解决上述问题。

消息持久化

如果希望RabbitMQ重启后消息不丢失,那么需要对以下三种实体进行持久化配置:

  • exchange(交换机)
  • queue(队列)
  • message(消息)

声明exchange时,应设置durable属性为trueautoDelete属性为false(默认值):

@Configuration
public class RabbitBaseConfig {
    @Bean
    public TopicExchange dlxExchange() {
        // durable = true, autoDelete = false(默认值)
        return new TopicExchange("dlx", true, false);
    }
}

声明queue时,应设置durable属性为trueautoDelete属性为false(默认值):

@Configuration
public class RabbitQueueConfig {
    @Bean
    public Queue orderSummaryQueue() {
        // durable = true, autoDelete = false(默认值)
        return QueueBuilder.durable("order-summary-queue")
                .build();
    }
}

发送消息时,需确保消息被标记为持久化(即deliveryMode设置为2)。可以通过MessagePostProcessorMessageBuilder来设置消息的deliveryMode

// 通过 MessagePostProcessor 设置
public void sendOrderCreatedEvent(String payload) {
    rabbitTemplate.convertAndSend("order", "order.created", payload, message -> {
        message.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT);
        return message;
    });
}
// 通过 MessageBuilder 设置
public void sendOrderCreatedEvent2(String content) {
    Message message = MessageBuilder
            .withBody(content.getBytes(StandardCharsets.UTF_8))
            .setDeliveryMode(MessageDeliveryMode.PERSISTENT)
            .build();
    rabbitTemplate.convertAndSend("order", "order.created", message);
}

发送确认

有时,业务处理成功后,消息可能因网络等原因未能成功到达RabbitMQ服务器。如果出现业务成功而消息发送失败的情况,发送方将面临数据不一致的问题。此时,可以使用RabbitMQ的发送确认功能,即要求RabbitMQ显式通知消息是否已成功发送到服务器。

可以通过配置publisher-confirm-type属性为correlated并结合RabbitTemplateConfirmCallback来启用发送确认。配置示例如下:

spring:
  rabbitmq:
    # ...
    publisher-confirm-type: correlated

并在配置类中设置回调:

@Bean
public RabbitTemplate.ConfirmCallback confirmCallback(RabbitTemplate rabbitTemplate) {
    RabbitTemplate.ConfirmCallback callback = (correlationData, ack, cause) -> {
        String id = correlationData != null ? correlationData.getId() : "UNKNOWN";
        if (ack) {
            log.info("{}: 消息已成功到达 Broker,可标记为【已发送】", id);
            // TODO: 更新 DB 中消息状态为“已发送”
        } else {
            log.warn("{}: 消息发送失败,原因:{}", id, cause);
            // TODO: 重试 or 标记为失败
        }
    };
    rabbitTemplate.setConfirmCallback(callback);
    return callback;
}

发送消息时可携带CorrelationData对象来关联回调:

public void sendWithConfirm(String routingKey, String payload) {
    CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());
    rabbitTemplate.convertAndSend("order", routingKey, payload, message -> {
        message.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT);
        return message;
    }, correlationData);
}

当消息正常投递时,RabbitMQ客户端将异步调用回调方法,acktrue,表示消息已成功投递。此时,程序可以自行处理投递成功后的逻辑,例如在数据库中将消息状态更新为已发送。当消息投递出现异常时,ackfalse,表示消息投递失败。

通常来说,发送端仅需保证消息能够发送到exchange,而无需关注消息是否被正确地投递到某个queue;后者是RabbitMQ和消息接收方需要关注的问题。基于此,即使RabbitMQ找不到任何需要投递的queue,它仍然会ack给发送方。此时发送方可以认为消息已正确投递,而无需关注消息没有队列接收的问题。然而,对于RabbitMQ而言,这种无法投递的消息是需要记录下来的,否则RabbitMQ将直接丢弃。为此,可以为exchange设置alternate-exchange属性,表示RabbitMQ将把无法投递到任何queue的消息发送到alternate-exchange指定的exchange中。

可以在exchange声明时设置:

@Bean
public TopicExchange orderExchange() {
    Map<String, Object> args = new HashMap<>();
    args.put("alternate-exchange", "dlx");
    return new TopicExchange("order", true, false, args);
}

另外,如果exchange存在但找不到任何接收queue,并且发送时设置了mandatory属性为true,那么在消息被ack前,消息将被return给客户端,触发ReturnsCallback回调:

spring:
  rabbitmq:
    # ...
    publisher-returns: true
    template:
      mandatory: true
@Bean
public RabbitTemplate.ReturnsCallback returnCallback() {
    return returnedMessage -> {
        String exchange = returnedMessage.getExchange();
        String routingKey = returnedMessage.getRoutingKey();
        log.warn("消息无法路由:exchange={}, routingKey={}, replyCode={}, replyText={}",
                exchange, routingKey,
                returnedMessage.getReplyCode(), returnedMessage.getReplyText());
        // 注意:return 后仍会触发 confirm ack,所以通常无需 mandatory=true
    };
}

需要注意的是,即使消息被return,它最终仍会被ack而非nack。因此,不如设置mandatory=false,让消息流转到alternate-exchange

此外,如果exchange不存在,此种情况会nack,触发ReturnsCallback回调。

综上所述,要实现发送方确认,需要完成以下几个方面:

  • 设置ConfirmCallback
  • 通过publisher-confirm-type开启确认机制。
  • exchange设置alternate-exchange指向死信交换机(DLX)。
  • 发送时通常没有必要设置mandatory
  • 发送方将消息记录在数据库中,收到ack时在数据库中标记消息为已发送状态。
  • 如果收到reject或因网络原因未收到ack,消息状态将不会改变,下次发送时会再次发送。此时可能导致消息重复,解决重复问题请参考“保证至少一次投递,并实现消费端幂等性”章节。

手动消费确认

有时,消息被正确投递到消费方,但消费方处理失败,则可能导致消费方数据不一致的问题。例如,订单已创建的消息发送到用户积分子系统用于增加用户积分,但积分消费处理逻辑却失败了。用户可能会困惑:为什么购买了商品积分却没有增加?

要解决这个问题,需要引入消费方确认机制,即只有消息被成功处理后才告知RabbitMQ进行ack,否则进行nack。此时的处理流程如下:

  1. 接收消息后不立即ack。处理消息成功则ack,不成功则nack
  2. 对于nack的消息,可以配置RabbitMQ重新投递、直接丢弃,或将其传输到死信交换机(DLX)。
  3. 如果处理成功,但由于网络等问题导致确认(无论是ack还是nack)不成功,那么RabbitMQ会重新投递消息。此时由于消息已成功处理,重新投递将导致消费重复。请参考“保证至少一次投递,并实现消费端幂等性”章节以解决重复问题。

通过设置acknowledge-mode属性为manual来开启手动确认模式,由消费方自行决定何时应该ack

spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: manual  # 手动 ack
        retry:
          enabled: false          # 禁用 Spring 内置重试,由业务控制

也可以在消费端使用@RabbitListener注解设置acknowledge-mode属性:

@Component
public class OrderSummaryConsumer {
  @RabbitListener(queues = "order-summary-queue", ackMode = "MANUAL")
  public void onMessage(Message message, Channel channel) throws IOException {
    // 处理消息
  }
}

消费端代码示例:

@Component
public class OrderSummaryConsumer {

    private final Logger logger = LoggerFactory.getLogger(getClass());

    @RabbitListener(queues = "order-summary-queue")
    public void onMessage(Message message, Channel channel) throws IOException {
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        try {
            if (success()) { // 模拟业务处理成功
                logger.info("成功消费消息 {}", deliveryTag);
                channel.basicAck(deliveryTag, false);
            } else { // 模拟业务处理失败
                if (!message.getMessageProperties().getRedelivered()) {
                    logger.warn("首次消费消息 {} 不成功,尝试重试", deliveryTag);
                    channel.basicNack(deliveryTag, false, true); // 重新投递
                } else {
                    logger.warn("第二次消费消息 {} 不成功,扔到 DLX", deliveryTag);
                    channel.basicNack(deliveryTag, false, false); // 不重新投递,发送到 DLX
                }
            }
        } catch (Exception e) { // 消费异常
            if (!message.getMessageProperties().getRedelivered()) {
                logger.warn("首次消费消息 {} 异常,尝试重试", deliveryTag);
                channel.basicNack(deliveryTag, false, true); // 重新投递
            } else {
                logger.warn("第二次消费消息 {} 异常,扔到 DLX", deliveryTag);
                channel.basicNack(deliveryTag, false, false); // 不重新投递,发送到 DLX
            }
            throw e;
        }
    }

    private boolean success() {
        return ThreadLocalRandom.current().nextBoolean();
    }
}

可以看到,在acknowledge-mode属性为manual的情况下,通过业务处理结果(由success()方法模拟)来决定进行acknack操作。

另外,为了避免消息反复requeue的情况,如果消息第一次消费不成功,则在nack时设置requeue=true,表示告知RabbitMQreject的消息重新投递。如果第二次消费依然不成功,那么在nack时设置requeue=false,告知RabbitMQ不要重新投递了。此时RabbitMQ将根据其自身配置要么直接丢弃消息,要么将消息发送到死信交换机(DLX)中。具体配置方法请参考“设置死信交换(DLX)和死信队列(DLQ)”章节。

保证至少一次投递,并实现消费端幂等性

通常来说,程序中会先完成写入数据库的操作,然后发送消息。此时,关键在于确保这两者操作的一致性,即一旦数据库保存成功,消息也必须能够发送成功。要保证发送方一致性,一种做法是使用全局事务,即将数据库操作和消息发送纳入同一个事务管理,例如使用JTA。然而,全局事务通常开销较大,并且RabbitMQ目前并不直接支持全局事务。

为了解决发送方一致性问题,可以实现将消息保存到数据库的事件表中。此时,业务处理的数据库操作和消息保存到数据库属于同一个本地数据库事务,从而保证了业务处理和消息产生的原子性。然后,有一个异步后台任务从数据库事件表中定时读取未发送的消息,并将其发送至RabbitMQ。消息发送成功后,更新事件表中消息的状态为已发布

然而,此时我们仍无法保证消息发送和状态更新操作之间的原子性,因为可能发生消息发送成功但数据库状态更新不成功的情况。为了解决这种极端情况,可以多次重试消息发送,步骤如下:

  1. 读取事件表中未发送消息,发送到RabbitMQ
  2. 如果发送成功,事件表中消息状态也更新成功,操作成功完成。
  3. 如果消息发送不成功,那么消息状态也不做改变,下次重试。
  4. 如果消息发送成功而状态更新不成功,下次仍重试。

通过持续重试,最终可实现消息发送与状态更新的原子性。

那么问题也来了:RabbitMQ中可能出现重复消息,此时消费端可能会感到困惑。为了解决这个问题,消费方应该设计为幂等的,即对相同消息的多次消费与单次消费结果相同。有些消费方的业务逻辑本身便是幂等的。而对于本身不幂等的消费方,需要在数据库中记录已经被正确消费的消息。当重复消息到来时,判断该消息是否已经被消费,如果没有则执行消费逻辑,如果已经消费则直接忽略。此时,即使忽略,仍需进行消费成功确认。消费方的处理步骤如下:

  1. 接收到消息,判断消息是否已经消费。如果是,则直接忽略,但仍需进行消费成功确认。
  2. 如果消息还未被消费,则处理业务逻辑,并记录消息。业务逻辑本身和记录消息在同一个数据库事务中。如果都成功,操作成功完成;如果失败,那么消费方业务回滚,消息也不记录,此时reject消息,等待下次重发。

示例如下:

@Service
@Transactional
public class OrderService {

    @Autowired
    private OrderRepository orderRepo;
    @Autowired
    private OutboxEventRepository eventRepo; // 事件表
    @Autowired
    private ApplicationEventPublisher eventPublisher;

    public void createOrder(OrderDTO dto) {
        // 1. 本地业务
        Order order = orderRepo.save(new Order(dto));

        // 2. 写入事件表(同一事务)
        OutboxEvent event = new OutboxEvent("order.created", 
                UUID.randomUUID().toString(), 
                order.getId(), 
                toJson(dto));
        eventRepo.save(event);

        // 3. 发布本地事件,触发异步发送
        eventPublisher.publishEvent(new OrderCreatedEvent(event.getId()));
    }
}

// 异步发送器(监听本地事件)
@Component
public class OutboxEventSender {

    @Autowired
    private RabbitTemplate rabbitTemplate;
    @Autowired
    private OutboxEventRepository eventRepo;

    @EventListener
    @Async
    public void onOrderCreated(OrderCreatedEvent event) {
        OutboxEvent outbox = eventRepo.findById(event.getEventId()).orElse(null);
        if (outbox == null || "PUBLISHED".equals(outbox.getStatus())) return;

        try {
            CorrelationData cd = new CorrelationData(outbox.getEventId());
            rabbitTemplate.convertAndSend("order", "order.created", outbox.getPayload(), 
                msg -> {
                    msg.getMessageProperties().setMessageId(outbox.getEventId());
                    msg.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT);
                    return msg;
                }, cd);

            // 注意:此处不能直接 update status!应等待 confirm 回调再更新
            // 实际应:在 confirmCallback 中更新 DB 状态
        } catch (Exception e) {
            log.error("发送事件失败", e);
            // 状态不变,后续补偿任务重试
        }
    }
}

// 补偿任务(定时扫描未发送事件)
@Component
@EnableScheduling
public class OutboxEventScheduler {

    @Autowired
    private OutboxEventRepository eventRepo;

    @Scheduled(fixedDelay = 30_000) // 每 30 秒
    public void retryUnsentEvents() {
        List<OutboxEvent> unsent = eventRepo.findByStatus("PENDING");
        unsent.forEach(event -> {
            // 触发重发(如调用 eventPublisher)
        });
    }
}

设置消息的 TTL 和消息队列的 max-length

为了保证消息的时效性,可以设置队列中消息的x-message-ttl属性。为了避免消息队列过大而影响性能,可以设置队列的最大消息数x-max-length属性。在创建队列时设置如下:

@Bean
public Queue orderSummaryQueueWithTtl() {
    return QueueBuilder.durable("order-summary-queue")
            .withArgument("x-max-length", 300)
            .withArgument("x-message-ttl", 24 * 60 * 60 * 1000)
            .build();
}

设置死信交换(DLX)和死信队列(DLQ)

对于无法投递的消息,我们需要将其记录下来以便后续跟踪和排查。此时,可以将这样的消息放入死信交换机(DLX)和死信队列(DLQ)中。默认情况下,queue中被抛弃的消息将被直接丢弃。但是,可以通过设置queuex-dead-letter-exchange参数,将被抛弃的消息发送到该参数所指定的exchange中,这样的exchange被称为死信交换机(DLX)。

当队列配置了x-dead-letter-exchange后,在以下三种情况下,消息将被投递到死信交换机(DLX)中:

  1. 消费方nack时指定了requeue=false
  2. 消息的TTL已到期。
  3. 消息队列的max-length已达到上限。

在声明queue时定义x-dead-letter-exchange,通常还会定义x-dead-letter-routing-key

@Bean
public Queue orderNotificationQueue() {
    return QueueBuilder.durable("order-notification-queue")
            .withArgument("x-dead-letter-exchange", "dlx")
            .withArgument("x-dead-letter-routing-key", "order.notification.dlq")
            .build();
}
  • 建议将死信队列(DLQ)设置为lazy模式,消息一进入队列,就直接写磁盘,且不设置TTLmax-length,以确保所有死信消息都能被持久存储和处理。

需要注意的是,在发送消息时,当已经达到queue的上限,并且queue定义为x-overflow属性设置为reject-publish时,RabbitMQnack该消息。当有多个queue同时绑定到exchange时,如果有些queue设置了reject-publish,而有些却没有,那么依然会nack,这会给发送方带来处理上的复杂性。因此,最好不要使用该类型,这样发送方仅需确保消息能正确投递到exchange,而无需关注exchange后端连接了哪些队列。

设置 Prefetch count

Prefetch count表示消费方一次性从RabbitMQ获取的消息数量。如果设置过大,消费方可能持续处于高负荷状态;如果设置过小,则会增加不必要的网络开销。通常建议设置为 20-50。为确保多个消费方公平地分摊消息处理任务,通常会将prefetch count设置为 1。

spring:
  rabbitmq:
    listener:
      simple:
        prefetch: 1   # 每次只取 1 条,确保公平分发(可调)

异常处理

在以上设置的情况下,我们来看看当各种异常发生时,RabbitMQ是如何运作的:

  • Broker不可达:直接抛出异常。
  • 发送方自己始终发送不出去:消息状态将始终保持为“未发送”,不会破坏一致性;但事件表中若累计过多事件,则需引起关注。
  • Exchange不存在:消息将被丢弃,RabbitMQ不会ack。消息状态将保持为“未发送”,下次会重新发送,不会破坏一致性;但如果exchange持续不存在,事件表中的事件也将大量累积。
  • Exchange存在但没有接收queue:消息将被ack并标记为“已发送”,但由于已设置alternate-exchangedlx,消息将发送到dlx对应的死信队列(DLQ)中,以便后续处理。
  • Consumer不在线,而累积消息太多:消息一致性不受影响。但当消息累积达到max-length上限时,队列头部的消息将被放置到死信队列(DLQ)中,以便后续处理。
  • Consumer临时性失败:通过redelivered属性判断是否为重复投递。如果是,则nack并设置requeue=false,表示如果重复投递的该消息再次失败,那么直接扔到dlx中。这意味着消息最多只会被重复投递一次。
  • Consumer始终失败:所有消息均被投入死信队列(DLQ)以便后续处理。此时可能需要关注死信队列(DLQ)的长度是否过长。

路由策略

系统中往往会发布多种类型的消息,在发送时有几种路由策略:

  • 所有类型的消息都发送到同一个exchange中。
  • 每种类型的消息都单独配置一个exchange
  • 对消息类型进行归类,同一类型的消息对应一个exchange

建议采用最后一种策略,并结合领域驱动设计(DDD)中的聚合划分原则。路由策略建议如下:

每一个聚合根下发布的所有类型事件对应一个exchangeexchange设置为topic类型。queue可以配置接收某一种类型的事件,也可以配置接收所有与某种聚合相关的事件,还可以配置接收所有事件。

案例

假设有一个订单(Order)系统,用户下单后需要向用户发送短信通知。所有对订单数据的显示都采用了CQRS架构,即将订单的读模型和写模型分离。这意味着所有订单的更新都通过事件发送到RabbitMQ,然后专门有一个consumer接收这些消息用于更新订单的读模型。

  • 订单相关有两个事件:order.createdorder.updated
  • 所有与订单相关的事件都发布到同一个topic exchange中,exchange名为"order"
  • 设置短信通知队列(order-notification-queue)只接收order.created消息,因为只有订单新建时才会发出通知,即order-notification-queue的路由键为order.created
  • 设置读模型的队列(order-summary-queue)接收所有与Order相关的消息,即配置order-summary-queue的路由键为order.#

示例代码如下。

配置文件

spring:
  rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest
    virtual-host: /
    publisher-confirm-type: correlated
    publisher-returns: false
    template:
      mandatory: false
    listener:
      simple:
        acknowledge-mode: manual
        retry:
          enabled: false

配置类(含 DLX/DLQ/Exchange/Queue/Binding)

@Slf4j
@Configuration
public class RabbitMQConfig {

    /* ---------- Exchanges ---------- */

    // DLX
    @Bean
    public TopicExchange dlxExchange() {
        return ExchangeBuilder
                .topicExchange("dlx")
                .durable(true)
                .build();
    }

    // order exchange + Alternate Exchange
    @Bean
    public TopicExchange orderExchange() {
        return ExchangeBuilder
                .topicExchange("order")
                .durable(true)
                .withArgument("alternate-exchange", "dlx")
                .build();
    }

    /* ---------- Queues ---------- */

    // DLQ(lazy)
    @Bean
    public Queue dlq() {
        return QueueBuilder
                .durable("dlq")
                .withArgument("x-queue-mode", "lazy")
                .build();
    }

    // order-summary-queue
    @Bean
    public Queue orderSummaryQueue() {
        return QueueBuilder
                .durable("order-summary-queue")
                .withArgument("x-dead-letter-exchange", "dlx")
                .withArgument("x-overflow", "drop-head")
                .withArgument("x-max-length", 300)
                .withArgument("x-message-ttl", 24 * 60 * 60 * 1000)
                .build();
    }

    // order-notification-queue
    @Bean
    public Queue orderNotificationQueue() {
        return QueueBuilder
                .durable("order-notification-queue")
                .withArgument("x-dead-letter-exchange", "dlx")
                .withArgument("x-overflow", "drop-head")
                .withArgument("x-max-length", 300)
                .withArgument("x-message-ttl", 24 * 60 * 60 * 1000)
                .build();
    }

    /* ---------- Bindings ---------- */

    @Bean
    public Binding dlqBinding() {
        return BindingBuilder
                .bind(dlq())
                .to(dlxExchange())
                .with("#");
    }

    @Bean
    public Binding orderSummaryBinding() {
        return BindingBuilder
                .bind(orderSummaryQueue())
                .to(orderExchange())
                .with("order.#");
    }

    @Bean
    public Binding orderNotificationBinding() {
        return BindingBuilder
                .bind(orderNotificationQueue())
                .to(orderExchange())
                .with("order.created");
    }

    @Bean
    public RabbitTemplate.ConfirmCallback confirmCallback(RabbitTemplate rabbitTemplate) {
        RabbitTemplate.ConfirmCallback callback = (correlationData, ack, cause) -> {
            String id = correlationData != null ? correlationData.getId() : "UNKNOWN";
            if (ack) {
                log.info("消息发送成功 correlationId={}",
                        correlationData != null ? correlationData.getId() : null);
            } else {
                log.warn("消息发送失败 correlationId={}, cause={}",
                        correlationData != null ? correlationData.getId() : null,
                        cause);
            }
        };
        rabbitTemplate.setConfirmCallback(callback);
        return callback;
    }
}

消息发送服务

@Service
public class OrderMessageSender {

    private final RabbitTemplate rabbitTemplate;

    public OrderMessageSender(RabbitTemplate rabbitTemplate) {
        this.rabbitTemplate = rabbitTemplate;
    }

    public void send() {
        rabbitTemplate.convertAndSend(
                "order",
                "order.created",
                "create order data",
                new CorrelationData("order-created-1")
        );

        rabbitTemplate.convertAndSend(
                "order",
                "order.updated",
                "update order data",
                new CorrelationData("order-updated-1")
        );
    }
}

消费服务(幂等 + 手动 ACK)

@Slf4j
@Component
public class OrderSummaryListener {

    private final Random random = new Random();

    @RabbitListener(queues = "order-summary-queue")
    public void onMessage(Message message, Channel channel) throws IOException {

        long tag = message.getMessageProperties().getDeliveryTag();
        boolean redelivered = message.getMessageProperties().isRedelivered();

        if (random.nextBoolean()) {
            log.info("成功消费消息 {}", tag);
            channel.basicAck(tag, false);
        } else {
            if (!redelivered) {
                log.warn("首次失败,重试 {}", tag);
                channel.basicNack(tag, false, true);
            } else {
                log.warn("第二次失败,丢入 DLX {}", tag);
                channel.basicNack(tag, false, false);
            }
        }
    }
}

测试类(验证用)

@SpringBootTest
class OrderMessageSenderTest {

    @Autowired
    private OrderMessageSender orderMessageSender;

    @Test
    void send() {
        orderMessageSender.send();
    }
}
posted @ 2025-12-16 11:04  Higurashi-kagome  阅读(20)  评论(0)    收藏  举报