SpringBoot 整合:RabbitMQ配置延时队列和消息重试

>>强大,10k+点赞的 SpringBoot 后台管理系统竟然出了详细教程!

SpringBoot 整合:RabbitMQ配置延时队列和消息重试

来源:blog.csdn.net/qq330983778/article/details/99661012

延时队列

通常我们会有一些额外的需求,一些队列中的消息并不是需要立即需要被消费掉的。这个时候我们需要将消息延迟处理。为了处理这些延时的消息。这个时候就是死信路由发挥作用的时候了。

要实现延迟队列,首先我们需要了解RabbitMQ中对于和消息相关的概念:消息的TTL和死信Exchange

TTL

消息的TTL指的是消息的存活时间(Time To Live)。RabbitMQ中可以对队列或者每一个消息设置单独的存活时间。当消息在队列中存在的时间超过这个设定值之后,系统会认为这个消息死了,这就写消息被称为"死信"。

要想设置消息的超时时间,我们可以配置消息中expiration字段或者配置队列的x-message-ttl参数。

当消息超过时间依旧没被消费掉,它会变成死信,但是这个消息并不会被删除或者丢弃。但是单靠死信,我们是没法实现延迟队列的。这里就需要死信Exchange来配合

死信Exchange

Exchange 之前我们已经介绍过了,而死信路由指的就是死信最后被转发到的路由地址。死信路由和普通的路由没有区别,只是某一个设置了死信路由的队列中存在消息过期的时候,会将消息转发过来。

整个死信Exchange的流程

SpringBoot 整合:RabbitMQ配置延时队列和消息重试

组件的配置

根据之前的流程图我们可以知道要想实现延时消息我们需要以下内容

  • 一套接收消息的处理配置
  • 一套被订阅的死信的处理配置
  • 超时时间的设置

正常处理配置

根据之前的流程,这个队列里的消息是不需要被消费的。

等待队列中的消息超时后消息被推送至死信队列后,消费端在死信队列再进行消费。这里我们配置了4000毫秒的超时时间。

    /**
     * 延时队列
     * 发送到该队列的message会在一段时间后过期进入到delay_process_queue
     * 队列里所有的message都有统一的失效时间
     */

    public static String DELAY_QUEUE   = "delay.queue";

    /**
     * 延时的交换器
     */

    public static String DELAY_EXCHANGE = "delay.queue.exchange";
    /**
     * 超时时间
     */

    public static Long QUEUE_EXPIRATION = 4000L;
    
    /**
     * 配置延时交换器
     * @return
     */

    @Bean
    DirectExchange delayExchange() {
        return new DirectExchange(DELAY_EXCHANGE);
    }
    
    /**
     * 配置延时队列
     * @return
     */

    @Bean
    public Queue delayQueue() {
        return QueueBuilder.durable(DELAY_QUEUE)
                // DLX,dead letter发送到的exchange ,设置死信队列交换器到处理交换器
                .withArgument("x-dead-letter-exchange", PROCESS_EXCHANGE)
                // dead letter携带的routing key,配置处理队列的路由key
                .withArgument("x-dead-letter-routing-key", PROCESS_QUEUE)
                // 设置过期时间
                .withArgument("x-message-ttl", QUEUE_EXPIRATION)
                .build();
    }
    
    /**
     * 将delayQueue2绑定延时交换机中,routingKey为队列名称
     * @return
     */

    @Bean
    Binding queueTTLBinding() {
        return BindingBuilder
                .bind(delayQueue())
                .to(delayExchange())
                .with(DELAY_QUEUE);
    }

在正常接收消息的队列中,进行的特殊配置。

为队列添加了x-dead-letter-exchange,x-dead-letter-routing-key,x-message-ttl这几个参数保证了消息超时后的死亡,并且保证了消息在死亡后 能够推送到真正被订阅的死信队列。

死信队列配置

    /**
     * 实际消费队列
     * message失效后进入的队列,也就是实际的消费队列
     */

    public static String PROCESS_QUEUE = "process.queue";

    /**
     * 处理的交换器
     */

    public static String PROCESS_EXCHANGE = "process.queue.exchange";
    
    /**
     * 设置处理队列
     * @return
     */

    @Bean 
    public Queue delayProcess() 
        return QueueBuilder
                .durable(PROCESS_QUEUE) 
                .build(); 
    }
    
    /**
     * 配置处理交换器
     * @return
     */

    @Bean 
    DirectExchange processExchange() 
        return new DirectExchange(PROCESS_EXCHANGE); 
    }
    
    /**
     * 将DLX绑定到实际消费队列
     * @return
     */

    @Bean 
    Binding processBinding() {
        return BindingBuilder
                .bind(delayProcess()) 
                .to(processExchange()) 
                .with(PROCESS_QUEUE); 
    }

