原文链接:要做重试机制,就只能选择 DelayQueue ?其实 RabbitMQ 它上它也行!

一、场景

最近研发一个新功能,后台天气预警:后台启动一条线程,定时调用天气预警 API,查询现有城市的相关天气预警信息,如果发现有预警或取消预警的信息,给指定预警部门配置的相关人员发送短信;而如果第一次发送失败,我们需要隔几分钟再重新发送,最多可以重发5次。

二、技术选型

1、JDK 原生 DelayQueue:

重试机制最简单的就是直接利用 JDK 提供的 DelayQyeye,而 DelayQueue 里面存放的任务需要是实现 Delay 接口的实现类,需要重写 getDelay 方法和 compareTo 方法。getDelay 方法主要用做判断任务是否到期要出队列,而 compareTo 方法主要用做入队时任务的判断,过期时间短的任务应放在队列的前面,通过这个方法,我们可以知道,DelayQueue 的底层是利用 PriorityQueue 实现的。

下面上一个 DelayQueue 的简单的使用例子:

  1. /**
  2. * @author Howinfun
  3. * @desc
  4. * @date 2020/8/1
  5. */
  6. public class TestDelayQueue {
  7. public static void main(String[] args) throws InterruptedException {
  8. DelayQueue<UserMsg> delayQueue = new DelayQueue();
  9. UserMsg userMsg1 = new UserMsg(1,"15627272727","你好,下单成功1",5, TimeUnit.SECONDS);
  10. UserMsg userMsg2 = new UserMsg(2,"15627272727","你好,下单成功2",3, TimeUnit.SECONDS);
  11. UserMsg userMsg3 = new UserMsg(3,"15627272727","你好,下单成功3",4, TimeUnit.SECONDS);
  12. UserMsg userMsg4 = new UserMsg(4,"15627272727","你好,下单成功4",6, TimeUnit.SECONDS);
  13. UserMsg userMsg5 = new UserMsg(5,"15627272727","你好,下单成功5",2, TimeUnit.SECONDS);
  14. delayQueue.add(userMsg1);
  15. delayQueue.put(userMsg2);
  16. delayQueue.put(userMsg3);
  17. delayQueue.put(userMsg4);
  18. delayQueue.put(userMsg5);
  19. for (int i=0;i<5;i++){
  20. // take方法会一直阻塞,直到有任务
  21. UserMsg userMsg = delayQueue.take();
  22. System.out.println(userMsg.toString());
  23. }
  24. }
  25. }
  1. @Data
  2. @ToString
  3. public class UserMsg implements Delayed {
  4. private int id;
  5. private String phone;
  6. private String msg;
  7. private int failCount;
  8. // 过期时间
  9. private long time;
  10. public UserMsg(int id,String phone,String msg,long time,TimeUnit unit){
  11. this.id = id;
  12. this.phone = phone;
  13. this.msg = msg;
  14. this.time = System.currentTimeMillis() + (time > 0 ? unit.toMillis(time) : 0);
  15. this.failCount = 0;
  16. }
  17. @Override
  18. public long getDelay(TimeUnit unit) {
  19. // 和当前时间比较,判断是否到期
  20. return this.time - System.currentTimeMillis();
  21. }
  22. @Override
  23. public int compareTo(Delayed o) {
  24. // 入队时需要判断任务放到队列的哪个位置,过期时间短的放在前面
  25. UserMsg item = (UserMsg) o;
  26. long diff = this.time - item.time;
  27. if (diff <= 0) {
  28. return -1;
  29. }else {
  30. return 1;
  31. }
  32. }
  33. }

扩展点:PriorityQueue的优化点

讲到 PriorityQueue,有一个优化点我是觉得挺有意思的:它的 take() 方法,是会一直阻塞直到有任务过期出队列。它里面主要是利用 for 死循环去读取队列的头节点,判断头节点是否为空,如果为空,则直接调用 Condition#await() 进入阻塞状态;而如果队列的头节点不为空,但是任务还未过期,则会判断之前是否有线程(leader)尝试获取过期任务了,如果有的话就调用 Condition#await() 方法,否则就继续在死循环里面继续尝试获取过期任务。这样的话,避免所有尝试获取过期任务的线程一直在死循环,这样能让多余的线程进入阻塞状态,从而释放系统资源。当然了,只要 leader 拿到过期任务了,那么就会判断队列是否还有任务,如果有则调用 Condition#signal() 唤醒等待状态的线程们。我们可以看看源码:

  1. public E take() throws InterruptedException {
  2. final ReentrantLock lock = this.lock;
  3. lock.lockInterruptibly();
  4. try {
  5. for (;;) {
  6. E first = q.peek();
  7. if (first == null)
  8. available.await();
  9. else {
  10. long delay = first.getDelay(NANOSECONDS);
  11. if (delay <= 0)
  12. return q.poll();
  13. first = null; // don't retain ref while waiting
  14. if (leader != null)
  15. available.await();
  16. else {
  17. Thread thisThread = Thread.currentThread();
  18. leader = thisThread;
  19. try {
  20. available.awaitNanos(delay);
  21. } finally {
  22. if (leader == thisThread)
  23. leader = null;
  24. }
  25. }
  26. }
  27. }
  28. } finally {
  29. if (leader == null && q.peek() != null)
  30. available.signal();
  31. lock.unlock();
  32. }
  33. }

