1、延迟任务概述
延迟任务和定时任务的区别:
- 定时任务:有固定周期的,有明确的触发时间
- 延迟队列:没有固定的开始时间,它常常是由一个事件触发的,而在这个事件触发之后的一段时间内触发另一个事件,任务可以立即执行,也可以延迟。
业务场景:
- 订单下单之后30分钟后,如果用户没有付钱,则系统自动取消订单;如果期间下单成功,任务取消
- 接口对接出现网络问题,1分钟后重试,如果失败,2分钟重试,直到出现阈值终止
下面介绍几种实现延迟任务的技术。
2、延迟任务实现技术
2.1、DelayQueue
JDK自带DelayQueue 是一个支持延时获取元素的阻塞队列, 内部采用优先队列 PriorityQueue 存储元素,同时元素必须实现 Delayed 接口;在创建元素时可以指定多久才可以从队列中获取当前元素,只有在延迟期满时才能从队列中提取元素。
DelayQueue属于排序队列,它的特殊之处在于队列的元素必须实现Delayed接口,该接口需要实现compareTo和getDelay方法:
- getDelay方法:获取元素在队列中的剩余时间,只有当剩余时间为0时元素才可以出队列。
- compareTo方法:用于排序,确定元素出队列的顺序。
2.1.1 实现示例
1:在测试包jdk下创建延迟任务对象DelayedTask,实现Delayed接口,重写compareTo和getDelay方法。
2:在main方法中创建DelayQueue并向延迟队列中添加三个延迟任务,
3:循环的从延迟队列中拉取任务
public class DelayedTask implements Delayed{
// 任务的执行时间,秒
private int executeTime = 0;
public DelayedTask(int delay){
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.SECOND,delay);
this.executeTime = (int)(calendar.getTimeInMillis() /1000 );
}
/**
* 元素在队列中的剩余时间
* @param unit
* @return
*/
@Override
public long getDelay(TimeUnit unit) {
Calendar calendar = Calendar.getInstance();
return executeTime - (calendar.getTimeInMillis()/1000);
}
/**
* 元素排序,可以判断传入的delayed与当前的delayed排序先后关系
* @param o
* @return
*/
@Override
public int compareTo(Delayed o) {
long val = this.getDelay(TimeUnit.NANOSECONDS) - o.getDelay(TimeUnit.NANOSECONDS);
return val == 0 ? 0 : ( val < 0 ? -1: 1 );
}
public static void main(String[] args) {
DelayQueue<DelayedTask> queue = new DelayQueue<DelayedTask>();
queue.add(new DelayedTask(5));
queue.add(new DelayedTask(10));
queue.add(new DelayedTask(15));
System.out.println(System.currentTimeMillis()/1000+" 定时任务开始 ");
while(queue.size() != 0){
// 弹出一个元素
DelayedTask delayedTask = queue.poll();
if(delayedTask !=null ){
System.out.println(System.currentTimeMillis()/1000+" 消费了一个任务");
}
//每隔一秒消费一次
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
DelayQueue虽然可以实现延迟任务,但是缺点也很明显:
- 使用线程池或者原生DelayQueue程序,任务都是放在内存,挂掉之后需要考虑未处理消息的丢失带来的影响,如何保证数据不丢失,需要持久化(磁盘)。
2.2、MQ实现延迟任务
实现原理:
生产者在生产消息时,通过给消息设置消息存活时间,从而实现消息的延迟消费,当队列中的消息达到设置的存活时间时,就会转到死信队列,这时再通过消费者去消费私信对列中的消息即可。
- TTL:Time To Live (消息存活时间)
- 死信队列:Dead Letter Exchange(死信交换机),当消息成为Dead message后,可以重新发送另一个交换机(死信交换机)
示意图:
2.3、Redis实现延迟任务
Redis有五种数据类型:string,list,set,hash,zset;其中zset带分值的去重有序队列,使用zset数据类型的去重有序(分数排序)特点进行延迟;例如:时间戳作为分值score进行排序。
实现思路示意图:
思路解答:
- 为什么任务需要存储在数据库中?
延迟任务是一个通用的服务,任何需要延迟得任务都可以调用该服务,需要考虑数据持久化的问题,存储数据库中是一种数据安全的考虑。
- 为什么redis中使用两种数据类型,list和zset?
效率问题,算法的时间复杂度
- 在添加zset数据的时候,为什么不需要预加载?
任务模块是一个通用的模块,项目中任何需要延迟队列的地方,都可以调用这个接口,要考虑到数据量的问题,如果数据量特别大,为了防止阻塞,只需要把未来几分钟要执行的数据存入缓存即可。
综上所述,使用Redis实现延迟任务是比较可靠的选择。
3、Redis实现延迟任务代码示例
按照2.3介绍的Redis实现延迟任务的思路,给出代码示例。
3.1、添加延迟任务
/**
* 把任务添加到redis中,已省略保存任务到数据库的步骤
*
* @param task
*/
private void addTaskToCache(Task task) {
String key = task.getTaskType() + "_" + task.getPriority();
//获取5分钟之后的时间 毫秒值
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, 5);
long nextScheduleTime = calendar.getTimeInMillis();
//2.1 如果任务的执行时间小于等于当前时间,存入list
if (task.getExecuteTime() <= System.currentTimeMillis()) {
stringRedisTemplate.opsForList().leftPush( key, JSON.toJSONString(task));
} else if (task.getExecuteTime() <= nextScheduleTime) {
//2.2 如果任务的执行时间大于当前时间 && 小于等于预设时间(未来5分钟) 存入zset中
stringRedisTemplate.opsForZSet().add( key, JSON.toJSONString(task),task.getExecuteTime());
}
}
3.2、消费任务
/**
* 按照类型和优先级拉取任务
* @return
*/
@Override
public Task poll(int type,int priority) {
Task task = null;
try {
String key = type+"_"+priority;
String task_json = stringRedisTemplate.opsForList().rightPop(key);
if(StringUtils.isNotBlank(task_json)){
task = JSON.parseObject(task_json, Task.class);
.......此处省略执行任务相关代码
//更新数据库信息为已执行
updateDb(task.getTaskId(),ScheduleConstants.EXECUTED);
}
}catch (Exception e){
e.printStackTrace();
log.error("poll task exception");
}
return task;
}
3.3、待执行任务数据定时刷新
由于待执行的任务数据,是通过zset的数据结构保存到Redis,所以如何取出待执行的数据有以下两种方式:
3.3.1 批量查询:reids key值匹配
- 方案1:keys 模糊匹配
keys的模糊匹配功能很方便也很强大,但是在生产环境需要慎用!开发中使用keys的模糊匹配却发现redis的CPU使用率极高,redis是单线程,会被堵塞,所以有的公司的redis生产环境将keys命令禁用了。 - 方案2:scan
SCAN 命令是一个基于游标的迭代器,SCAN命令每次被调用之后, 都会向用户返回一个新的游标, 用户在下次迭代时需要使用这个新游标作为SCAN命令的游标参数, 以此来延续之前的迭代过程。
代码示例:
@Test
public void testKeys(){
// 模糊匹配
Set<String> keys = stringRedisTemplate.keys("future_*");
System.out.println(keys);
// scan扫描主键
Set<String> scan = stringRedisTemplate.execute((RedisCallback<Set<String>>) connection -> {
Set<String> result = new HashSet<>();
try (Cursor<byte[]> cursor = connection.scan(new ScanOptions.ScanOptionsBuilder()
.match(patten).count(10000).build())) {
while (cursor.hasNext()) {
result.add(new String(cursor.next()));
}
} catch (IOException e) {
e.printStackTrace();
}
return result;
});
System.out.println(scan);
}
3.3.2 批量写入:Redis管道
普通redis客户端和服务器交互模式:
Pipeline请求模型:
官方测试结果数据对比:
- 横坐标:数据量
- 纵坐标:用时(毫秒)
代码示例:
//耗时6151 使用普通方式
@Test
public void test1(){
long start =System.currentTimeMillis();
for (int i = 0; i <10000 ; i++) {
Task task = new Task();
task.setTaskType(1001);
task.setPriority(1);
task.setExecuteTime(new Date().getTime());
stringRedisTemplate.opsForList().leftPush("1001_1", JSON.toJSONString(task));
}
System.out.println("耗时"+(System.currentTimeMillis()- start));
}
//使用管道技术
@Test
public void test2(){
long start = System.currentTimeMillis();
List<Object> objectList = stringRedisTemplate().executePipelined(new RedisCallback<Object>() {
@Nullable
@Override
public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {
for (int i = 0; i <10000 ; i++) {
Task task = new Task();
task.setTaskType(1001);
task.setPriority(1);
task.setExecuteTime(new Date().getTime());
redisConnection.lPush("1001_1".getBytes(), JSON.toJSONString(task).getBytes());
}
return null;
}
});
System.out.println("使用管道技术执行10000次自增操作共耗时:"+(System.currentTimeMillis()-start)+"毫秒");
}
3.3.3 完成代码
综合上面介绍的scan扫描,和管道技术,完整的待执行任务数据定时刷新代码如下:
@Scheduled(cron = "0 */1 * * * ?")
public void refresh() {
// 获取所有未来数据集合的key值
Set<String> futureKeys = stringRedisTemplate.execute((RedisCallback<Set<String>>) connection -> {
Set<String> result = new HashSet<>();
try (Cursor<byte[]> cursor = connection.scan(new ScanOptions.ScanOptionsBuilder()
.match("future_" + "*").count(10000).build())) {
while (cursor.hasNext()) {
result.add(new String(cursor.next()));
}
} catch (IOException e) {
e.printStackTrace();
}
return result;
});
for (String futureKey : futureKeys) {
// 获取待执行任务list的key
String listKey = ScheduleConstants.TOPIC + futureKey.split(ScheduleConstants.FUTURE)[1];
//获取该组key下当前需要消费的任务数据,分值是小于当前时间的任务数据
Set<String> tasks = stringRedisTemplate.opsForZSet().rangeByScore(futureKey, 0, System.currentTimeMillis());
if (!tasks.isEmpty()) {
//将这些任务数据添加到list中
cacheService.refreshWithPipeline(futureKey, topicKey, tasks);
stringRedisTemplate.executePipelined(new RedisCallback<Object>() {
@Nullable
@Override
public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {
StringRedisConnection stringRedisConnection = (StringRedisConnection)redisConnection;
String[] strings = tasks.toArray(new String[tasks.size()]);
stringRedisConnection.lPush(topicKey,strings);
// Redis Zrem 命令用于移除有序集中的一个或多个成员,不存在的成员将被忽略。
stringRedisConnection.zRem(futureKey,strings);
return null;
}
});
}
}
}
以上就是如何实现延迟任务的技术实现方式,介绍了比较多的Redis的技术运用,欢迎大家点赞收藏,评论区交流。