配置生产者

目前我们创建了两个队列delay.queue和process.queue(死信队列),这个时候我们的生产者需要向delay.queue中推送消息,而不是死信队列。死信队列的消息应该是消息在delay.queue中超时后转发到达的。

@Component
@Slf4j
public class DelaySender {
    
    @Autowired
    private RabbitTemplate rabbitTemplate;

    public void send(User user) {
        log.info("消息已经发送,时间为:{}",
                new Timestamp(System.currentTimeMillis()));
        this.rabbitTemplate.convertAndSend(
                DelayConfig.PROCESS_EXCHANGE,
                // routingKey
                DelayConfig.PROCESS_QUEUE,
                user);
    }
}

配置消费者

和之前的内容不同,这里使用SimpleMessageListenerContainer完成对队列的监听。

和上面一样,这里我们监听的对象是process.queue队列。

@Component
@Slf4j
public class DelayReceiver {

    /**
     * queues是指要监听的队列的名字
     * @param user
     */

    @RabbitListener(queues = DelayConfig.PROCESS_QUEUE)
    @RabbitHandler
    public void receiveDirect1(User user) {
        log.info("消息已经接收,时间为:{}",new Timestamp(System.currentTimeMillis()));
        System.out.println("【converter-receiveDirect1监听到消息】" + user);
    }

}

测试

我们请求:http://localhost:8000/delay/send 接口推送了一条消息到rabbitMQ中。

后续可以看到控制台打印内容:

2019-08-14 19:52:54.956  INFO 80456 --- [nio-8000-exec-1] dai.samples.rabbit.delay.DelaySender     : 消息已经发送,时间为:2019-08-14 19:52:54.956
2019-08-14 19:52:59.158 INFO 80456 --- [cTaskExecutor-1] dai.samples.rabbit.delay.DelayReceiver   : 消息已经接收,时间为:2019-08-14 19:52:59.158
【converter-receiveDirect1监听到消息】User(id=1, name=Direct, age=100)

为消息设置超时

之前介绍过,超时的设置可以针对队列设置同样也支持对消息进行单独设置。对消息进行单独设置的时候我们消息生产者发起请求需要添加额外的参数。

public void send2(User user,Long time) {
    log.info("消息已经发送,时间为:{}",new Timestamp(System.currentTimeMillis()));
    this.rabbitTemplate.convertAndSend(
            DelayConfig.DELAY_EXCHANGE,
            // routingKey
            DelayConfig.DELAY_QUEUE,
            user,
            message -> {
                // 设置延迟毫秒值
                message.getMessageProperties().setExpiration(String.valueOf(time));
                return message;
            });
}

但是需要注意的,当对队列和消息都设置的超时时间的时候,会选择最小的值作为实际超时时间。

测试

消息超时时间大于队列超时时间

我们请求:http://localhost:8000/delay/send/3000 接口推送了一条三秒消息到rabbitMQ中。

后续可以看到控制台打印内容:

2019-08-14 19:55:10.101  INFO 80456 --- [nio-8000-exec-5] dai.samples.rabbit.delay.DelaySender     : 消息已经发送,时间为:2019-08-14 19:55:10.101
2019-08-14 19:55:13.130  INFO 80456 --- [cTaskExecutor-1] dai.samples.rabbit.delay.DelayReceiver   : 消息已经接收,时间为:2019-08-14 19:55:13.13
【converter-receiveDirect1监听到消息】User(id=2, name=Direct2, age=200)

可以看到消息根据我们设置的时间推送到了死信路由

消息超时时间小于队列超时时间

我们请求:delay/send/6000 接口推送了一条消息到rabbitMQ中。

2019-08-14 14:56:04.386  INFO 80456 --- [nio-8000-exec-7] dai.samples.rabbit.delay.DelaySender     : 消息已经发送,时间为:2019-08-14 14:56:04.386
2019-08-14 14:56:08.412 INFO 80456 --- [cTaskExecutor-1] dai.samples.rabbit.delay.DelayReceiver   : 消息已经接收,时间为:2019-08-14 14:56:08.412
【converter-receiveDirect1监听到消息】User(id=2, name=Direct2, age=200)

这个时候可以看到消息并没有根据我们设置时间的6秒钟延迟而是使用了队列的4秒延迟