2、消息中间件 RabbitMQ :

可能很多同学看到这个标题后会有点疑惑,消息中间件还能做重发机制?其实一开始我都没想到,关于 RabbitMQ 我也只是简单地学了他的六大使用模式,其他的业务场景还没深究。然后有一天和我的一个好同事聊了一下这个重试机制,他就说了 RabbitMQ 的死信队列可以做到,然后我自己就去研究一下。

他的主要原理是利用消息的 ttl + 死信队列。当短信发送失败时,封装一个消息往指定的业务 queue 发送,并且指定消息的 ttl,当然了,还需要为业务 queue 指定死信队列。当消息过期后,会从业务 queue 转到死信队列中,所以说,我们只需要监听死信队列,拉取其中的消息进行消费,这样就能做到重试了。

这两个怎么选?

使用原生的 DelayQueue 会更加方便点,因为只需要自定义类实现 Delay 接口,启动线程阻塞获取过期任务即可。可是对于想监控这个 DelayQueue 里面任务的情况,可能就要自己写接口来获取了,并且在微服务中,通常一个服务模块有多个实例,这样子的话,统一管理还有监控就更麻烦了。

所以我们可以考虑使用 RabbitMQ 来完成。不但可以统一处理重试机制,并且 RabbitMQ 还提供了自己的后台管理系统,这样监控起来也很方便。

三、RabbitMQ 如何利用消息 ttl 和死信队列做重试机制

下面是基于 spring-boot-starter-amqp 做的。

1、声明业务/死信队列相关组件: Exchange、Routing Key、Queue

第一步还是比较简单的,主要是创建对应的交换器、队列和路由键,特别要注意的是,在创建业务队列时,需要为他设置死信队列的相关信息,代码如下:

  1. @Configuration
  2. public class RabbitMQConfig {
  3. /**
  4. * 业务 queue
  5. */
  6. public static final String BUSINESS_QUEUE_NAME = "business.queue";
  7. /**
  8. * 业务 exchange
  9. */
  10. public static final String BUSINESS_EXCHANGE_NAME = "business.exchange";
  11. /**
  12. * 业务 routing key
  13. */
  14. public static final String BUSINESS_QUEUE_ROUTING_KEY = "business.routing.key";
  15. /**
  16. * 死信队列 exchange
  17. */
  18. public static final String DEAD_LETTER_EXCHANGE_NAME = "dead.letter.exchange";
  19. /**
  20. * 死信队列 queue
  21. */
  22. public static final String DEAD_LETTER_QUEUE_NAME = "dead.letter.queue";
  23. /**
  24. * 死信队列 routing key
  25. */
  26. public static final String DEAD_LETTER_QUEUE_ROUTING_KEY = "dead.letter.routing.key";
  27. /**
  28. * 声明业务交换器
  29. * @return
  30. */
  31. @Bean("businessExchange")
  32. public DirectExchange businessExchange(){
  33. return new DirectExchange(BUSINESS_EXCHANGE_NAME);
  34. }
  35. /**
  36. * 声明业务队列
  37. * @return
  38. */
  39. @Bean("businessQueue")
  40. public Queue businessQueue(){
  41. Map<String, Object> args = new HashMap<>(3);
  42. // 这里声明当前队列绑定的死信交换机
  43. args.put("x-dead-letter-exchange", DEAD_LETTER_EXCHANGE_NAME);
  44. // 这里声明当前队列的死信路由key
  45. args.put("x-dead-letter-routing-key", DEAD_LETTER_QUEUE_ROUTING_KEY);
  46. return QueueBuilder.durable(BUSINESS_QUEUE_NAME).withArguments(args).build();
  47. }
  48. /**
  49. * 声明业务队列绑定业务交换器,绑定路由键
  50. * @param queue
  51. * @param exchange
  52. * @return
  53. */
  54. @Bean
  55. public Binding businessBinding(@Qualifier("businessQueue") Queue queue,
  56. @Qualifier("businessExchange") DirectExchange exchange){
  57. return BindingBuilder.bind(queue).to(exchange).with(BUSINESS_QUEUE_ROUTING_KEY);
  58. }
  59. /**
  60. * 声明死信交换器
  61. * @return
  62. */
  63. @Bean("deadLetterExchange")
  64. public DirectExchange deadLetterExchange(){
  65. return new DirectExchange(DEAD_LETTER_EXCHANGE_NAME);
  66. }
  67. /**
  68. * 声明死信队列
  69. */
  70. @Bean("deadLetterQueue")
  71. public Queue deadLetterQueue(){
  72. return QueueBuilder.durable(DEAD_LETTER_QUEUE_NAME).build();
  73. }
  74. /**
  75. * 声明死信队列绑定死信交换器,绑定路由键
  76. * @param queue
  77. * @param exchange
  78. * @return
  79. */
  80. @Bean
  81. public Binding deadLetterBinding(@Qualifier("deadLetterQueue") Queue queue,
  82. @Qualifier("deadLetterExchange") DirectExchange exchange){
  83. return BindingBuilder.bind(queue).to(exchange).with(DEAD_LETTER_QUEUE_ROUTING_KEY);
  84. }
  85. }

