一、功能介绍

  要实现一个消息的定时发送功能,也就是让消息可以在某一天某一个时间具体节点进行发送。而我们公司的业务场景是类似短信的业务,而且数量不小,用户会进行号码、消息内容、定时发送时间等信息的提交。等到了设定的定时时间,则进行消息的发送工作。

二、思考实现逻辑

  前提准备:

    MySQL

    RocketMQ,最好broker开启队列自动创建的配置

  刚开始我想的是基于MySQL去实现定时发送,后来觉得这种扫描方式单线程的时候并发能力不够,多线程也得需要做并发控制,还得做一些任务的调度,比如线程执行一半卡死或者异常的时候,还得做任务的补偿工作。所以,后面我选择了通过组件的方式实现,这是我想到了消息队列的延迟发送功能,刚好我司系统用到了RocketMQ,我就想通过该组件实现该消息的定时发送功能。RocketMQ的文件默认只保存72小时,这个要注意一下,有需要调整时间的需要调整一下。我也不建议调整的太大,这样容易引起系统的资源浪费。

  具体实现逻辑如下:

  1)用户创建消息,记录到数据库(号码,内容,定时发送时间)

  2)如果是是可以立即发送的消息,发送消息到RocketMQ的立即发送主题(TOPIC_NOW_SEND)中,后续有线程消费该消息,直接进行数据发送。

  3)如果是需要定时发送的消息,发送消息到RocketMQ的延迟发送主题(TOPIC_DELAY_SEND)中。

  这里有一些细节设置:

    tags:可以用于同一主题下区分不同类型的消息。此处可以设定为当前日期(我是用的是YYYYMMDD,例如:20220730),因为设定为需要发送的当天日期后,程序就可以通过该字段分辨是否需要处理。后续消费者就可以通过当天日期筛选需要当天发送的消息,过滤掉不是当天需要发送的消息。

    keys:索引,可以用于查询消息。我们设定的格式可以是【订单号+消息定时发送的时间戳(可以是毫秒级)】,例如:FS20220730123456+16591914490001

  4)启动两个(或者多个,这个和文件保存的时间有关系)消费者进行轮换,这里我称之为masterConsumer(启动时负责消费前一天的数据)和slaveConsumer(启动时负责消费当天的数据)。这里我设计的初衷是每天有自己的消费者,例如20220730消费tags=20220730的数据,20220731消费tags=20220731的数据。这里需要消费前一天的数据原因是,需要防止出现晚上23:59分提交消息的时候,当天没处理完。所以,需要在第二天进行额外的补偿操作。

  程序启动第1天(例如当天是20220730):

    masterConsumer:MASTER_CONSUMER_TOPIC_DELAY_SEND  消费tags=20220729
    slaveConsumer:SLAVE_CONSUMER_TOPIC_DELAY_SEND  消费tags=20220730

  程序启动第2天(例如当天是20220731):
    slaveConsumer:SLAVE_CONSUMER_TOPIC_DELAY_SEND  消费tags=20220730
    masterConsumer:MASTER_CONSUMER_TOPIC_DELAY_SEND  消费tags=20220731

  程序启动第3天(例如当天是20220801):
    masterConsumer:MASTER_CONSUMER_TOPIC_DELAY_SEND  消费tags=20220731
    slaveConsumer:SLAVE_CONSUMER_TOPIC_DELAY_SEND  消费tags=20220801

  程序启动第4天(例如当天是20220802):
    slaveConsumer:SLAVE_CONSUMER_TOPIC_DELAY_SEND  消费tags=20220801
    masterConsumer:MASTER_CONSUMER_TOPIC_DELAY_SEND  消费tags=20220802

  ......

  程序的masterConsumer和slaveConsumer就这样持续轮换消费数据。

  5)消费者的消费数据逻辑:

  消费者需要设置consumeFromWhere参数=CONSUME_FROM_FIRST_OFFSET(默认是ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET,从最新的位置开始消费),这样数据才可以第一次启动从队列的最前位置开始消费。

  消费到数据之后,获取keys的内容,根据之前发送的设定获取消息定时发送的时间戳,然后和当前的时间进行比对。这里要比对的主要原因是当前 RocketMQ 不支持任意时间的延迟。 生产者发送延迟消息前需要设置几个固定的延迟级别,分别对应1s到2h的1到18个延迟级,消息消费失败会进入延迟消息队列,消息发送时间与设置的延迟级别和重试次数有关。

  当前支持的消息延迟级别有:

    private String messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h";

  如果比对时间<=1秒,则直接发送到RocketMQ的立即发送主题(TOPIC_NOW_SEND)中,后续有线程进行消费发送;否则,发送时间根据RocketMQhi吃的延迟级别进行选择,按照最大可支持的时间为准,等待后续的消息重新消费,直到最终消息符合条件,可以投递到RocketMQ的立即发送主题(TOPIC_NOW_SEND)为止。例如:如果比对时间>2h,则重新投递到延迟发送主题(TOPIC_DELAY_SEND)中,延迟发送的等级为18(也就是2h)。如果比对时间>2m,比对时间<3m,则重新投递到延迟发送主题(TOPIC_DELAY_SEND)中,延迟发送的等级为6(也就是2m)。

  6)在第4步骤中的masterConsumer和slaveConsumer的轮换逻辑支持,即在过了0点之后,前一天的consumer需要进行重置。

  

  以上就是我的实现逻辑。

