使用RabbitMQ实现延迟任务

本文介绍如何利用RabbitMQ实现延迟任务,包括消息的TTL(TimeToLive)和死信Exchange的概念,以及如何通过Spring配置实现延迟队列。同时,提供了一种基于AOP的重试机制,通过RabbitMQ的延迟队列来处理业务重试。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

场景一:调用一个外部接口,如果调用失败,经过一段时间之后重试。

场景二:订单下单之后30分钟后,如果用户没有付钱,则系统自动取消订单。

上述类似的需求是我们经常会遇见的问题。最常用的方法是定期轮训数据库,设置状态。在数据量小的时候并没有什么大的问题,但是数据量一大轮训数据库的方式就会变得特别耗资源。当面对千万级、上亿级数据量时,本身写入的IO就比较高,导致长时间查询或者根本就查不出来,更别说分库分表以后了。除此之外,还有优先级队列,基于优先级队列的JDK延迟队列,时间轮等方式。但如果系统的架构中本身就有RabbitMQ的话,那么选择RabbitMQ来实现类似的功能也是一种选择。

使用RabbitMQ来实现延迟任务必须先了解RabbitMQ的两个概念:消息的TTL和死信Exchange,通过这两者的组合来实现上述需求。

消息的TTL(Time To Live)

消息的TTL就是消息的存活时间。RabbitMQ可以对队列和消息分别设置TTL。对队列设置就是队列没有消费者连着的保留时间,也可以对每一个单独的消息做单独的设置。超过了这个时间,我们认为这个消息就死了,称之为死信。如果队列设置了,消息也设置了,那么会取小的。所以一个消息如果被路由到不同的队列中,这个消息死亡的时间有可能不一样(不同的队列设置)。这里单讲单个消息的TTL,因为它才是实现延迟任务的关键。

可以通过设置消息的expiration字段或者x-message-ttl属性来设置时间,两者是一样的效果。只是expiration字段是字符串参数,所以要写个int类型的字符串:

byte[] messageBodyBytes = "Hello, world!".getBytes();
AMQP.BasicProperties properties = new AMQP.BasicProperties();
properties.setExpiration("60000");
channel.basicPublish("my-exchange", "routing-key", properties, messageBodyBytes);

当上面的消息扔到队列中后,过了60秒,如果没有被消费,它就死了。不会被消费者消费到。这个消息后面的,没有“死掉”的消息对顶上来,被消费者消费。死信在队列中并不会被删除和释放,它会被统计到队列的消息数中去。单靠死信还不能实现延迟任务,还要靠Dead Letter Exchange。

Dead Letter Exchanges

Exchage的概念在这里就不在赘述。一个消息在满足如下条件下,会进死信路由,记住这里是路由而不是队列,一个路由可以对应很多队列。

1. 一个消息被Consumer拒收了,并且reject方法的参数里requeue是false。也就是说不会被再次放在队列里,被其他消费者使用。

2. 上面的消息的TTL到了,消息过期了。

3. 队列的长度限制满了。排在前面的消息会被丢弃或者扔到死信路由上。

Dead Letter Exchange其实就是一种普通的exchange,和创建其他exchange没有两样。只是在某一个设置Dead Letter Exchange的队列中有消息过期了,会自动触发消息的转发,发送到Dead Letter Exchange中去。

实现延迟队列

延迟任务通过消息的TTL和Dead Letter Exchange来实现。我们需要建立2个队列,一个用于发送消息,一个用于消息过期后的转发目标队列。

 

生产者输出消息到Queue1,并且这个消息是设置有有效时间的,比如60s。消息会在Queue1中等待60s,如果没有消费者收掉的话,它就是被转发到Queue2,Queue2有消费者,收到,处理延迟任务。

需要注意的是,如果为每个消息单独设置过期时间,即使一个消息比在同一队列中的其他消息提前过期,提前过期的也不会优先进入死信队列,它们还是按照入库的顺序让消费者消费。如果第一进去的消息过期时间是1小时,那么即使第二条消息过期时间是1分钟,第二条消息也要等1小时才能进入死信队列。参考官方文档发现“Only when expired messages reach the head of a queue will they actually be discarded (or dead-lettered).”只有当过期的消息到了队列的顶端(队首),才会被真正的丢弃或者进入死信队列。

所以在考虑使用RabbitMQ来实现延迟任务队列的时候,需要确保业务上每个任务的延迟时间是一致的。如果遇到不同的任务类型需要不同的延时的话,需要为每一种不同延迟时间的消息建立单独的消息队列。

