Java架构直通车——使用RabbitMQ延时队列做回调

前言

最近做区块链项目时,由于区块链做存证的时候会有延迟(区块链默认会收集2秒内所有的transanction,然后做统一计算,打包上链),所以后端请求该接口的时候,不能立刻得到返回,只能返回状态为200的成功响应。

考虑到在不增加复杂度的情况下,不能要求客户端做二次请求,因为客户端做二次请求的时候是不知道服务端是否已经处理完成该上链请求的。所以,这部分的处理只能服务端完成后做 延时回调

“延时回调”准确的说是实现 阶梯性异步回调通知,即如果收不到客户端的答复,会以定期的重试策略来做retry。

解决方案


  • 定时任务

看起来似乎使用定时任务,一直轮询数据,每2秒查一次,取出需要被处理的数据,然后处理不就完事了吗?

如果数据量比较少,确实可以这样做,比如:对于“如果账单一周内未支付则进行自动结算”这样的需求,如果对于时间不是严格限制,而是宽松意义上的一周,那么每天晚上跑个定时任务检查一下所有未支付的账单,确实也是一个可行的方案。
但对于数据量比较大,并且时效性较强的场景,如:“订单十分钟内未支付则关闭“,短期内未支付的订单数据可能会有很多,活动期间甚至会达到百万甚至千万级别,对这么庞大的数据量仍旧使用轮询的方式显然是不可取的,很可能在一秒内无法完成所有订单的检查,同时会给数据库带来很大压力,无法满足业务要求而且性能低下。


  • 延迟队列DelayQueue

采取jdk自带的延迟队列能很好的优化传统的处理方案,但是该方案的弊、端也是非常致命的,所有的消息数据都是存于内存之中,一旦宕机或重启服务队列中数据就全无了,而且也无法进行扩展。

@Component
public class Broker {
    DelayQueue<Item> delayQueue;
    ...
}
    
@Data
public class Item<T> implements Delayed {
    private T object;
    private long time;
    ...
     @Override
    public long getDelay(TimeUnit unit) {
        return time - System.currentTimeMillis();
    }

    @Override
    public int compareTo(Delayed o) {
        Item item = (Item) o;
        long diff = this.time - item.time;
        if (diff <= 0) {// 改成>=会造成问题
            return -1;
        } else {
            return 1;
        }
    }
}

  • Redis延时队列

设计主要包含以下几点:

  1. 将整个Redis当做消息池,以KV形式存储消息
  2. 使用ZSET做优先队列,按照Score维持优先级
  3. 使用LIST结构,以先进先出的方式消费
  4. ZSET和LIST存储消息地址(对应消息池的每个KEY)
  5. 自定义路由对象,存储ZSET和LIST名称,以点对点的方式将消息从ZSET路由到正确的LIST
  6. 使用定时器维护路由
  7. 根据TTL规则实现消息延迟
    在这里插入图片描述

  • RabbitMQ延迟队列

一台普通的rabbitmq服务器单队列容纳千万级别的消息还是没什么压力的,而且rabbitmq集群扩展支持的也是非常好的,并且队列中的消息是可以进行持久化,即使我们重启或者宕机也能保证数据不丢失。

在这里插入图片描述

引入RabbitMQ

引入RabbitMQ的延时队列,在消息存入区块链的时候,向RabbitMQ发送延迟消息:2s后查询上链结果,并将上链结果持久化,然后发送至客户端。
这时,客户端可能有两种结果:
(1)处理成功,向服务端返回200响应。
(2)处理失败或者网络波动,服务端收到非200的其他响应或者收不到响应。对于第二种结果,需要将该消息以阶梯性延时的方式发送至延迟队列来做处理。

众所周知,引入消息队列会增加系统的复杂度:

  1. 对于客户端需要去保证幂等性,因为客户端收到该消息是要做持久化的,在insert的时候做检查即可。
  2. 对于服务端,需要保障消息投递的可靠性,具体实现方案参考:Java架构直通车——MQ 生产端可靠性投递和消费端幂等性保障方案

RabbitMQ延时队列概述