使用消息超时时候需要注意

现在我们推送一条4秒延迟的消息,再推送一条2秒延迟的消息,按照延迟时间设计应该是我们后续推送的消息最先超时然后被我们消费掉,但实际却不是的。推荐:Java面试练题宝典

我们请求:http://localhost:8000/delay/send

然后请求:http://localhost:8000/delay/send/2000

2019-08-14 19:58:53.482  INFO 80456 --- [io-8000-exec-10] dai.samples.rabbit.delay.DelaySender     : 消息已经发送,时间为:2019-08-14 19:58:53.482
2019-08-14 19:58:54.758 INFO 80456 --- [nio-8000-exec-1] dai.samples.rabbit.delay.DelaySender     : 消息已经发送,时间为:2019-08-14 19:58:54.758
2019-08-14 19:58:57.510  INFO 80456 --- [cTaskExecutor-1] dai.samples.rabbit.delay.DelayReceiver   : 消息已经接收,时间为:2019-08-14 19:58:57.51
【converter-receiveDirect1监听到消息】User(id=1, name=Direct, age=100)
2019-08-14 19:58:57.511 INFO 80456 --- [cTaskExecutor-1] dai.samples.rabbit.delay.DelayReceiver   : 消息已经接收,时间为:2019-08-14 19:58:57.511
【converter-receiveDirect1监听到消息】User(id=2, name=Direct2, age=200)

而实际确实第二条消息等待第一条被消费后才被我们消费,实际上延迟超过2秒。

这是因为,由于队列的先进先出特性,只有当过期的消息到了队列的顶端(队首),才会被真正的丢弃或者进入死信队列。

所以当我们设置了一个较短的消息超时时间,但是因为他之前有队列尚未完结。此消息依旧不会进入死信。

消息重试

使用延时消息我们还可以实现一种业务场景:延迟的消息重试。

有些时候我们可能以为一些原因,导致消息在某一时段处理的失败,但是假如马上进行重试可能会再次失败,我们希望稍后对其进行处理且设置一个可控的重试次数。此时我们可以在延迟消息中进行修改来实现消息重试。

重试的流程

SpringBoot 整合:RabbitMQ配置延时队列和消息重试

根据上面的设计,我们需要三个消息队列workQueue业务队列、retryQueue重试队列、failedQueue失败队列。推荐:Java面试练题宝典

配置exchange和queue

workQueue

业务处理所订阅的内容,普通队列

@Configuration
public class WorkConfig {

    /**
     * 处理业务的队列
     */

    public final static String WORK_QUEUE = "retry.work.queue";

    /**
     * 处理业务的交换器
     */

    public final static String WORK_EXCHANGE = "retry.work.exchange";

    /**
     * 处理业务的路由key
     */

    public final static String WORK_KEY = "retry.work.key";


    /**
     * 处理业务的交换器
     * @return
     */

    @Bean 
    DirectExchange retryWorkExchange() {
        return new DirectExchange(WORK_EXCHANGE);
    }


    /**
     * 处理业务的队列
     * @return
     */

    @Bean
    public Queue retryWorkQueue() {
        return QueueBuilder
                .durable(WORK_QUEUE)
                .build();
    }



    /**
     * 绑定处理队列的数据监听工作
     * @return
     */

    @Bean
    public Binding workRetryBinding() {
        return BindingBuilder
                .bind(retryWorkQueue())
                .to(retryWorkExchange())
                .with(WORK_KEY);
    }

}

retryQueue

延时队列,其配置了死信相关参数,其死信队列为workqueue

@Configuration
public class RetryConfig {


    /**
     * 重试的队列
     */

    public final static String RETRY_QUEUE = "retry.queue";

    /**
     * 重试的交换器
     */

    public final static String RETRY_EXCHANGE = "retry.exchange";

    /**
     * 处理业务的路由key
     */

    public final static String RETRY_KEY = "retry.key";

    /**
     * 超时时间
     */

    private static final Long QUEUE_EXPIRATION = 4000L;


   
    /**
     * 重试的交换器
     * @return
     */

    @Bean
    DirectExchange retryExchange() {
        return new DirectExchange(RETRY_EXCHANGE);
    }


    /**
     * 重试的队列
     * @return
     */

    @Bean
    public Queue retryQueue() {
        // 设置超时队列
        return QueueBuilder.durable(RETRY_QUEUE)
                // DLX,dead letter发送到的exchange ,设置死信队列交换器到处理交换器
                .withArgument("x-dead-letter-exchange", WorkConfig.WORK_EXCHANGE)
                // dead letter携带的routing key,配置处理队列的路由key
                .withArgument("x-dead-letter-routing-key", WorkConfig.WORK_KEY)
                // 设置过期时间
                .withArgument("x-message-ttl", QUEUE_EXPIRATION)
                .build();
    }


