java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > SpringBoot RabbitMQ延迟任务

SpringBoot集成RabbitMQ实现可靠消息投递与延迟任务

作者:(farerboy)

这篇文章将详细对比Redis ZSET、TTL+死信队列、延迟插件等方案,给出SpringBoot集成完整代码和实战经验,帮你轻松实现订单超时自动关闭、库存释放,并保证消息不丢且处理可靠

先说说这需求

做电商或者本地生活的兄弟应该都不陌生,用户下单了,但不付款,你不能让他一直占着库存占着优惠券。常规操作就是给他一个支付时限,比如 15 分钟或者 30 分钟,超时了订单自动取消,然后把占用的库存释放掉,优惠券还给他,顺便再给他发个通知说“您的订单已超时取消”。

说白了,这就是个延迟任务。到了指定时间点,触发一个异步的动作。但这里面的关键点是,这个“到了时间才触发”的动作,得可靠,得及时,还得扛得住流量。

先聊聊大家最容易想到的笨办法

很多团队一上来就是定时任务轮询数据库。就是写个 @Scheduled,每 30 秒跑一次,扫一下订单表,把那些创建时间超过 15 分钟还没付款的订单捞出来,挨个关掉。

@Scheduled(cron = "0/30 * * * * ?")
public void scanAndClose() {
    LocalDateTime now = LocalDateTime.now();
    List<Order> expiredOrders = orderMapper.selectByStatusAndCreateTimeBefore("WAIT_PAY", now.minusMinutes(15));
    for (Order order : expiredOrders) {
        orderService.close(order);
    }
}

这个写法,怎么说呢,能用,但真不推荐。你想想,订单量一旦上来,这个高频的全表扫描对数据库就是一场灾难。而且实时性差,你 30 秒扫一次,最倒霉的订单就得等 30 秒才能被关闭。要是搞微服务多实例部署,每个实例都去扫,还得引入分布式锁,不然就是大家一起关同一个订单,各种并发问题就来了。如果一次扫出来的数据又多,处理又慢,那任务就全积压在那了,越积越多。

所以,这种轮询方案,业务量小还能忍,业务量一大基本就得换思路了。

那换什么方案?我跟你唠唠

市面上的方案看着挺多,其实绕不开那几种:

所以呢,目标很明确了,就用 RabbitMQ 的延迟消息插件。这玩意儿在保证消息可靠性的同时,又不会像 TTL+DLX 那样把我逼疯。

核心机制,稍微提一嘴

用 RabbitMQ 搞这种高可靠的消息,你就绕不开下面这几个东西。

消息怎么保证不丢

消息在网络上飘,哪一环断了都可能丢。所以得联动起来考虑:

TTL + DLX 是咋回事

当消息出现下面这仨情况之一时,它就变成“死信”(Dead Letter)了:

  1. 消费者用 basicNack 或者 basicReject 拒绝了消息,并且 requeue 设成 false。
  2. 消息在队列里呆的时间超过了 TTL,过期了。
  3. 队列满了,装不下了。

这时候,如果这个队列配置了“死信交换机(DLX)”,那这些死信就会被自动扔到 DLX 上,然后再由 DLX 路由到它绑定的死信队列里。

利用这个特性,我们就能实现延迟任务了:消息发到一个带着 TTL 的“等待队列”,没人消费它,等它一过期,就自动被扔进死信队列,这时候真正的消费者才从死信队列里把消息拉出来处理。

@Bean
public Queue waitQueue() {
    return QueueBuilder.durable("order.wait.queue")
            .ttl(15 * 60 * 1000)          // 15 分钟
            .deadLetterExchange("order.dlx.exchange")
            .deadLetterRoutingKey("close")
            .build();
}

之前说的,这做法的痛点就是 TTL 粒度太粗,得为每个延迟时间单独建队列。

还是延迟插件香

rabbitmq_delayed_message_exchange 这个官方插件就不用这么死板了。它的原理是,交换机收到消息后,如果带 x-delay 参数,就认为这是个延迟消息,先把它扣在交换机内部,等延迟时间到了,再根据 RoutingKey 路由到对应的队列。

这样,你就不需要搞一堆 TTL 队列了,一个交换机,就能支撑任意时长的延迟消息,很省心。唯一的额外要求就是得装插件。

代码实战,老哥们抄作业了

环境准备

前置条件是 RabbitMQ 上装好插件,命令很简单:

rabbitmq-plugins enable rabbitmq_delayed_message_exchange

代码层面就是引入两个依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

application.yml 里面关键配置:

spring:
  rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest
    publisher-confirm-type: correlated   # 开启发送确认
    publisher-returns: true              # 开启路由失败回调
    listener:
      simple:
        acknowledge-mode: manual         # 手动 ACK
        retry:
          enabled: true
          max-attempts: 3

配置延迟交换机和队列

声明一个类型为 x-delayed-message 的 CustomExchange,再声明一个消费者监听的普通队列,然后把它们绑起来。