三、敲代码

  1、定义延迟级别与时间的对应关系

package cn.lxw.mq.constant;

import java.util.Arrays;

public enum MessageDelayLevel {
SECOND_1(1, 1 * 1000L),
SECOND_5(2, 5 * 1000L),
SECOND_10(3, 10 * 1000L),
SECOND_30(4, 30 * 1000L),
MINUTE_1(5, 1 * 60 * 1000L),
MINUTE_2(6, 2 * 60 * 1000L),
MINUTE_3(7, 3 * 60 * 1000L),
MINUTE_4(8, 4 * 60 * 1000L),
MINUTE_5(9, 5 * 60 * 1000L),
MINUTE_6(10, 6 * 60 * 1000L),
MINUTE_7(11, 7 * 60 * 1000L),
MINUTE_8(12, 8 * 60 * 1000L),
MINUTE_9(13, 9 * 60 * 1000L),
MINUTE_10(14, 10 * 60 * 1000L),
MINUTE_20(15, 20 * 60 * 1000L),
MINUTE_30(16, 30 * 60 * 1000L),
HOUR_1(17, 1 * 60 * 60 * 1000L),
HOUR_2(18, 2 * 60 * 60 * 1000L),
;
// 1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
private Integer level;
private Long mills; MessageDelayLevel(Integer level, Long mills) {
this.level = level;
this.mills = mills;
} public Integer getLevel() {
return level;
} public void setLevel(Integer level) {
this.level = level;
} public Long getMills() {
return mills;
} public void setMills(Long mills) {
this.mills = mills;
} /**
* 功能描述: <br>
* 〈计算需等待的时间,最长2小时〉
* @Param: [timeMills]
* @Return: {@link MessageDelayLevel}
* @Author: luoxw
* @Date: 2022/7/26 16:33
*/
public static MessageDelayLevel getMaxLevel(long timeMills){
long millsBwt = timeMills - System.currentTimeMillis();
if(millsBwt < 1000L){
millsBwt = 1000L;
}
final long paramMills = millsBwt;
return Arrays.asList(MessageDelayLevel.values()).stream().filter(p -> p.mills.compareTo(paramMills) <= 0).max((c1,c2) -> c1.getLevel().compareTo(c2.getLevel())).orElse(HOUR_2);
} public static void main(String[] args) {
getMaxLevel(2 * 60 * 60 * 1000L);
getMaxLevel(2 * 60 * 60 * 1000L + 1);
getMaxLevel(2 * 60 * 60 * 1000L - 1);
getMaxLevel( 6 * 1000L);
getMaxLevel( 5 * 1000L);
getMaxLevel( System.currentTimeMillis());
}
}

  2、延迟消息消费逻辑

package cn.lxw.task;