    /**
     * 绑定处理队列的数据监听工作
     * @return
     */

    @Bean
    public Binding retryBinding() {
        return BindingBuilder
                .bind(retryQueue())
                .to(retryExchange())
                .with(RETRY_KEY);
    }

}

failedQueue

重试次数超过上限后的消息处理队列,没有额外处理

@Configuration
public class FailedConfig {

    /**
     * 处理业务的队列
     */

    public final static String FAILED_QUEUE = "retry.failed.queue";

    /**
     * 处理业务的交换器
     */

    public final static String FAILED_EXCHANGE = "retry.failed.exchange";

    /**
     * 处理业务的路由key
     */

    public final static String FAILED_KEY = "retry.failed.key";


    /**
     * 处理业务的交换器
     * @return
     */

    @Bean DirectExchange retryFailedExchange() {
        return new DirectExchange(FAILED_EXCHANGE);
    }


    /**
     * 处理业务的队列
     * @return
     */

    @Bean
    public Queue retryFailedQueue() {
        return QueueBuilder
                .durable(FAILED_QUEUE)
                .build();
    }



    /**
     * 绑定处理队列的数据监听工作
     * @return
     */

    @Bean
    public Binding failedRetryBinding() {
        return BindingBuilder
                .bind(retryFailedQueue())
                .to(retryFailedExchange())
                .with(FAILED_KEY);
    }
}

配置消息发送者

@Component
public class RetrySender {
    
    @Autowired
    private RabbitTemplate rabbitTemplate;

    public void send(User user) {
        this.rabbitTemplate.convertAndSend(
                WorkConfig.WORK_EXCHANGE,
                // routingKey
                WorkConfig.WORK_KEY,
                user);
    }
}

配置消息监听者

在对work_queue的消息订阅中,模拟了业务逻辑,进行重试或者转发至失败队列

@Component
@Slf4j
public class WorkReceiver {

    @Autowired RabbitTemplate rabbitTemplate;
    
    /**
     * queues是指要监听的队列的名字
     * @param user
     */

    @RabbitListener(queues = WorkConfig.WORK_QUEUE,
            errorHandler = "retryReceiverListenerErrorHandler")
    public void receiveDirect(User user,
                              Channel channel,
                              Message message)
 throws Exception 
{
        try {
            log.info("【WorkReceiver监听到消息】" + JSON.toJSONString(user));
            Integer retry = user.getRetry();
            String id = user.getId();
            log.info("重试次数:{}",retry);
            if (retry < 3 || "1".equals(id)) {
                user.setRetry(retry + 1);
                throw new RuntimeException("进入重试");
            }
            log.info("消费成功");
        } catch (Exception e) {
            log.info("开始重试");
            if (user.getRetry() > 3) {
                rabbitTemplate.convertAndSend(
                        FailedConfig.FAILED_EXCHANGE,
                        // routingKey
                        FailedConfig.FAILED_KEY,
                        user);
                log.info("receiver failed");
            } else {
                rabbitTemplate.convertAndSend(
                        RetryConfig.RETRY_EXCHANGE,
                        // routingKey
                        RetryConfig.RETRY_KEY,
                        user);
                log.info("receiver error");
            }
        }
    }

}

失败队列的订阅处理中一般会进行数据的持久化,以方便后续人工介入进行业务处理

@Component
@Slf4j
public class FailedReceiver {

    /**
     * queues是指要监听的队列的名字
     * @param user
     */

    @RabbitListener(queues = FailedConfig.FAILED_QUEUE,
            errorHandler = "retryReceiverListenerErrorHandler")
    public void receiveDirect(User user, Channel channel, Message message) throws Exception {
        try {
            log.info("【FailedReceiver监听到消息】" + JSON.toJSONString(user));
            log.info(" 人工处理");
        } catch (Exception e) {
            log.info("receiver error");
        }
    }
}

测试

为了测试编写了两个接口

@RestController
@RequestMapping("retry")
public class RetryController {

    @Autowired
    private RetrySender sender;

    /**
     * 测试重试3次后完成处理
     * @return
     */

    @RequestMapping(value = "send",method = RequestMethod.GET)
    public String sendMessage() {
        User user = new User("3","Direct3",200,0);
        sender.send(user);
        return JSON.toJSONString(user);
    }

