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

言归正传,进入今天的主题,延迟消息推送。啥? 消息推送,还要延迟,产品经理,你的要求是不是有点... 程序猿桌上的大刀已经安耐不住了。
背景描述
我们的场景是这样的:需要时刻监控车辆的网络状态,当该车处于离线状态的时候,是需要向服务器告警的(好吧,不是我们故意监听用户的隐私,而是国家监控平台需要哈 😺),我们作为下游业务,只能收到车辆离线的事件通知。那么问题就来了,难道我们收到离线事件的时候就立即向平台告警么? 这显然是不合理的,为啥?因为车辆离线的原因存在很多客观因素,如网络抖动导致的离线,车辆进入树荫底下,或者进入地下停车场也会导致车辆的短暂离线。那就需要在收到车辆离线的时候,延迟一小段时间,如5min,再去查询车辆是否仍然离线,如果这个车辆任然离线,好了,可以推送告警到平台了。
心中暗想,需求是个好需求,但是怎么做才能显得逼格够高呢?
抬头望去,旁边走过一位穿着格子衬衣的中年男子,手中mac满是划痕,看着快秃顶的头发,心想尼玛顶级架构师应该有比较优雅的方案吧?

龙哥觉得,我们遇到问题得先有自己的思考,要结合实际的业务,团队的技术栈等等综合考虑,毕竟,没有最好的技术,只有最合适的技术,对吧,杀鸡焉用牛刀。
这个问题的关键点就是,如何捕捉到这个延时到期的事件。
参考方案
1. 定时轮询机制:
将车辆离线的事件数据存储到db,做好索引,例如以车辆的唯一标识车架号做索引,然后通过定时任务去扫表,判断车辆是否在延时的时间段内,任然离线,如果任然离线,就推告警。
使用此种方案,需要注意:
- 你的db数据量要有评估,因为单表db量达到千万级别的时候性能下降比较明显。
- 既然是定时任务,那么定时的间隔需要去好好评估,因为太小的时间间隔,可能导致表还未扫完,又进入到下一个定时周期了,这样会导致线程堆积。
- 考虑时间误差带来的容忍度,因为你的定时任务执行起来肯定是有时间周期的,那么可能带来的问题是,你期望的延迟5分钟推送,可能4分55秒,或者5分5秒才推送出来。
- 轮询带来的效率问题,因为可能你扫描了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具备下面几个特点:
- 可靠性:RabbitMQ可通过队列持久化,交换机持久化,消息持久化及ACK回应等机制保证可靠性
- 支持多种语言与协议:RabbitMQ几乎支持所有的编程语言,还支持AMQP,STOMP,MQTT等多种协议
- 管理界面:RabbitMQ有一个可视化的管理界面可以用来直观的查看RabbitMQ的状态及运行情况
- 可灵活的扩展:多个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的延迟消息队列方式,接收延迟消息
技术没有最好,只有最合适。多思考多总结,找到最适合自己的方案。
如果本篇博客有任何错误,请批评指教,不胜感激 !
如果有更好的方案,请给我留言,送花花❀❀❀