import cn.hutool.core.thread.ThreadUtil;
import com.alibaba.rocketmq.client.consumer.DefaultMQPushConsumer;
import com.alibaba.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import com.alibaba.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import com.alibaba.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import com.alibaba.rocketmq.client.producer.DefaultMQProducer;
import com.alibaba.rocketmq.client.producer.SendResult;
import com.alibaba.rocketmq.common.message.MessageExt;
import cn.lxw.Consumer;
import cn.lxw.DateUtil;
import cn.lxw.constant.LogConst;
import cn.lxw.context.AppEnvContext;
import cn.lxw.enums.EnumMQTopic;
import cn.lxw.mq.constant.MessageDelayLevel;
import cn.lxw.util.ProducerUtil;
import lombok.extern.slf4j.Slf4j; import java.util.Date;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger; @Slf4j
public class Send_DelayMessageTask implements Runnable {
private DefaultMQProducer defaultSendProducer;
private DefaultMQProducer defaultDelayProducer; private static volatile String currentDate = DateUtil.operateDate(new Date(), 0);
private DefaultMQPushConsumer masterDelayConsumer;
private static volatile String lastDate = DateUtil.operateDate(new Date(), -1);
private DefaultMQPushConsumer slaveDelayConsumer;
// 2个消费者的时候,奇数更新master消费者,偶数更新slave消费者
private static volatile AtomicInteger closeCnt = new AtomicInteger();   
public Send_DelayMessageTask() {
try {
String rocketMQAddr = AppEnvContext.getPropValue("rocketmq.address");
defaultSendProducer = ProducerUtil.init(rocketMQAddr, "PRODUCER_TOPIC_NOW_SEND");
defaultDelayProducer = ProducerUtil.init(rocketMQAddr, "PRODUCER_TOPIC_DELAY_SEND");
// 主从消费者交替进行数据消费。
// 初始化的时候,主消费者消费昨天(1月1日)的数据,从消费者消费今天(1月2日)的数据。
int consumeThreadMin = AppEnvContext.getPropValueIntOrDefault("delay.consumer.consumeThreadMin", 10);
int consumeThreadMax = AppEnvContext.getPropValueIntOrDefault("delay.consumer.consumeThreadMax", 10);
int consumeMessageBatchMaxSize = AppEnvContext.getPropValueIntOrDefault("delay.consumer.consumeMessageBatchMaxSize", 10);
masterDelayConsumer = Consumer.getInstance(
"TOPIC_DELAY_SEND",
"MASTER_CONSUMER_TOPIC_DELAY_SEND",
rocketMQAddr,
lastDate,
consumeThreadMin,
consumeThreadMax,
consumeMessageBatchMaxSize
);
slaveDelayConsumer = Consumer.getInstance(
"TOPIC_DELAY_SEND",
"SLAVE_CONSUMER_TOPIC_DELAY_SEND",
rocketMQAddr,
currentDate,
consumeThreadMin,
consumeThreadMax,
consumeMessageBatchMaxSize
);
} catch (Exception e) {
log.error(LogConst.PREFIX + "初始化异常", e);
}
} // 消费者重启
private void restartConsumer(DefaultMQPushConsumer consumer, String consumerName) throws Exception {
String rocketMQAddr = AppEnvContext.getPropValue("rocketmq.address");
consumer.shutdown();
int consumeThreadMin = AppEnvContext.getPropValueIntOrDefault("delay.consumer.consumeThreadMin", 10);
int consumeThreadMax = AppEnvContext.getPropValueIntOrDefault("delay.consumer.consumeThreadMax", 10);
int consumeMessageBatchMaxSize = AppEnvContext.getPropValueIntOrDefault("delay.consumer.consumeMessageBatchMaxSize", 10);
consumer = Consumer.getInstance(
"TOPIC_DELAY_SEND",
consumerName,
rocketMQAddr,
currentDate,
consumeThreadMin,
consumeThreadMax,
consumeMessageBatchMaxSize
);
MessageListenerConcurrently listener = getDelayListener();
consumer.registerMessageListener(listener);
consumer.start();
closeCnt.incrementAndGet();
} // 重新初始化消费者
private synchronized void reInitComsumer(){
try {
String thisDate = DateUtil.operateDate(new Date(), 0);
int lastCloseCnt = closeCnt.get();
// 初始化的时候不执行 and 时间没跨度的时候不执行,只有当第二天的时候才执行数据
if(lastCloseCnt > 0 && !thisDate.equals(currentDate)){
// 获取今天的时间
currentDate = thisDate;
// 如果时间对2取余的值为1,则更新主消费者,之前的昨天(1月1日)更新为第二天(1月3日)的数据
if(closeCnt.get() % 2 == 1) {
restartConsumer(masterDelayConsumer, "MASTER_CONSUMER_TOPIC_DELAY_SEND");
}
// 如果时间对2取余的值为0,则更新从消费者,之前的今天(1月2日)更新为第三天(1月4日)的数据
if(closeCnt.get() % 2 == 0){
restartConsumer(slaveDelayConsumer, "SLAVE_CONSUMER_TOPIC_DELAY_SEND");
}
}
} catch (Exception e) {
log.error(LogConst.PREFIX + "消费者重新初始化异常", e);
}
} // 消费逻辑
private MessageListenerConcurrently getDelayListener(){
MessageListenerConcurrently listener = (List<MessageExt> list, ConsumeConcurrentlyContext context) -> {
for (MessageExt msg : list) {
String msgBody = null;
try {
msgBody = new String(msg.getBody());
String msgKeys = msg.getKeys();
// 订单号+发送时间戳
String[] split = msgKeys.split("\\+");
if(split.length == 2) {
String delaySendTimeStr = split[1];
long time = DateUtil.parseStr2TimeMills(delaySendTimeStr);
// 时间比对,获取最大支持的延迟级别
MessageDelayLevel maxLevel = MessageDelayLevel.getMaxLevel(time);
Integer level = maxLevel.getLevel();
// 如果延迟级别=1,也就是小于等于1秒的时候,直接发送到立即发送主题(TOPIC_NOW_SEND)
if (MessageDelayLevel.SECOND_1.getLevel().equals(level)) {
SendResult sendResult = ProducerUtil.syncSend(
defaultSendProducer,
"TOPIC_NOW_SEND",
msg.getTags(),
msg.getKeys(),
msgBody
);
log.info(LogConst.PREFIX + "定时消息[{}]发送到[立即发送主题]结果:{}", msgBody, sendResult);
} else {
SendResult sendResult = ProducerUtil.syncSendDelay(
defaultDelayProducer,
"TOPIC_DELAY_SEND",
msg.getTags(),
msg.getKeys(),
level,
msgBody
);
log.info(LogConst.PREFIX + "定时消息[{}]发送到[延迟发送主题]结果:{}", msgBody, sendResult);
}
}
} catch (Exception ex) {
log.error(LogConst.PREFIX + "定时消息[{}]发送异常", msgBody, ex);
}
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
};
return listener;
} @Override
public void run() {
try {
MessageListenerConcurrently masterListener = getDelayListener();
masterDelayConsumer.registerMessageListener(masterListener);
MessageListenerConcurrently slaveListener = getDelayListener();
slaveDelayConsumer.registerMessageListener(slaveListener);
// 启动消费者
masterDelayConsumer.start();
slaveDelayConsumer.start();
}catch (Exception ex){
log.error(LogConst.PREFIX + "定时消息消费程序启动异常", ex);
} while (true){
// 一分钟检查一次是否需要轮换消费者
ThreadUtil.sleep(60 * 1000);
reInitComsumer();
}
}
}