首先要理解RabbitMQ上的几个概念:

  • 存活时间(Time-To-Live 简称 TTL),分别有三种TTL的设置模式:
    • x-message-ttl ,该属性是在创建队列的时候 ,在arguments的map中配置;该参数的作用是设置当前队列中所有的消息的存活时间
    • x-expires 该属性也是在arguments中配置;其作用是设置当前队列在N毫秒中(不能为 0,且为正整数),就删除该队列;“未使用”意味着队列没有消费者,队列尚未重新声明,并且至少在有效期内未调用basicGet (basicGet 是手动拉取指定队列中的一条消息)
    • AMQP.BasicProperties配置中的expiration 属性,前两者都是基于队列的TTL,该属性是基于单条消息的TLL用于配置每条消息在队列中的存活时间
  • 死信消息,造成这样的信息有以下几种情况:
    • 消息被拒绝,即消费者没有成功确认消息被消费
    • 消息TTL过期
    • 超出队列长度限制
  • “死信” 交换器:
    与普通的Exchange差不多,路由的方式也是Fanout、Direct、Topic和HEADER四种;区别在于过了存活期的消息才会经由死信交换器路由到死信队列上。

所以,如果要使用RabbitMQ的延时队列:

  1. 首先在Connection中创建一个Channel,通过Channel声明两个交换器,一个是 用来接收“死信”的交换器ExchangeA,一个是延时通知数据的交换器ExchangeB。
  2. 声明两个队列,一个"死信"队列queueA,一个延时通知数据队列queueB。
  3. 根据路由规则将ExchangeA绑定queueA,ExchangeB绑定queueB。并设置相应的routingKey。

通过上述方式,当一个设置了TTL的消息发送到ExchangeB上的时候,会被路由到queueB。由于queueB没有设置Consumer,所以queueB上的消息会因为没有消费而过期,过期后的消息会经由ExchangeA路由到queueA上。
在这里插入图片描述

代码实现:初始化RabbitMQ

后端实现最核心的一个步骤是:声明交换机、队列以及他们的绑定关系

rabbitMQ包会采用懒加载的方式,在第一次向服务端请求的时候,会分别在服务端生成Exchange、Queue以及他们的绑定关系。


@Configuration
@Slf4j
public class RabbitMQConfig {
    @Value("${spring.rabbitmq.host}")
    private String host;
    @Value("${spring.rabbitmq.port}")
    private int port;
    @Value("${spring.rabbitmq.username}")
    private String username;
    @Value("${spring.rabbitmq.password}")
    private String password;
    /**
     * 调用正常队列过期时间 单位为微秒.
     */
    private long makeCallExpire = 2000L;

    //交换机
    public static final String DEFALT_EXCHANGE = "blockcert.default";
    public static final String DEAD_EXCHANGE = "blockcert.dead";

    //路由键  用于把生产者的数据绑定到交换机上的
    public static final String CERTIFICATION_ROUTINGKEY = "cert";

    //队列
    public static final String DEFALT_QUEUE = "blockcert.default.queue";
    public static final String DEAD_QUEUE = "blockcert.dead.queue";

    @Bean
    public ConnectionFactory connectionFactory() {
        log.info("rabbitmq的配置信息为host:[" + host + "]port:[" + port + "]username:[" + username + "]password:[" + password + "]");
        CachingConnectionFactory connectionFactory = new CachingConnectionFactory(host, port);
        connectionFactory.setUsername(username);
        connectionFactory.setPassword(password);
        connectionFactory.setVirtualHost("/");
        connectionFactory.setPublisherConfirms(true);  //自动确认机制 false为手动确认
        return connectionFactory;
    }

