一、前言
说明: 我们有一个任务的情况下,我们会期望这个任务在某个时间点去执行,那么就要使用延迟队列。一般延迟队列可以使用RabbitMQ或者RocketMQ来进行实现,另外一种常用的方式就是使用Redis来进行实现了!
实现方案: 基于redis来进行实现,我们主要使用的是zset这个数据类型天生的具有score的特性。zset可以根据score放入,而且可以通过range进行排序获取,以及删除指定的值。从业务上,我们可以再新增任务的时候放入,再通过定时任务进行拉取,要注意的一点就是拉取的时候要有 分布式锁 ,不要进行重复拉取,或者交由 分布式任务调度来处理拉取,都是可以的。
使用场景: 我们更加偏向于 定时群发,定时取消 等。就举一个发博客的例子吧,博客我们可以选择定时发布,那么就可以应用redis的延迟队列来进行实现。要注意的一个点就是小心大key的产生,要做好延迟队列的key的隔离。
说明: 整体的源代码和测试类都放在业务类中,因为每个任务的延迟队列的实现都是不同的,所以没法抽成一个工具类,而其借助的Redis分布式锁和Redis数据写入读取的工具类已经被我们封装在了 ape-common-redis 包中!
二、代码实现
2.1 延迟任务的实体类
package com.ssm.user.delayQueue;
import lombok.Data;
import java.util.Date;
@Data
public class MassMailTask {
// 相关任务ID
private Long taskId;
// 延迟任务的开始时间
private Date startTime;
}
2.2 任务对延迟队列的推送方法和拉取的方法
实现思路:
入队: 入队消息体一定要有时间的概念,把时间转换为毫秒,来作为我们zset的score;底层就是zset的add方法,由key,value以及score来组成。
出队: 出队要基于rangeByScore来进行实现,指定我们的score的区间,也就是我们要拉取哪些的任务,拉取成功之后,我们先去执行业务逻辑,执行成功之后,我们再将其从消息队列进行删除。
package com.ssm.user.delayQueue;
import com.alibaba.druid.support.json.JSONUtils;
import com.ssm.redis.util.RedisUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.Date;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@Slf4j
public class MassMailTaskService {
@Resource
private RedisUtil redisUtil;
// 该MassMailTaskService类位于Redis中ZSet结构的key
private static final String MASS_MAIL_TASK_KEY = "massMailTask";
public void pushMassMailTaskQueue(MassMailTask massMailTask) { //入队,推送延迟任务
Date startTime = massMailTask.getStartTime();
if(startTime == null) {
return;
}
if(startTime.compareTo(new Date()) <= 0) { //开始时间小于等于当前时间,也不处理(compareTo比大小,小于为-1 等于为0)
return;
}
log.info("定时任务加入队列, massTask:{}", massMailTask);
//有序集合 Zadd key score1 member1 [score2 member2]
redisUtil.zAdd(MASS_MAIL_TASK_KEY, //key
massMailTask.getTaskId().toString(), //value为id
startTime.getTime() //score为当前任务开始时间的毫秒数
);
}
public Set<Long> poolMassTaskQueue() { //出队,拉取延迟任务
//获取有序集合中指定分数范围内的元素(zset默认有序)
Set<String> taskIdSet = redisUtil.rangeByScore(MASS_MAIL_TASK_KEY, 0, System.currentTimeMillis());
if(CollectionUtils.isEmpty(taskIdSet)) {
return Collections.emptySet();
}
//此时已得到所有要执行的定时任务了,把zset中的任务remove
redisUtil.removeZsetList(MASS_MAIL_TASK_KEY, taskIdSet);
//转为Long类型set
return taskIdSet.stream().map(n -> Long.parseLong(n)).collect(Collectors.toSet());
}
}
2.3 编写测试类
注意: 由于可能是分布式服务,所以可能是定时循环拉取,在拉取的时候不能所有服务的拉取都去拉,而是只允许一个任务去拉取,要么使用xxljob来实现,要么就选择分布式锁,只允许一个服务能够拉取并执行!
实际需求中通常配合xxl-job等定时任务来不断拉取延迟队列
package com.ssm.user;
import com.alibaba.druid.support.json.JSONUtils;
import com.ssm.redis.util.RedisShareRedLock;
import com.ssm.user.delayQueue.MassMailTask;
import com.ssm.user.delayQueue.MassMailTaskService;
import lombok.extern.slf4j.Slf4j;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import javax.annotation.Resource;
import java.text.SimpleDateFormat;
import java.util.Set;
import java.util.UUID;
@RunWith(SpringRunner.class)
@SpringBootTest(classes = UserApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Slf4j
public class MassMailTaskTest {
@Resource
private MassMailTaskService massMailTaskService;
@Resource
private RedisShareRedLock redisShareRedLock;
@Test
public void pushTask() throws Exception {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
MassMailTask massMailTask = new MassMailTask();
massMailTask.setTaskId(1L);
massMailTask.setStartTime(simpleDateFormat.parse("2024-09-12 09:55:00")); //利用自定义的时间格式设置延迟任务开始时间
massMailTaskService.pushMassMailTaskQueue(massMailTask); //入队,推送任务
}
@Test
public void deal() throws Exception {
String lockKey = "test.delay.task";
String requestId = UUID.randomUUID().toString(); //设置setnx的id,防止误删
try {
boolean lock = redisShareRedLock.lock(lockKey, requestId, 5L);
if(!lock) { //获取分布式锁失败,其他服务在执行
return;
}
Set<Long> taskIds = massMailTaskService.poolMassTaskQueue();
log.info("定时任务拉取:{}", JSONUtils.toJSONString(taskIds));
//执行其他业务逻辑
} catch (Exception e) {
log.error("拉取异常:{}", e.getMessage(), e);
} finally {
//释放锁
redisShareRedLock.unLock(lockKey, requestId);
}
}
}
2.4 测试结果
1) 第一步: 任务推送延迟队列
2) 第二步: 定时任务未到时间,直接进行拉取
3) 第三步: 定时任务到时间了,然后进行拉取
说明: 可以看到,到了时间之后,就可以正常拉取到定时任务!