2、模拟业务处理失败,发送需要重试的短信:

代码如下:

  1. UserMsg userMsg1 = new UserMsg(1,"15627236666","你好,麻烦充值",1);
  2. UserMsg userMsg2 = new UserMsg(2,"15627236667","你好,麻烦支付",1);
  3. UserMsg userMsg3 = new UserMsg(3,"15627236668","你好,麻烦下单",1);
  4. String msgJson1 = JSON.toJSONString(userMsg1);
  5. String msgJson2 = JSON.toJSONString(userMsg2);
  6. String msgJson3 = JSON.toJSONString(userMsg3);
  7. userMsgMapper.insert(userMsg1);
  8. userMsgMapper.insert(userMsg2);
  9. userMsgMapper.insert(userMsg3);
  10. MessagePostProcessor messagePostProcessor = message -> {
  11. // 如果配置了 params.put("x-message-ttl", 5 * 1000); 那么这一句也可以省略,具体根据业务需要是声明 Queue 的时候就指定好延迟时间还是在发送自己控制时间
  12. message.getMessageProperties().setExpiration(1 * 1000 * 60 + "");
  13. return message;
  14. };
  15. // 往业务 Queue 发送需要重试的短信
  16. rabbitTemplate.convertAndSend(RabbitMQConfig.BUSINESS_EXCHANGE_NAME,RabbitMQConfig.BUSINESS_QUEUE_ROUTING_KEY,msgJson1,messagePostProcessor);
  17. rabbitTemplate.convertAndSend(RabbitMQConfig.BUSINESS_EXCHANGE_NAME,RabbitMQConfig.BUSINESS_QUEUE_ROUTING_KEY,msgJson2,messagePostProcessor);
  18. rabbitTemplate.convertAndSend(RabbitMQConfig.BUSINESS_EXCHANGE_NAME,RabbitMQConfig.BUSINESS_QUEUE_ROUTING_KEY,msgJson3,messagePostProcessor);

我们可以看到,在发送消息时,会利用 MessagePostProcessor 来完成给消息添加 ttl。

3、监听死信队列,消费消息:

这是最重要的一步,我们需要监听死信队列,一旦有消息,证明有任务需要重试了,我们只需要拉取下来然后消费即可。

这里需要注意的有一个点:为了避免出现消息丢失的情况,我们需要开启手动 ack,然后配合 fetch = 1,保证客户端每次只能拉取一个消息,当客户端消费完此消息后,需要手动调用 channel#basicAck() 方法去确认此消息已经被消费了。

下面是相关的配置:

  1. # 开启手动 ack
  2. spring.rabbitmq.listener.simple.acknowledge-mode=manual
  3. # 设置 false,消息才能进入死信队列
  4. spring.rabbitmq.listener.simple.default-requeue-rejected=false
  5. # 消费者每次只读取一个消息
  6. spring.rabbitmq.listener.simple.prefetch=1

