SpringBoot集成RabbitMQ实现可靠消息投递与延迟任务
作者:(farerboy)
先说说这需求
做电商或者本地生活的兄弟应该都不陌生,用户下单了,但不付款,你不能让他一直占着库存占着优惠券。常规操作就是给他一个支付时限,比如 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 秒才能被关闭。要是搞微服务多实例部署,每个实例都去扫,还得引入分布式锁,不然就是大家一起关同一个订单,各种并发问题就来了。如果一次扫出来的数据又多,处理又慢,那任务就全积压在那了,越积越多。
所以,这种轮询方案,业务量小还能忍,业务量一大基本就得换思路了。
那换什么方案?我跟你唠唠
市面上的方案看着挺多,其实绕不开那几种:
- Redis ZSET 延迟队列:用
ZSET的score存过期时间戳,搞个轮询去取。Redis 性能是杠杠的,但问题在于可靠性差。没有消息确认机制,消费端挂了或者 Redis 本身出点问题,消息就可能丢了,而且也不好排查。对我们这种强一致性的订单业务来说,太冒险了。 - RabbitMQ TTL + 死信队列:这个是 RabbitMQ 原生机制,靠谱。利用队列的 TTL 属性和死信交换机,消息过期了自动转投到另一个队列。但缺点是,每你要一种延迟时间,就得建一个队列。比如我业务里有 15 分钟关单、30 分钟自动确认收货、1 小时提醒,就得建 3 个队列,这谁顶得住?管理成本太高了。
- RabbitMQ 延迟消息插件:这玩意儿是真的方便。它提供了一个新的交换机类型(
x-delayed-message),消息发过来的时候带上个x-delay参数,交换机就帮你算好时间,到点了再路由到队列里。一个交换机就能搞定所有延迟时间,灵活的一批。唯一的缺点就是得装个插件。
所以呢,目标很明确了,就用 RabbitMQ 的延迟消息插件。这玩意儿在保证消息可靠性的同时,又不会像 TTL+DLX 那样把我逼疯。
核心机制,稍微提一嘴
用 RabbitMQ 搞这种高可靠的消息,你就绕不开下面这几个东西。
消息怎么保证不丢
消息在网络上飘,哪一环断了都可能丢。所以得联动起来考虑:
- 生产者端:开启
publisher-confirm-type: correlated,消息发到 Broker 它会给你回个ack,要是没回或者回了nack,你就得想办法重发。还有个publisher-returns,就是消息路由不到任何队列的时候(比如 RoutingKey 写错了),Broker 会回调告诉你一声,这很重要。 - 持久化:队列得是
durable的,发消息的时候消息体也要标记成PERSISTENT,这样就算 RabbitMQ 整个挂掉重启了,消息也还在。 - 消费者端:用手动确认(Manual Ack)。处理完了再
ack,处理失败了咱不ack,根据情况决定nack重回队列还是丢进死信队列。反正,就是不能把消息弄丢了。
TTL + DLX 是咋回事
当消息出现下面这仨情况之一时,它就变成“死信”(Dead Letter)了:
- 消费者用
basicNack或者basicReject拒绝了消息,并且requeue设成false。 - 消息在队列里呆的时间超过了 TTL,过期了。
- 队列满了,装不下了。
这时候,如果这个队列配置了“死信交换机(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,直接把消息丢到死信队列,然后让死信队列的消费者去处理重试。这样逻辑清晰点。
完整链路,看一眼心里有数
再来捋一下这条链路:
- 订单服务创建订单,往
order.delay.exchange发延迟消息。 - 发送成功后,Broker 回
ack,订单服务知道消息稳了。 - 延迟交换机过了 15 分钟,把消息路由到
order.close.queue。 OrderCloseConsumer收到消息,开始处理。- 先查 Redis 做幂等,确认这个订单没被处理过。
- 执行关单业务逻辑。
- 成功 -> 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),先看这几个地方:
- Queues:看 Ready 和 Unacked 两个值。Ready 是等待被消费的消息,如果这个数字一直往上涨,说明消费者消费能力不行或者挂了,消息在积压。Unacked 是已经发给消费者但还没确认的,如果这个数很高,说明消费者处理很慢。
- Consumers:看看消费者在线不,消费速率咋样。
- Publish / Get / Deliver:消息的进出速率,主要看有没有失衡。
- Node:内存、磁盘、文件描述符,这些基础指标看是不是快爆了。
告警和链路追踪
监控方面,用 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延迟任务的资料请关注脚本之家其它相关文章!