@Configuration
public class RabbitConfig {

    // 延迟交换机
    @Bean
    public CustomExchange delayExchange() {
        Map<String, Object> args = new HashMap<>();
        args.put("x-delayed-type", "direct");   // 支持 direct / topic / fanout 等
        return new CustomExchange("order.delay.exchange", "x-delayed-message", true, false, args);
    }

    // 消费者真正监听的队列
    @Bean
    public Queue closeOrderQueue() {
        // 配置死信交换机,方便后续把处理失败的消息转走
        return QueueBuilder.durable("order.close.queue")
                .deadLetterExchange("order.dlx.exchange")
                .deadLetterRoutingKey("close.dead")
                .build();
    }

    // 绑定关系
    @Bean
    public Binding binding(Queue closeOrderQueue, CustomExchange delayExchange) {
        return BindingBuilder.bind(closeOrderQueue)
                .to(delayExchange)
                .with("order.close")
                .noargs();
    }

    // 死信队列,用于接收处理失败的消息,做最终兜底
    @Bean
    public Queue orderCloseDeadQueue() {
        return new Queue("order.close.dlq", true);
    }

    @Bean
    public Binding deadBinding() {
        return BindingBuilder.bind(orderCloseDeadQueue())
                .to(new TopicExchange("order.dlx.exchange"))
                .with("close.dead");
    }
}

发消息,给消息塞个延迟时间

订单创建成功后,就往延迟交换机里丢一条消息,路由键是 order.close,消息头里带上个 x-delay 参数,值就是延迟的毫秒数。这里我用 MessagePostProcessor 来设置延迟属性和持久化。

@Service
@Slf4j
public class OrderMessageSender {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    public void sendDelayCloseMessage(Long orderId, long delayMillis) {
        Map<String, Object> message = new HashMap<>();
        message.put("orderId", orderId);
        message.put("type", "ORDER_CLOSE");
        message.put("timestamp", System.currentTimeMillis());

        MessagePostProcessor processor = msg -> {
            // 关键点,设置延迟时间,单位毫秒
            msg.getMessageProperties().setDelay(Long.valueOf(delayMillis).intValue());
            msg.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT);
            return msg;
        };

        rabbitTemplate.convertAndSend(
                "order.delay.exchange",
                "order.close",
                message,
                processor);

        log.info("延迟关闭订单消息已发送, orderId={}, delay={}ms", orderId, delayMillis);
    }
}

为了确保消息能稳稳地发出去,还得加上确认回调。不然消息发丢了都不知道。

@PostConstruct
public void init() {
    rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
        if (!ack) {
            log.error("消息发送失败: {}, cause={}", correlationData.getId(), cause);
            // 可以把消息状态更新到 DB,或者搞个定时任务捞出来重发
        }
    });

    rabbitTemplate.setMandatory(true);
    rabbitTemplate.setReturnsCallback(returned -> {
        log.error("消息路由失败: exchange={}, routingKey={}, message={}",
                returned.getExchange(), returned.getRoutingKey(), returned.getMessage());
    });
}

消费端,这是重头戏

消费者这边要处理的细节比较多,主要是为了健壮性。

@Component
@Slf4j
public class OrderCloseConsumer {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private OrderService orderService;

    @RabbitListener(queues = "order.close.queue")
    public void onMessage(Message message, Channel channel) throws Exception {
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        String content = new String(message.getBody(), StandardCharsets.UTF_8);
        JSONObject json = JSONObject.parseObject(content);
        Long orderId = json.getLong("orderId");

        // 幂等校验,用 Redis 分布式锁或者 SETNX 都行
        String key = "order:close:" + orderId;
        Boolean first = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", Duration.ofHours(1));
        if (!Boolean.TRUE.equals(first)) {
            log.warn("重复消息,直接确认: orderId={}", orderId);
            channel.basicAck(deliveryTag, false);
            return;
        }

        try {
            boolean success = orderService.closeOrder(orderId);
            if (success) {
                channel.basicAck(deliveryTag, false);
                log.info("订单关闭成功: orderId={}", orderId);
            } else {
                // 业务上明确返回失败,比如订单已经被用户手动取消,这种就属于“预期内的失败”,
                // 直接 ack 算了,别再重试了,不然是浪费资源
                channel.basicAck(deliveryTag, false);
                log.warn("订单关闭失败(业务), 不再重试: orderId={}", orderId);
                stringRedisTemplate.delete(key); // 这里要记得删掉幂等标记
            }
        } catch (Exception e) {
            // 真正的异常情况,比如数据库连接挂了,这种可以重试
            long retryCount = message.getMessageProperties().getHeaders()
                    .getOrDefault("retryCount", 0) instanceof Integer ?
                    (Integer) message.getMessageProperties().getHeaders().get("retryCount") : 0;

            if (retryCount < 2) {
                log.warn("订单关闭失败,准备重试, orderId={}, retryCount={}", orderId, retryCount);
                // 这里我用的 requeue=true,让消息重新回到队头
                // 但要注意,重试次数我是放在 header 里的,requeue 回来 header 还会带着
                channel.basicNack(deliveryTag, false, true);
            } else {
                // 超过最大重试次数,拒收消息,让它进死信队列
                log.error("订单关闭失败,进入死信队列: orderId={}", orderId);
                channel.basicNack(deliveryTag, false, false);
            }
        }
    }
}

