场景一:调用一个外部接口,如果调用失败,经过一段时间之后重试。
场景二:订单下单之后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());
}
}