spring配置如下:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:rabbit="http://www.springframework.org/schema/rabbit"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/rabbit http://www.springframework.org/schema/rabbit/spring-rabbit.xsd">

    <bean id="connectionFactory"
          class="org.springframework.amqp.rabbit.connection.CachingConnectionFactory">
        <constructor-arg value="${rabbitmq.host}"/>
        <property name="username" value="${rabbitmq.username}"/>
        <property name="password" value="${rabbitmq.password}"/>
    </bean>
    <rabbit:template id="pushOrderTemplate" connection-factory="connectionFactory" routing-key="delayPushOrder"/>
    <!--自动声明缺失的queue-->
    <rabbit:admin connection-factory="connectionFactory"/>
    <!--声明一个持久化延迟队列(单位毫秒)。如果需要发送延迟消息,则发送到该队列。-->
    <rabbit:queue name="delayPushOrder" durable="true">
        <rabbit:queue-arguments>
            <entry key="x-message-ttl">
                <value type="java.lang.Long">72000</value>
            </entry>
            <entry key="x-dead-letter-exchange" value=""/>
            <entry key="x-dead-letter-routing-key" value="pushOrder"/>
        </rabbit:queue-arguments>
    </rabbit:queue>

    <!--声明一个持久化队列,延迟队列中的消息到期后发送到该队列中。如果不需要延迟,则直接发送消息到该队列-->
    <rabbit:queue name="pushOrder" durable="true"/>

    <!--消息监听。注意,监听的是正常队列pushOrder,而不是延迟队列delayPushOrder-->
    <bean id="pushOrderListener" class="com.csy.webmvc.rabbitmq.pushOrderListener"/>
    <rabbit:listener-container connection-factory="connectionFactory">
        <rabbit:listener queues="pushOrder" ref="pushOrderListener" method="handle"/>
    </rabbit:listener-container>
</beans>

 定义重试注解:

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface RetryMonitor {
    /**
     * 重试次数
     */
    int retryCount() default 5;

    /**
     * 延时队列的名称,默认用5分钟的延时队列
     */
    String queueName() default MqQueues.dlxRoutingKey;

    /**
     * 不需要捕获的异常类型
     */
    Class<? extends Throwable>[] notCatchFor() default {BusinessException.class};

    /**
     * 业务描述
     */
    String desc() default "";
}

 切面类:

@Component
@Aspect
@Slf4j
public class RetryAspect {
    private int expireTime = 86400;
    public static final ThreadLocal<String> retryUUIDThreadLocal = new ThreadLocal<>();

    @Resource
    private RabbitTemplate mrpRabbitTemplate;

    @Autowired
    RabbitMqProperties config;

    @Pointcut("@annotation(retry.RetryMonitor)")
    public void retryAspect() {
    }

    @Around("retryAspect()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            return joinPoint.proceed();
        } catch (Exception e) {
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            Method method = signature.getMethod();
            RetryMonitor retryMonitor = method.getAnnotation(RetryMonitor.class);
            Class[] notCatchfor = retryMonitor.notCatchFor();
            for (Class c : notCatchfor) {
                //业务抛出了异常,但是这个异常不需要重试,直接向上抛出异常即可
                if (c.isInstance(e)) {
                    throw e;
                }
            }

            long retryCountNow = 1;
            int retryCount = retryMonitor.retryCount();
            String UUID = retryUUIDThreadLocal.get();
            if (UUID == null) {
                UUID = java.util.UUID.randomUUID().toString();
                JedisUtil.set(UUID, retryCountNow + "", expireTime);
            } else {
                retryCountNow = JedisUtil.incrAndGet(UUID);
                JedisUtil.expire(UUID, expireTime, TimeUnit.SECONDS);
                //使用完之后清理掉,防止污染下一次请求
                retryUUIDThreadLocal.remove();
            }
            //超过重试次数之后不再重试
            if (retryCountNow > retryCount) {
                return null;
            }
            String desc = retryMonitor.desc();
            RetryPo retryPo = new RetryPo();
            String queueName = retryMonitor.queueName();
            retryPo.setServiceClass(signature.getDeclaringType().getName());
            retryPo.setServiceMethod(signature.getMethod().getName());
            retryPo.setParamTypeArray(signature.getParameterTypes());
            retryPo.setParamValueArray(joinPoint.getArgs());
            retryPo.setUUID(UUID);
            retryPo.setDesc(desc);
            RabbitMqProperties.MRP mrp = config.getMrp();
            //打开fastjson的autotype功能,否则泛型无法正确反序列化
            ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
            mrpRabbitTemplate.convertAndSend(mrp.getDirectExchangeName(), queueName, JSON.toJSONString(retryPo, SerializerFeature.WriteClassName));
            return null;
        }
    }
}

 消费者:

public class RetryMqReceiver implements ApplicationContextAware {
    private static ApplicationContext applicationContext;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext= applicationContext;
    }

    @RabbitListener(queues = {"queue-name"})
    public void commonRetry(@Payload String message) throws InvocationTargetException, IllegalAccessException, NoSuchMethodException, ClassNotFoundException {
        RetryPo retryPo = JSON.parseObject(message, RetryPo.class);
        String UUID = retryPo.getUUID();
        RetryAspect.retryUUIDThreadLocal.set(UUID);
        //调用业务方法进行重试
        Class businessClass = Class.forName(retryPo.getServiceClass());
        Object o = applicationContext.getBean(businessClass);
        Method m = businessClass.getMethod(retryPo.getServiceMethod(), retryPo.getParamTypeArray());
        String desc = retryPo.getDesc();
        if (StringUtil.isEmpty(desc)) {
            desc = retryPo.getServiceClass() + "." + retryPo.getServiceMethod();
        }
        log.info("通过MQ进行业务重试,业务类型-{},参数:{}", desc, message);
        m.invoke(o, retryPo.getParamValueArray());
    }
}

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值