    @Bean
    @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)     //必须是prototype类型
    public RabbitTemplate rabbitTemplate() {
        RabbitTemplate template = new RabbitTemplate(connectionFactory());
        template.setMessageConverter(jsonMessageConverter());
        return template;
    }

    /**
     * 创建交换机
     */
    @Bean("businessExchange")
    public DirectExchange defaultExchange() {
        return new DirectExchange(DEFALT_EXCHANGE);
    }

    /**
     * 创建死信交换机
     */
    @Bean("deadLetterExchange")
    public DirectExchange lindExchangeDl() {
        return (DirectExchange) ExchangeBuilder.directExchange(DEAD_EXCHANGE).durable(true)
                .build();
    }

    /**
     * 创建业务队列
     */
    @Bean("businessQueue")
    public Queue lindQueue() {
        return QueueBuilder.durable(DEFALT_QUEUE)
                .withArgument("x-dead-letter-exchange", DEAD_EXCHANGE)//设置死信交换机,并非绑定规则
                .withArgument("x-message-ttl", makeCallExpire)
                .withArgument("x-dead-letter-routing-key", CERTIFICATION_ROUTINGKEY)//设置死信routingKey
                .build();
    }

    /**
     * 创建死信队列
     */
    @Bean("deadLetterQueue")
    public Queue lindDelayQueue() {
        return QueueBuilder.durable(DEAD_QUEUE).build();
    }

    /**
     * 绑定普通队列.
     *
     * @return
     */
    @Bean
    public Binding bindBuilders(@Qualifier("businessQueue") Queue queue,
                                @Qualifier("businessExchange") DirectExchange exchange) {
        return BindingBuilder.bind(queue).to(exchange).with(CERTIFICATION_ROUTINGKEY);
    }

    /**
     * 绑定死信队列规则:即绑定死信交换机和死信队列
     */
    @Bean
    public Binding bindDeadBuilders(@Qualifier("deadLetterQueue") Queue queue,
                                    @Qualifier("deadLetterExchange") DirectExchange exchange) {
        return BindingBuilder.bind(queue).to(exchange).with(CERTIFICATION_ROUTINGKEY);
    }

    //json消息转换器保证接收和发送的格式一致
    @Bean
    public MessageConverter jsonMessageConverter() {
        return new Jackson2JsonMessageConverter();
    }

    @Bean
    public RabbitListenerContainerFactory<?> rabbitListenerContainerFactory(ConnectionFactory connectionFactory) {
        SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
        factory.setConnectionFactory(connectionFactory);
        factory.setMessageConverter(jsonMessageConverter());
        return factory;
    }
}

当然,在做业务队列声明的时候,使用x-dead-letter-exchange表示该正常队列上消息过期后连接的死信交换机,使用x-message-ttl表示队列上消息的过期时间。显然,如果要对过期时间做自定义,需要一种更通用的方案才能满足需求,那么就只能将TTL设置在消息属性里了。

如果要将TTL设置在消息属性里,那么就不需要声明x-message-ttl属性了,直接在消息里附带TTL即可。但是,如果使用在消息属性上设置TTL的方式,消息可能并不会按时“死亡“,因为RabbitMQ只会检查第一个消息是否过期,如果过期则丢到死信队列,索引如果第一个消息的延时时长很长,而第二个消息的延时时长很短,则第二个消息并不会优先得到执行

解决这个问题的方案是加入插件🔗,下载rabbitmq_delayed_message_exchange插件,然后解压放置到RabbitMQ的插件目录。接下来,进入RabbitMQ的安装目录下的sbin目录,执行下面命令让该插件生效,然后重启RabbitMQ。

rabbitmq-plugins enable rabbitmq_delayed_message_exchange

代码实现:池化RabbitMQ

注意:下面这段代码。RabbitTemplate使用了Prototype而不是Singleton,是因为如果使用单例RabbitTemplate性能会比较慢。

    @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)     //必须是prototype类型
    public RabbitTemplate rabbitTemplate() {
        RabbitTemplate template = new RabbitTemplate(connectionFactory());
        template.setMessageConverter(jsonMessageConverter());
        return template;
    }

一个很好的解决办法是使用池化的RabbitMQ,每次使用RabbitTemplate的时候去连接池里拿取。
参考:Java架构直通车——RabbitMQ池化方案

总结

延时队列在需要延时处理的场景下非常有用,使用RabbitMQ来实现延时队列可以很好的利用RabbitMQ的特性,如:消息可靠发送、消息可靠投递、死信队列来保障消息至少被消费一次以及未被正确处理的消息不会被丢弃。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值