  3、其余的逻辑就不展示了,可以根据自己实际业务情况进行处理。

四、总结

  这是我实现的一个方式,其实是不太完美的,因为量大的时候性能也是有问题的,因为根据RocketMQ文件保存的时间(默认72小时)因素决定。如果保存的时间太长,消息太多,而我们的程序第一次启动从队列的最前位置开始消费,此时的消息数据是巨大的。不管是对RocketMQ组件,还是对我们程序都是一个压力,而且目前的消息过滤功能只能在客户端进行处理。所以,这里我建议的是一天一个主题,以定时发送的当天进行发送。例如:20220730需要发送的数据用TOPIC_DELAY_SEND_20220730主题;20220731需要发送的数据用TOPIC_DELAY_SEND_20220731主题。这样的话,数据消费的时候也不需要通过tags进行过滤了,只需要通过keys的时间戳数据进行判断,消费逻辑大致和文中写法一样,只需要调整延迟发送的主题名为【TOPIC_DELAY_SEND_YYYYMMDD】,发送和轮换的延迟发送主题也需要根据发送时间进行调整。

  以上思路仅供各位参考使用,有更好的实现方式可以在下方进行留言。谢谢大家的观看!衷心感谢!

【定时功能】消息的定时发送-基于RocketMQ的更多相关文章

  1. 基于 RocketMQ 的同城双活架构在美菜网的挑战与实践

    本文整理自李样兵在北京站 RocketMQ meetup分享美菜网使用 RocketMQ 过程中的一些心得和经验,偏重于实践. 嘉宾李样兵,现就职于美菜网基础服务平台组,负责 MQ ,配置中心和任务调 ...

  2. 阿里云RocketMQ定时/延迟消息队列实现

    新的阅读体验:http://www.zhouhong.icu/post/157 一.业务需求 需要实现一个提前二十分钟通知用户去做某件事的一个业务,拿到这个业务首先想到的最简单得方法就是使用Redis ...

  3. vbs脚本实现qq定时发消息(初级)

    vbs脚本实现QQ消息定时发送 目标 批处理又称为批处理脚本,强大的强大功能可以高效得实现很多功能,例如批量更改文件格式,批量进行文件读写,今天我们的目标是用vbs脚本编写可以发送qq消息的脚本,并利 ...

  4. 【分布式事务】基于RocketMQ搭建生产级消息集群?

