如何优雅的实现延迟消息推送?

2,846 阅读7分钟
tips:疫情期间,大家每天都是带口罩上下班,看见谁都有嫌疑,心中暗想,卧槽,他不会感染了吧,我听到他咳了... 卧槽, 听说他是湖北的....特殊时期,守望相助,共克时坚。


言归正传,进入今天的主题,延迟消息推送。啥? 消息推送,还要延迟,产品经理,你的要求是不是有点... 程序猿桌上的大刀已经安耐不住了。

背景描述

我们的场景是这样的:需要时刻监控车辆的网络状态,当该车处于离线状态的时候,是需要向服务器告警的(好吧,不是我们故意监听用户的隐私,而是国家监控平台需要哈 😺),我们作为下游业务,只能收到车辆离线的事件通知。那么问题就来了,难道我们收到离线事件的时候就立即向平台告警么? 这显然是不合理的,为啥?因为车辆离线的原因存在很多客观因素,如网络抖动导致的离线,车辆进入树荫底下,或者进入地下停车场也会导致车辆的短暂离线。那就需要在收到车辆离线的时候,延迟一小段时间,如5min,再去查询车辆是否仍然离线,如果这个车辆任然离线,好了,可以推送告警到平台了。

心中暗想,需求是个好需求,但是怎么做才能显得逼格够高呢?

抬头望去,旁边走过一位穿着格子衬衣的中年男子,手中mac满是划痕,看着快秃顶的头发,心想尼玛顶级架构师应该有比较优雅的方案吧?


龙哥觉得,我们遇到问题得先有自己的思考,要结合实际的业务,团队的技术栈等等综合考虑,毕竟,没有最好的技术,只有最合适的技术,对吧,杀鸡焉用牛刀。

这个问题的关键点就是,如何捕捉到这个延时到期的事件。

参考方案

1. 定时轮询机制:

将车辆离线的事件数据存储到db,做好索引,例如以车辆的唯一标识车架号做索引,然后通过定时任务去扫表,判断车辆是否在延时的时间段内,任然离线,如果任然离线,就推告警。

使用此种方案,需要注意:

  1. 你的db数据量要有评估,因为单表db量达到千万级别的时候性能下降比较明显。
  2. 既然是定时任务,那么定时的间隔需要去好好评估,因为太小的时间间隔,可能导致表还未扫完,又进入到下一个定时周期了,这样会导致线程堆积。
  3. 考虑时间误差带来的容忍度,因为你的定时任务执行起来肯定是有时间周期的,那么可能带来的问题是,你期望的延迟5分钟推送,可能4分55秒,或者5分5秒才推送出来。
  4. 轮询带来的效率问题,因为可能你扫描了100万条记录,只有几百条满足要求,定时任务一直在空跑。


2. 使用Redis过期推送机制



过期事件的捕捉,我第一个想到的就是redis不是有天然的过期时间设置么? 那既然有过期时间设置,那肯定是能够把过期事件推送出来的吧? 果不其然,一顿猛操作(查询api文档),确实是支持。

其实,它的核心原理是通过消息订阅的方式,订阅redis内部的一个过期主题: __keyevent@<db>__:expired,就能够收到过期事件的消息。注意该主题是一个固定格式,其中db标识你要关注的db号。

这里写图片描述

本质上就是订阅内部主题。



前提:在业务方收到上游业务推送过来的车辆离线时间之后,就去redis上面记录,并设置其过期时间为5分钟。

好了,下面我们只需要处理好消息过期推送的逻辑就好了。

首先我们需要向spring中注入一个MessageListenerContainer, 来监听需要我们需要关心的主题

package com.zl.common.config;

import com.zl.common.listener.RedisExpiredListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;

/**
 * @author jacky
 * @date 2020-03-07
 */
@Configuration
public class RedisListenerConfig {

    @Autowired
    private RedisProperties redisProperties;

    @Autowired
    private RedisExpiredListener redisExpiredListener;

    @Bean
    RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory) {

        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        // redisProperties.getDatabase() 是获取配置文件的db号,如5号db
        container.addMessageListener(redisExpiredListener, new PatternTopic("__keyevent@" + redisProperties.getDatabase() + "__:expired")); 
        return container;
    }
}

其次,想spring容器中注册一个监听器,获取redis推送过来的过期key的消息。

/**
 * @author jacky
 * @date 2020-03-07
 */
@Slf4j
@Component
public class RedisExpiredListener implements MessageListener {

    @Autowired
    private StringRedisTemplate redisTemplate;