    /**
     * 测试重试3次后转入失败队列中
     * @return
     */

    @RequestMapping(value = "send2",method = RequestMethod.GET)
    public String sendMessage2() {
        User user = new User("1","Direct1",200,0);
        sender.send(user);
        return JSON.toJSONString(user);
    }
}

请求接口:http://localhost:8000/retry/send 可以看到下面结果

2019-08-14 20:51:29.891  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 【WorkReceiver监听到消息】{"age":200,"id":"1","name":"Direct1","retry":0}
2019-08-14 20:51:29.891 INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 重试次数:0
2019-08-14 20:51:29.891  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 开始重试
2019-08-14 20:51:29.892  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : receiver error
2019-08-14 20:51:33.916  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 【WorkReceiver监听到消息】{"age":200,"id":"1","name":"Direct1","retry":1}
2019-08-14 20:51:33.917 INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 重试次数:1
2019-08-14 20:51:33.917  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 开始重试
2019-08-14 20:51:33.919  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : receiver error
2019-08-14 20:51:37.945  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 【WorkReceiver监听到消息】{"age":200,"id":"1","name":"Direct1","retry":2}
2019-08-14 20:51:37.946 INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 重试次数:2
2019-08-14 20:51:37.946  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 开始重试
2019-08-14 20:51:37.947  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : receiver error
2019-08-14 20:51:41.974  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 【WorkReceiver监听到消息】{"age":200,"id":"1","name":"Direct1","retry":3}
2019-08-14 20:51:41.975 INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 重试次数:3
2019-08-14 20:51:41.975  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 开始重试
2019-08-14 20:51:41.976  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : receiver failed
2019-08-14 20:51:41.999  INFO 89656 --- [cTaskExecutor-1] d.s.r.retry.receiver.FailedReceiver      : 【FailedReceiver监听到消息】{"age":200,"id":"1","name":"Direct1","retry":4}
2019-08-14 20:51:42.000  INFO 89656 --- [cTaskExecutor-1] d.s.r.retry.receiver.FailedReceiver      :  人工处理

请求接口:http://localhost:8000/retry/send2 可以看到下面结果

2019-08-14 20:53:05.751  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 【WorkReceiver监听到消息】{"age":200,"id":"1","name":"Direct1","retry":0}
2019-08-14 20:53:05.751 INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 重试次数:0
2019-08-14 20:53:05.752  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 开始重试
2019-08-14 20:53:05.752  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : receiver error
2019-08-14 20:53:09.779  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 【WorkReceiver监听到消息】{"age":200,"id":"1","name":"Direct1","retry":1}
2019-08-14 20:53:09.779 INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 重试次数:1
2019-08-14 20:53:09.779  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 开始重试
2019-08-14 20:53:09.780  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : receiver error
2019-08-14 20:53:13.804  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 【WorkReceiver监听到消息】{"age":200,"id":"1","name":"Direct1","retry":2}
2019-08-14 20:53:13.804 INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 重试次数:2
2019-08-14 20:53:13.804  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 开始重试
2019-08-14 20:53:13.806  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : receiver error
2019-08-14 20:53:17.827  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 【WorkReceiver监听到消息】{"age":200,"id":"1","name":"Direct1","retry":3}
2019-08-14 20:53:17.827 INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 重试次数:3
2019-08-14 20:53:17.827  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : 开始重试
2019-08-14 20:53:17.827  INFO 89656 --- [cTaskExecutor-1] d.s.rabbit.retry.receiver.WorkReceiver   : receiver failed
2019-08-14 20:53:17.853  INFO 89656 --- [cTaskExecutor-1] d.s.r.retry.receiver.FailedReceiver      : 【FailedReceiver监听到消息】{"age":200,"id":"1","name":"Direct1","retry":4}
2019-08-14 20:53:17.854  INFO 89656 --- [cTaskExecutor-1] d.s.r.retry.receiver.FailedReceiver      :  人工处理

本篇文章涉及的源码下载地址:

https://gitee.com/daifyutils/springboot-samples

推荐好文

强大,10k+点赞的 SpringBoot 后台管理系统竟然出了详细教程!

分享一套基于SpringBoot和Vue的企业级中后台开源项目,代码很规范!

能挣钱的,开源 SpringBoot 商城系统,功能超全,超漂亮!SpringBoot 整合:RabbitMQ配置延时队列和消息重试

原文始发于微信公众号(Java笔记虾):SpringBoot 整合:RabbitMQ配置延时队列和消息重试