    导读 目前很多互联网公司的系统都在朝着微服务化.分布式化系统的方向在演进,这带来了很多好处,也带来了一些棘手的问题,其中最棘手的莫过于数据一致性问题了.早期我们的软件功能都在一个进程中,数据的一致性可 ...

  5. Spring boot实战项目整合阿里云RocketMQ (非开源版)消息队列实现发送普通消息,延时消息 --附代码

    一.为什么选择RocketMQ消息队列? 首先RocketMQ是阿里巴巴自研出来的,也已开源.其性能和稳定性从双11就能看出来,借用阿里的一句官方介绍:历年双 11 购物狂欢节零点千万级 TPS.万亿 ...

  6. 【mq读书笔记】消息确认(失败消息,定时队列重新消费)

    接上文的集群模式,监听器返回RECONSUME_LATER,需要将将这些消息发送给Broker延迟消息.如果发送ack消息失败,将延迟5s后提交线程池进行消费. 入口:ConsumeMessageCo ...

  7. C#/Net定时导出Excel并定时发送到邮箱

    一.定时导出Excel并定时发送到邮箱   首先我们先分析一下该功能有多少个小的任务点:1.Windows计划服务 2.定时导出Excel定指定路径 3.定时发送邮件包含附件   接下来我们一个个解决 ...

  8. spring和Quartz的定时功能

    一:前沿 最近在做一个定时的功能,就是在一定时间内查询订单,然后告诉用户未付款,已付款等消息通知,而且要做集群的功能,这个集群的功能是指,我部署两套代码,其中一个定时的功能在运行,另外一个就不要运行. ...

  9. UniRx精讲(一):UniRx简介&定时功能实现

    1.UniRx 简介 UniRx 是一个 Unity3D 的编程框架.它专注于解决时间上异步的逻辑,使得异步逻辑的实现更加简洁和优雅. 简洁优雅如何体现? 比如,实现一个"只处理第一次鼠标点 ...

随机推荐

  1. vue - Vue路由

    至此基本上vue2.0的内容全部结束,后面还有点elementUI和vue3.0的内容过几天再来更新. 这几天要回学校去参加毕业答辩,断更几天 一.相关理解 是vue的一个插件库,专门用来实现spa( ...

  2. Volatile的学习

    首先先介绍三个性质 可见性 可见性代表主内存中变量更新,线程中可以及时获得最新的值. 下面例子证明了线程中可见性的问题 由于发现多次执行都要到主内存中取变量,所以会将变量缓存到线程的工作内存,这样当其 ...

  3. TS 自学笔记(二)装饰器

    TS 自学笔记(二)装饰器 本文写于 2020 年 9 月 15 日 上一篇 TS 文章已经是很久之前了.这次来讲一下 TS 的装饰器. 对于前端而言,装饰器是一个陌生的概念,但是对于 Java.C# ...

  4. 常见排序算法的golang 实现

    五种基础排序算法对比 五种基础排序算法对比 1:冒泡排序 算法描述 比较相邻的元素.如果第一个比第二个大,就交换它们两个: 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素 ...

  5. Docker部署mysql 5.7

    Docker部署mysql 5.7 准备工作 在CentOS或者Linux创建部署目录,用于存放容器的配置和MySQL数据:目的是当重装或者升级容器时,配置文件和数据不会丢失.执行以下命令: a.创建 ...

  6. 345. Reverse Vowels of a String - LeetCode

    Question 345. Reverse Vowels of a String Solution 思路:交换元音,第一次遍历,先把出现元音的索引位置记录下来,第二遍遍历元音的索引并替换. Java实 ...

  7. Kafka 的稳定性

    一.事务 1. 事务简介 1.1 事务场景 producer发的多条消息组成⼀个事务这些消息需要对consumer同时可⻅或者同时不可⻅ producer可能会给多个topic,多个partition ...

  8. Bika LIMS 开源LIMS集——实验室检验流程概述及主页、面板

    主页 主页左侧为功能入口菜单.右侧含待办提醒,中间为工作区. 工作区功能将主要工作页面置于首页,便于用户操作. Dashboard 面板 系统面板 包括待排定的实验任务.实验中的任务数.复核/审核中的 ...

  9. 实现领域驱动设计 - 使用ABP框架 - 通用准则

    在进入细节之前,让我们看看一些总体的 DDD 原则 数据库提供者 / ORM 无关性 领域和应用程序层应该与 ORM / 数据库提供程序 无关.它们应该只依赖于 Repository 接口,而 Rep ...

  10. ABAP CDS - DEFINE VIEW, name_list

    Syntax ... ( name1, name2, ... ) ... Effect Defines the element names of a CDS view in ABAP CDS in a ...