    @Override
    public void onMessage(Message message, byte[] bytes) {

        byte[] body = message.getBody(); // 消息体数据
        byte[] channel = message.getChannel();

        String expiredRedisKey = message.toString();

        // TODO
        // 再次查询车辆网络状态,如果任然离线,可以发起推送了。


    }

   
}

通过redis的方式,完美解决轮询的效率问题,卧槽,我真牛逼,看来离架构师就多了几根头发而已。

开开心心上线,以为万事大吉,但是... 几天后就遇到了投诉,平台投诉,好多车辆离线都没有把告警信息推送出来。卧槽,不可能啊,难道网络丢包,这丢的也太多了吧,难道使用redis留坑了?

再一顿猛操作,查询官方文档,阿*云的文档(我们使用的阿*云redis方案),原来redis的过期消息事件推送是不保证可靠性的,而且还存在版本兼容性问题。我勒个去... 这不是坑爹么?

原来和架构师的差距还真不是几根头发的事情,是一撮头发的事情。😁

使用redis过期方案,需要注意:redis订阅发布简单,轻量级,延迟比较低,适合业务量不大非核心的一些订阅功能,再重复一句:不保证消息的可靠投递

3. 使用传统的消息队列,如RabbitMQ

RabbitMQ具备下面几个特点:

  1. 可靠性:RabbitMQ可通过队列持久化,交换机持久化,消息持久化及ACK回应等机制保证可靠性  
  2. 支持多种语言与协议:RabbitMQ几乎支持所有的编程语言,还支持AMQP,STOMP,MQTT等多种协议  
  3. 管理界面:RabbitMQ有一个可视化的管理界面可以用来直观的查看RabbitMQ的状态及运行情况  
  4. 可灵活的扩展:多个RabbitMQ节点可以组成一个集群,队列可以在集群中的机器上设置镜像,使得在部分节点出现问题的情况下队仍然可用 

相比于其他消息队列,RabbitMQ是支持延迟消息的。

使用RabbitMQ实现延迟队列的两种方式

  • TTL + DLX
  • 使用延迟插件

TTL+DLX是旧版本的RabbitMQ的方案,既然有新的RabbitMQ版本出来了,那就玩点新花样吧。

首先下载延迟插件:www.rabbitmq.com/community-p… 注意:支持RabbitMQ 3.5.8及更高版本

高版本的RabbitMQ已经支持延迟Exchange了。


其次,将解压后的ez结尾的文件放到/rabbitmq/plugins/目录下 

输入 rabbitmq-plugins list 指令查看延迟插件是否存在 :rabbitmq_delayed_message_exchange

启用延迟插件 启动插件:rabbitmq-plugins enable rabbitmq_delayed_message_exchange ——(关闭插件:rabbitmq-plugins disable rabbitmq_delayed_message_exchange) 访问可视化界面,有如下则说明启动成功 


使用延时Exchange(消息发送到Exchange,Exchange等待指定时间后发送到匹配队列) 

注意x-delayed-type参数必须有,不然会报错。

package com.zl.rabbitmq.demo;

import com.zl.rabbitmq.RabbitConnectionFactory;
import com.rabbitmq.client.AMQP;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;

import java.util.HashMap;
import java.util.Map;

public class DelayExchangeDemo {
    public static void main(String[] args) throws Exception {
        Connection connection = RabbitConnectionFactory.getConnection();
        Channel channel = connection.createChannel();
        Map<String, Object> arguments = new HashMap<>();
        arguments.put("x-delayed-type", "direct");
        channel.exchangeDeclare("delay.plugin.exchange", "x-delayed-message", true, false, arguments);
        channel.queueDeclare("delay.plugin.queue", true, false, false, null);
        channel.queueBind("delay.plugin.queue", "delay.plugin.exchange", "delay.plugin.routingKey");
        Map<String, Object> headers = new HashMap<>();
        //延迟10s后发送
        headers.put("x-delay", 10000); 
        AMQP.BasicProperties.Builder builder = new AMQP.BasicProperties.Builder();
        builder.headers(headers);
        channel.basicPublish("delay.plugin.exchange", "delay.plugin.routingKey", builder.build(), "该消息将在10s后发送到队列".getBytes());
        channel.close();
        connection.close();
    }
}

在这里插入图片描述

10s之后,发现消息队列有了。
在这里插入图片描述

小结

我们使用了三种方案实现消息的延迟推送。

1. 轮询db,逐个判断是否到期

2.订阅redis的内部过期主题,接收过期事件的推送

3.使用RabbitMQ的延迟消息队列方式,接收延迟消息

技术没有最好,只有最合适。多思考多总结,找到最适合自己的方案。

如果本篇博客有任何错误,请批评指教,不胜感激 !

如果有更好的方案,请给我留言,送花花❀❀❀