接着我们看看如何监听死信队列,先上代码:

  1. //@RabbitListener(queues = {RabbitMQConfig.DEAD_LETTER_QUEUE_NAME})
  2. @Component
  3. public class DeadLetterQueueListener {
  4. @Resource
  5. private RabbitTemplate rabbitTemplate;
  6. @Resource
  7. private UserMsgMapper userMsgMapper;
  8. @RabbitListener(queues = {RabbitMQConfig.DEAD_LETTER_QUEUE_NAME})
  9. @RabbitHandler
  10. public void processHandler(String msg, Channel channel, Message message) throws IOException {
  11. try {
  12. UserMsg userMsg = JSON.parseObject(new String(message.getBody()), UserMsg.class);
  13. // 模拟发送短信
  14. int num = new Random().nextInt(10);
  15. if (num >5){
  16. // 发送成功
  17. // 更新数据库记录
  18. System.out.println("消息【" + userMsg.getId() + "】发送成功,失败次数:" + userMsg.getFailCount());
  19. userMsgMapper.update(userMsg);
  20. }else {
  21. // 重新发到业务队列中
  22. int failCount = userMsg.getFailCount()+1;
  23. if (failCount > 5){
  24. System.out.println("消息【"+ userMsg.getId() +"】发送次数已到上线");
  25. userMsgMapper.update(userMsg);
  26. }else {
  27. userMsg.setFailCount(failCount);
  28. String msgJson = JSON.toJSONString(userMsg);
  29. System.out.println("消息【"+ userMsg.getId() +"】发送失败,失败次数为:"+ userMsg.getFailCount());
  30. userMsgMapper.update(userMsg);
  31. MessagePostProcessor messagePostProcessor = message2 -> {
  32. // 如果配置了 params.put("x-message-ttl", 5 * 1000); 那么这一句也可以省略,具体根据业务需要是声明 Queue 的时候就指定好延迟时间还是在发送自己控制时间
  33. message2.getMessageProperties().setExpiration(1 * 1000 * 60 + "");
  34. return message2;
  35. };
  36. rabbitTemplate.convertAndSend(RabbitMQConfig.BUSINESS_EXCHANGE_NAME,RabbitMQConfig.BUSINESS_QUEUE_ROUTING_KEY,msgJson,messagePostProcessor);
  37. }
  38. }
  39. channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
  40. } catch (Exception e) {
  41. System.err.println("消息即将再次返回队列处理...");
  42. channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);
  43. }
  44. }
  45. }

其实非常简单,首先在 @RabbitListener 注解中加上自己需要监听的死信队列,我们可以发现这个注解可加载类上,也可以加载处理消息的方法上;当然了,还需要在消费消息的方法上加上注解 @RabbitHandler。

在消费消息的逻辑中,如果是业务处理成功了,也就是重试成功了,此时不需做其他操作;而如果重试失败了,需要重新发送一个消息到业务 Queue,表示又要重试一次。最后,我们需要调用 channel#basicAck() 表示消息消费成功~

在给业务 Queue 发送消息之前,我们记得给消息设置一下过期时间,还是利用 MessagePostProcessor 来完成。

四、最后

到此基本就结束了。

但是我们还有一个点要注意:就是如果我们使用 RabbitMQ 来做重试机制,我们一定要保证 RabbitMQ 的高可用,这时候我们一般推荐使用镜像集群模式,而不是普通集群模式。因为普通集群模式中,每个实例都只是保存其他实例中 queue 的元数据,只要一个实例宕机的,它所负责的 queue 都不能再被使用了。而镜像集群模式中,每个实例都会保存所有 queue ,这样能保证数据 100% 的不丢失!当然了,如果不追求高并发,使用主备模式也还是可以滴~

大家如果对上面的例子还感兴趣,可到我的 github 看看:死信队列完成重试机制