这个重试逻辑我是故意写简单了点,正常项目里可能会用 Spring Retry 模板来包装业务逻辑,或者定义个 OrderCloseMessage 带个 retryCount 字段。都行,看你能把代码设计得多优雅吧。

哦对,上面 retryCount 的写法有个坑。我用的是 requeue=true 让消息回到队列,但是消息回到队列后 header 并不会自动更新。所以你得在发送 nack 之前,把这个消息头给改了。这里我图省事儿没写,你可以用 MessageBuilder 再搞一下。要不还是建议用 basicNack + requeue=false,直接把消息丢到死信队列,然后让死信队列的消费者去处理重试。这样逻辑清晰点。

完整链路,看一眼心里有数

再来捋一下这条链路:

  1. 订单服务创建订单,往 order.delay.exchange 发延迟消息。
  2. 发送成功后,Broker 回 ack,订单服务知道消息稳了。
  3. 延迟交换机过了 15 分钟,把消息路由到 order.close.queue。
  4. OrderCloseConsumer 收到消息,开始处理。
  5. 先查 Redis 做幂等,确认这个订单没被处理过。
  6. 执行关单业务逻辑。
  7. 成功 -> ACK,完事儿。失败 -> 重试,或者进死信队列等人工干预。

如果不装插件,备选方案呢?

如果你的公司死都不让装插件,那咱也有退路。就是用 TTL + DLX。

原理很简单,发消息的时候不设置 x-delay,而是发到一个“等待队列”。这个等待队列的 TTL 设为 15 分钟,而且配置了死信交换机。消息在这等 15 分钟没人管,过期了,就被扔到死信交换机,路由到真正的工作队列。

@Bean
public Queue waitQueue() {
    return QueueBuilder.durable("order.wait.queue")
            .ttl(900000) // 15 分钟
            .deadLetterExchange("order.dlx.exchange")
            .deadLetterRoutingKey("close")
            .build();
}

这个方案代码复杂度没有增加多少,但就是“一个延迟时间一个队列”这个事儿挺膈应人的。你可以自己评估。

聊聊运行时的监控和排查

生产环境出了事儿,不能两眼一抹黑。

RabbitMQ 管理台看啥

打开那个 Web 管理台(http://host:15672),先看这几个地方:

告警和链路追踪

监控方面,用 Prometheus + RabbitMQ Exporter 是标配,在 Grafana 配个告警很容易。比如队列积压超过 5000,或者消费者掉线了,就告警。还有一个指标挺有价值,就是进入死信队列的消息数量。这个可以自己用 Micrometer 埋点统计一下,能反映出消息处理的健康度。

@Autowired
private MeterRegistry meterRegistry;

// 消费者异常时
meterRegistry.counter("order.close.failures", "queue", "order.close.queue").increment();

链路追踪的话,Spring Cloud Sleuth + Zipkin 是个经典组合。它可以把消息的生产和消费串到一个 TraceId 里。你查日志的时候,用这个 TraceId 一搜,整条链路的日志全出来了,效率会高不少。

// 发送时,把 TraceId 塞进去
message.getMessageProperties().setHeader("traceId", traceIdGenerator.generate());
// 消费时,从消息里取出来放到 MDC
String traceId = message.getMessageProperties().getHeader("traceId");
MDC.put("traceId", traceId);

结语,随便唠叨两句

延迟消息这东西,用好了确实很爽。订单超时关闭只是其中一个很常见的场景,其他的像支付超时、活动自动上下线、退款超时提醒、优惠券到期提醒,都是同一个套路。

RabbitMQ 延迟插件 + Spring Boot,在做企业级应用的时候,算是最快能落地、社区资料最多的方案了。如果你技术选型从一开始就是往 RabbitMQ 靠,那用这个插件绝对没错。

但你也得明白,这方案不是万能的。延迟插件不是 MQ 自带的,装的时候要注意版本兼容问题。另外,它的延迟时间单位是毫秒,对于业务来说这个精度是足够了,但要真上到金融级别的秒级甚至毫秒级调度,这玩意儿还是不够看,你可能要考虑时间轮算法或者更专业的调度系统。

还有一点,消息可靠性和不重复消费,这是两个没法同时达到完美的指标。你只能通过合理的重试机制和业务上的幂等设计,去找到一个合适的平衡点。

行了,废话不多说。希望这篇文章能帮你把这块儿的思路捋清楚,后面遇到类似需求,能少踩几个坑。

以上就是SpringBoot集成RabbitMQ实现可靠消息投递与延迟任务的详细内容,更多关于SpringBoot RabbitMQ延迟任务的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文