要做重试机制,就只能选择 DelayQueue ?其实 RabbitMQ 它上它也行!的更多相关文章

  1. 精讲RestTemplate第8篇-请求失败自动重试机制

    本文是精讲RestTemplate第8篇,前篇的blog访问地址如下: 精讲RestTemplate第1篇-在Spring或非Spring环境下如何使用 精讲RestTemplate第2篇-多种底层H ...

  2. ENode 1.0 - 消息的重试机制的设计思路

    项目开源地址:https://github.com/tangxuehua/enode 上一篇文章,简单介绍了enode框架中消息队列的设计思路,本文介绍一下enode框架中关系消息的重试机制的设计思路 ...

  3. Selenium 定位元素原理,基本API,显示等待,隐式等待,重试机制等等

    Selenium  如何定位动态元素: 测试的时候会遇到元素每次变动的情况,例如: <div id="btn-attention_2030295">...</di ...

  4. SpringCloud | FeignClient和Ribbon重试机制区别与联系

    在spring cloud体系项目中,引入的重试机制保证了高可用的同时,也会带来一些其它的问题,如幂等操作或一些没必要的重试. 今天就来分别分析一下 FeignClient 和 Ribbon 重试机制 ...

  5. spring-retry 重试机制

    业务场景 应用中需要实现一个功能: 需要将数据上传到远程存储服务,同时在返回处理成功情况下做其他操作.这个功能不复杂,分为两个步骤:第一步调用远程的Rest服务逻辑包装给处理方法返回处理结果:第二步拿 ...

  6. springboot系列——重试机制原理和应用,还有比这个讲的更好的吗(附完整源码)

    1. 理解重试机制 2. 总结重试机制使用场景 3. spring-retry重试组件 4. 手写一个基于注解的重试组件 5. 重试机制下会出现的问题 6. 模板方法设计模式实现异步重试机制 如果有, ...

  7. [转载]rabbitmq可靠发送的自动重试机制

    转载地址http://www.jianshu.com/p/6579e48d18ae http://www.jianshu.com/p/4112d78a8753 接这篇 在上文中,主要实现了可靠模式的c ...

  8. 【Dubbo 源码解析】07_Dubbo 重试机制

    Dubbo 重试机制 通过前面 Dubbo 服务发现&引用 的分析,我们知道,Dubbo 的重试机制是通过 com.alibaba.dubbo.rpc.cluster.support.Fail ...

  9. Rocket重试机制,消息模式,刷盘方式

    一.Consumer 批量消费(推模式) 可以通过 consumer.setConsumeMessageBatchMaxSize(10);//每次拉取10条 这里需要分为2种情况 Consumer端先 ...

随机推荐

  1. Hyperledger Fabric 2.1 搭建教程

    Hyperledger Fabric 2.1 搭建教程 环境准备 版本 Ubuntu 18.04 go 1.14.4 fabric 2.1 fabric-sample v1.4.4 nodejs 12 ...

  2. day56 js收尾,jQuery前戏

    目录 一.原生js事件绑定 1 开关灯案例 2 input框获取焦点,失去焦点案例 3 实现展示当前时间,定时功能 4 省市联动 二.jQuery入门 1 jQuery的两种导入方式 1.1 直接下载 ...

  3. java IO流 (八) RandomAccessFile的使用

    1.随机存取文件流:RandomAccessFile 2.使用说明: * 1.RandomAccessFile直接继承于java.lang.Object类,实现了DataInput和DataOutpu ...

  4. redis(十六):Redis 安装,部署(LINUX环境下)

    第一步:下载安装包 访问https://redis.io/download  到官网进行下载.这里下载最新的4.0版本. 第二步:安装 1.通过远程管理工具,将压缩包拷贝到Linux服务器中,执行解压 ...

  5. 机器学习实战基础(十四):sklearn中的数据预处理和特征工程(七)特征选择 之 Filter过滤法(一) 方差过滤

    Filter过滤法 过滤方法通常用作预处理步骤,特征选择完全独立于任何机器学习算法.它是根据各种统计检验中的分数以及相关性的各项指标来选择特征 1 方差过滤 1.1 VarianceThreshold ...

  6. JVM垃圾回收(五)

    低延迟垃圾收集器 衡量垃圾收集器的三项最重要的指标是: 内存占用(Footprint).吞吐量(Throughput)和延迟(Latency).三者总体的表现会随技术进步而越来越好,但是要在这三个方面 ...

  7. JavaScript 基础 学习(三)

    JavaScript 基础 学习(三) 事件三要素 ​ 1.事件源: 绑定在谁身上的事件(和谁约定好) ​ 2.事件类型: 绑定一个什么事件 ​ 3.事件处理函数: 当行为发生的时候,要执行哪一个函数 ...

  8. 关于Java8的精心总结

    前言 ​ 最近公司里比较新的项目里面,看到了很多关于java8新特性的用法,由于之前自己对java8的新特性不是很了解也没有去做深入研究,所以最近就系统的去学习了一下,然后总结了一篇文章第一时间和大家 ...

  9. React Native 报错 Error: spawn EACCES 权限

    权限不足,运行命令修改权限 chmod android/gradlew

  10. javascript中的堆栈、深拷贝和浅拷贝、闭包

    堆栈 在javascript中,堆内存是用来存放引用类型的空间环境 而栈内存,是存储基本类型和指定代码的环境 在对象中的属性名具有唯一性,数字属性名=字符串属性名,但是在测试的时候你会发现,好像所有属 ...